Эх сурвалжийг харах

feat(api): implement camera and admin account management APIs

- Added comprehensive API documentation for camera control, including endpoints for listing, retrieving, and managing camera settings.
- Introduced admin account management APIs, covering functionalities for listing, adding, updating, and deleting accounts and roles.
- Enhanced user interface components for role and user management, improving usability and accessibility.
- Implemented e2e tests for system role and user management to ensure functionality and reliability.
yb 1 өдөр өмнө
parent
commit
071757aba4

+ 776 - 0
docs/api_torna/camera_control.md

@@ -0,0 +1,776 @@
+# 文档
+
+## default
+
+## 摄像头控制 Controller
+
+MVP API 接口:
+
+- GET /camera/list 获取摄像头列表
+- GET /camera/{id} 获取摄像头信息
+- POST /camera/switch 切换摄像头通道
+- GET /camera/current 获取当前通道
+
+后台 PTZ 接口(可选):
+
+- POST /camera/{id}/ptz/start 开始 PTZ 控制
+- POST /camera/{id}/ptz/stop 停止 PTZ 控制
+
+### 获取摄像头列表
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/list
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/list
+
+描述:获取摄像头列表
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称      | 类型   | 必填 | 最大长度 | 描述            | 示例值      |
+| --------- | ------ | ---- | -------- | --------------- | ----------- |
+| machineId | String | 否   | -        | 机器 ID(可选) | machine_001 |
+
+#### 请求示例
+
+```
+{
+    "machineId": "machine_001"
+}
+```
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | array | 否 |  | 响应数据 (ActualType: List) |  |
+| └ cameraId | String | 否 | - | 摄像头 ID | cam_001 |
+| └ name | String | 否 | - | 摄像头名称 | 主摄像头 |
+| └ lssId | String | 否 | - | 绑定的 LSS 节点 ID | lss_001 |
+| └ model | String | 否 | - | 摄像头型号 | DS-2CD2143G2-IS |
+| └ rtspUrl | String | 否 | - | RTSP 推流地址 | rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101 |
+| └ channelNo | String | 否 | - | 通道号 | 1 |
+| └ status | String | 否 | - | 摄像头状态: online, offline, connecting, failed, error, unknown | online |
+| └ capability | String | 否 | - | 摄像头能力: switch_only, ptz_enabled | ptz_enabled |
+| └ ptzSupported | Boolean | 否 | - | 是否支持 PTZ | true |
+| └ remark | String | 否 | - | 备注 |  |
+| └ enabled | Boolean | 否 | - | 是否启用 | true |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": [
+        {
+            "cameraId": "cam_001",
+            "name": "主摄像头",
+            "lssId": "lss_001",
+            "model": "DS-2CD2143G2-IS",
+            "rtspUrl": "rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
+            "channelNo": "1",
+            "status": "online",
+            "capability": "ptz_enabled",
+            "ptzSupported": true,
+            "remark": "string",
+            "enabled": true
+        }
+    ]
+}
+```
+
+#### 错误码
+
+无
+
+### 获取摄像头信息
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/camera/{cameraId}
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/camera/{cameraId}
+
+描述:获取摄像头信息
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值     |
+| -------- | ---- | --------- | ---------- |
+| cameraId | 是   | 摄像头 ID | camera_001 |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | object | 否 |  | 响应数据 (ActualType: CameraDTO) |  |
+| └ cameraId | String | 否 | - | 摄像头 ID | cam_001 |
+| └ name | String | 否 | - | 摄像头名称 | 主摄像头 |
+| └ lssId | String | 否 | - | 绑定的 LSS 节点 ID | lss_001 |
+| └ model | String | 否 | - | 摄像头型号 | DS-2CD2143G2-IS |
+| └ rtspUrl | String | 否 | - | RTSP 推流地址 | rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101 |
+| └ channelNo | String | 否 | - | 通道号 | 1 |
+| └ status | String | 否 | - | 摄像头状态: online, offline, connecting, failed, error, unknown | online |
+| └ capability | String | 否 | - | 摄像头能力: switch_only, ptz_enabled | ptz_enabled |
+| └ ptzSupported | Boolean | 否 | - | 是否支持 PTZ | true |
+| └ remark | String | 否 | - | 备注 |  |
+| └ enabled | Boolean | 否 | - | 是否启用 | true |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "cameraId": "cam_001",
+        "name": "主摄像头",
+        "lssId": "lss_001",
+        "model": "DS-2CD2143G2-IS",
+        "rtspUrl": "rtsp://admin:password@192.168.1.100:554/Streaming/Channels/101",
+        "channelNo": "1",
+        "status": "online",
+        "capability": "ptz_enabled",
+        "ptzSupported": true,
+        "remark": "string",
+        "enabled": true
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 切换摄像头通道(MVP 核心)
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/switch
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/switch
+
+描述:切换摄像头通道(MVP 核心)
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称      | 类型   | 必填 | 最大长度 | 描述        | 示例值      |
+| --------- | ------ | ---- | -------- | ----------- | ----------- |
+| machineId | String | 是   | -        | 机器 ID     | machine_001 |
+| channelId | String | 是   | -        | 目标通道 ID | ch_001      |
+
+#### 请求示例
+
+```
+{
+    "machineId": "machine_001",
+    "channelId": "ch_001"
+}
+```
+
+#### 响应参数
+
+| 名称          | 类型    | 必填 | 最大长度 | 描述                              | 示例值                           |
+| ------------- | ------- | ---- | -------- | --------------------------------- | -------------------------------- |
+| success       | Boolean | 否   | -        | 请求是否成功                      | true                             |
+| errCode       | String  | 否   | -        | 错误码(失败时返回)              |                                  |
+| errMessage    | String  | 否   | -        | 错误信息(失败时返回)            |                                  |
+| data          | object  | 否   |          | 响应数据 (ActualType: ChannelDTO) |                                  |
+| └ channelId   | String  | 否   | -        | 通道 ID                           | ch_001                           |
+| └ name        | String  | 否   | -        | 通道名称                          | 主通道                           |
+| └ rtspUrl     | String  | 否   | -        | RTSP 地址                         | rtsp://192.168.1.100:554/stream1 |
+| └ defaultView | Boolean | 否   | -        | 是否默认视角                      | true                             |
+| └ status      | String  | 否   | -        | 通道状态: ONLINE, OFFLINE         | ONLINE                           |
+| └ cameraId    | String  | 否   | -        | 所属摄像头 ID                     | cam_001                          |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "channelId": "ch_001",
+        "name": "主通道",
+        "rtspUrl": "rtsp://192.168.1.100:554/stream1",
+        "defaultView": true,
+        "status": "ONLINE",
+        "cameraId": "cam_001"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 获取当前活动通道
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/camera/current
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/camera/current
+
+描述:获取当前活动通道
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### 请求参数
+
+##### Query Parameter
+
+| 名称      | 类型   | 必填 | 最大长度 | 描述    | 示例值      |
+| --------- | ------ | ---- | -------- | ------- | ----------- |
+| machineId | string | 是   | -        | 机器 ID | machine_001 |
+
+#### 响应参数
+
+| 名称          | 类型    | 必填 | 最大长度 | 描述                              | 示例值                           |
+| ------------- | ------- | ---- | -------- | --------------------------------- | -------------------------------- |
+| success       | Boolean | 否   | -        | 请求是否成功                      | true                             |
+| errCode       | String  | 否   | -        | 错误码(失败时返回)              |                                  |
+| errMessage    | String  | 否   | -        | 错误信息(失败时返回)            |                                  |
+| data          | object  | 否   |          | 响应数据 (ActualType: ChannelDTO) |                                  |
+| └ channelId   | String  | 否   | -        | 通道 ID                           | ch_001                           |
+| └ name        | String  | 否   | -        | 通道名称                          | 主通道                           |
+| └ rtspUrl     | String  | 否   | -        | RTSP 地址                         | rtsp://192.168.1.100:554/stream1 |
+| └ defaultView | Boolean | 否   | -        | 是否默认视角                      | true                             |
+| └ status      | String  | 否   | -        | 通道状态: ONLINE, OFFLINE         | ONLINE                           |
+| └ cameraId    | String  | 否   | -        | 所属摄像头 ID                     | cam_001                          |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "channelId": "ch_001",
+        "name": "主通道",
+        "rtspUrl": "rtsp://192.168.1.100:554/stream1",
+        "defaultView": true,
+        "status": "ONLINE",
+        "cameraId": "cam_001"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 开始 PTZ 控制(后台专用)
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/{cameraId}/ptz/start
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/{cameraId}/ptz/start
+
+描述:开始 PTZ 控制(后台专用)
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Query Parameter
+
+| 名称   | 类型   | 必填 | 最大长度 | 描述        | 示例值 |
+| ------ | ------ | ---- | -------- | ----------- | ------ |
+| action | string | 是   | -        | PTZ 动作    | up     |
+| speed  | int32  | 是   | -        | 速度(1-100) | 50     |
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                        | 示例值 |
+| ---------- | ------- | ---- | -------- | --------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)        |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)      |        |
+| data       | object  | 否   | -        | 响应数据 (ActualType: Void) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {}
+}
+```
+
+#### 错误码
+
+无
+
+### 停止 PTZ 控制(后台专用)
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/{cameraId}/ptz/stop
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/{cameraId}/ptz/stop
+
+描述:停止 PTZ 控制(后台专用)
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                        | 示例值 |
+| ---------- | ------- | ---- | -------- | --------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)        |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)      |        |
+| data       | object  | 否   | -        | 响应数据 (ActualType: Void) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {}
+}
+```
+
+#### 错误码
+
+无
+
+### PTZ 直接控制(兼容前端 pan/tilt/zoom 方式)
+
+与前端海康调试接口一致:直接传 pan/tilt/zoom 值
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/{cameraId}/ptz/control
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/{cameraId}/ptz/control
+
+描述:PTZ 直接控制(兼容前端 pan/tilt/zoom 方式)
+
+与前端海康调试接口一致:直接传 pan/tilt/zoom 值
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称    | 类型    | 必填 | 最大长度 | 描述                                                   | 示例值 |
+| ------- | ------- | ---- | -------- | ------------------------------------------------------ | ------ |
+| pan     | Integer | 否   | 100      | 水平移动速度<br>负值向左,正值向右<br>范围: -100 ~ 100 | 0      |
+| tilt    | Integer | 否   | 100      | 垂直移动速度<br>负值向下,正值向上<br>范围: -100 ~ 100 | 0      |
+| zoom    | Integer | 否   | 100      | 缩放速度<br>负值缩小,正值放大<br>范围: -100 ~ 100     | 0      |
+| channel | Integer | 否   | -        | 通道号(可选,默认 1)                                 | 0      |
+
+#### 请求示例
+
+```
+{
+    "pan": 0,
+    "tilt": 0,
+    "zoom": 0,
+    "channel": 0
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                        | 示例值 |
+| ---------- | ------- | ---- | -------- | --------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)        |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)      |        |
+| data       | object  | 否   | -        | 响应数据 (ActualType: Void) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {}
+}
+```
+
+#### 错误码
+
+无
+
+### 获取预置位列表
+
+<p>异步获取摄像头的预置位列表,结果通过 Ably 推送返回。</p>
+<p>预置位是摄像头保存的固定位置点,可快速跳转到该位置。</p>
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/camera/control/{cameraId}/preset/list
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/camera/control/{cameraId}/preset/list
+
+描述:获取预置位列表
+
+<p>异步获取摄像头的预置位列表,结果通过 Ably 推送返回。</p>
+<p>预置位是摄像头保存的固定位置点,可快速跳转到该位置。</p>
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Query Parameter
+
+| 名称    | 类型  | 必填 | 最大长度 | 描述                                     | 示例值 |
+| ------- | ----- | ---- | -------- | ---------------------------------------- | ------ |
+| channel | int32 | 否   | -        | 通道号(可选,默认使用摄像头配置的通道) | 1      |
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                          | 示例值 |
+| ---------- | ------- | ---- | -------- | ----------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                  | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)          |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)        |        |
+| data       | string  | 否   | -        | 响应数据 (ActualType: String) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": "string"
+}
+```
+
+#### 错误码
+
+无
+
+### 跳转到预置位
+
+<p>控制摄像头快速移动到指定的预置位位置。</p>
+<p>预置位编号从 1 开始,需确保该预置位已存在。</p>
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/control/{cameraId}/preset/goto
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/control/{cameraId}/preset/goto
+
+描述:跳转到预置位
+
+<p>控制摄像头快速移动到指定的预置位位置。</p>
+<p>预置位编号从 1 开始,需确保该预置位已存在。</p>
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称        | 类型    | 必填 | 最大长度 | 描述       | 示例值 |
+| ----------- | ------- | ---- | -------- | ---------- | ------ |
+| presetIndex | Integer | 是   | -        | 预置位编号 | 1      |
+
+#### 请求示例
+
+```
+{
+    "presetIndex": 1
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                          | 示例值 |
+| ---------- | ------- | ---- | -------- | ----------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                  | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)          |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)        |        |
+| data       | string  | 否   | -        | 响应数据 (ActualType: String) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": "string"
+}
+```
+
+#### 错误码
+
+无
+
+### 设置预置位(保存当前位置)
+
+<p>将摄像头当前的 PTZ 位置保存为预置位。</p>
+<p>如果指定的预置位编号已存在,将覆盖原有设置。</p>
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/control/{cameraId}/preset/set
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/control/{cameraId}/preset/set
+
+描述:设置预置位(保存当前位置)
+
+<p>将摄像头当前的 PTZ 位置保存为预置位。</p>
+<p>如果指定的预置位编号已存在,将覆盖原有设置。</p>
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称        | 类型    | 必填 | 最大长度 | 描述                                      | 示例值 |
+| ----------- | ------- | ---- | -------- | ----------------------------------------- | ------ |
+| presetIndex | Integer | 是   | -        | 预置位编号                                | 1      |
+| presetName  | String  | 否   | 50       | 预置位名称(可选)<br>Validate[max: 50; ] | 位置 1 |
+
+#### 请求示例
+
+```
+{
+    "presetIndex": 1,
+    "presetName": "位置1"
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                          | 示例值 |
+| ---------- | ------- | ---- | -------- | ----------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                  | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)          |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)        |        |
+| data       | string  | 否   | -        | 响应数据 (ActualType: String) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": "string"
+}
+```
+
+#### 错误码
+
+无
+
+### 删除预置位
+
+<p>删除摄像头的指定预置位。</p>
+<p>删除后该预置位编号将不可用,直到重新设置。</p>
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/control/{cameraId}/preset/remove
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/control/{cameraId}/preset/remove
+
+描述:删除预置位
+
+<p>删除摄像头的指定预置位。</p>
+<p>删除后该预置位编号将不可用,直到重新设置。</p>
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值        |
+| -------- | ---- | --------- | ------------- |
+| cameraId | 是   | 摄像头 ID | hikvision_ptz |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称        | 类型    | 必填 | 最大长度 | 描述       | 示例值 |
+| ----------- | ------- | ---- | -------- | ---------- | ------ |
+| presetIndex | Integer | 是   | -        | 预置位编号 | 1      |
+
+#### 请求示例
+
+```
+{
+    "presetIndex": 1
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                          | 示例值 |
+| ---------- | ------- | ---- | -------- | ----------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                  | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)          |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)        |        |
+| data       | string  | 否   | -        | 响应数据 (ActualType: String) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": "string"
+}
+```
+
+#### 错误码
+
+无
+
+### 回放记忆路线(前端用户接口)
+
+<p>触发指定摄像头的记忆路线回放,摄像头将按录制顺序自动执行所有 PTZ 动作。</p>
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/camera/{cameraId}/patrol/play
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/camera/{cameraId}/patrol/play
+
+描述:回放记忆路线(前端用户接口)
+
+<p>触发指定摄像头的记忆路线回放,摄像头将按录制顺序自动执行所有 PTZ 动作。</p>
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称     | 必填 | 描述      | 示例值  |
+| -------- | ---- | --------- | ------- |
+| cameraId | 是   | 摄像头 ID | cam_001 |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称        | 类型    | 必填 | 最大长度 | 描述                                                   | 示例值   |
+| ----------- | ------- | ---- | -------- | ------------------------------------------------------ | -------- |
+| tourId      | String  | 是   | -        | 轨迹 ID                                                | tour_001 |
+| loopEnabled | Boolean | 否   | -        | 覆盖循环设置:是否循环执行(可选,不传则使用轨迹配置) | true     |
+| loopCount   | Integer | 否   | -        | 覆盖循环设置:循环次数(可选,不传则使用轨迹配置)     | 3        |
+
+#### 请求示例
+
+```
+{
+    "tourId": "tour_001",
+    "loopEnabled": true,
+    "loopCount": 3
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                          | 示例值 |
+| ---------- | ------- | ---- | -------- | ----------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                  | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)          |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)        |        |
+| data       | string  | 否   | -        | 响应数据 (ActualType: String) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": "string"
+}
+```
+
+#### 错误码
+
+无

+ 92 - 0
src/api/admin-account.ts

@@ -0,0 +1,92 @@
+import { get, post } from '@/utils/request'
+import type { IBaseResponse, IPageResponse, PageRequest } from '@/types'
+
+// ==================== 账号管理 API ====================
+
+// 角色简要信息
+export interface RoleSimpleDTO {
+  id: number
+  code: string
+  name: string
+}
+
+// 账号信息
+export interface AdminDTO {
+  id: number
+  username: string
+  nickname: string
+  enabled: boolean
+  lastLoginAt?: string
+  roles: RoleSimpleDTO[]
+  createdAt: string
+  updatedAt: string
+}
+
+// 账号列表请求参数
+export interface AccountListRequest extends PageRequest {
+  // 继承 PageRequest: page, size, keyword, enabled, sortBy, sortDir
+}
+
+// 新增账号请求
+export interface AccountAddRequest {
+  username: string
+  password: string
+  nickname?: string
+  roleIds?: number[]
+}
+
+// 更新账号请求
+export interface AccountUpdateRequest {
+  id: number
+  nickname?: string
+  password?: string
+  enabled?: boolean
+  roleIds?: number[]
+}
+
+// 批量删除请求
+export interface BatchDeleteRequest {
+  ids: number[]
+}
+
+/**
+ * 获取账号列表(分页)
+ */
+export function listAccounts(params: AccountListRequest): Promise<IPageResponse<AdminDTO>> {
+  return post('/admin/accounts/list', params)
+}
+
+/**
+ * 获取账号详情
+ */
+export function getAccountDetail(id: number): Promise<IBaseResponse<AdminDTO>> {
+  return get('/admin/accounts/detail', { id })
+}
+
+/**
+ * 新增账号
+ */
+export function addAccount(data: AccountAddRequest): Promise<IBaseResponse<AdminDTO>> {
+  return post('/admin/accounts/add', data)
+}
+
+/**
+ * 更新账号
+ */
+export function updateAccount(data: AccountUpdateRequest): Promise<IBaseResponse<AdminDTO>> {
+  return post('/admin/accounts/update', data)
+}
+
+/**
+ * 删除账号
+ */
+export function deleteAccount(id: number): Promise<IBaseResponse<void>> {
+  return post('/admin/accounts/delete', null, { params: { id } })
+}
+
+/**
+ * 批量删除账号
+ */
+export function batchDeleteAccounts(data: BatchDeleteRequest): Promise<IBaseResponse<number>> {
+  return post('/admin/accounts/deleteBatch', data)
+}

+ 98 - 0
src/api/admin-role.ts

@@ -0,0 +1,98 @@
+import { get, post } from '@/utils/request'
+import type { IBaseResponse, IPageResponse, IListResponse, PageRequest } from '@/types'
+
+// ==================== 角色管理 API ====================
+
+// 角色简要信息(下拉选择用)
+export interface RoleSimpleDTO {
+  id: number
+  code: string
+  name: string
+}
+
+// 角色详细信息
+export interface RoleDTO {
+  id: number
+  code: string
+  name: string
+  description?: string
+  enabled: boolean
+  userCount: number
+  createdAt: string
+  updatedAt: string
+}
+
+// 角色列表请求参数
+export interface RoleListRequest extends PageRequest {
+  // 继承 PageRequest: page, size, keyword, enabled, sortBy, sortDir
+}
+
+// 新增角色请求
+export interface RoleAddRequest {
+  code: string
+  name: string
+  description?: string
+}
+
+// 更新角色请求
+export interface RoleUpdateRequest {
+  id: number
+  code: string
+  name: string
+  description?: string
+  enabled?: boolean
+}
+
+// 批量删除请求
+export interface BatchDeleteRequest {
+  ids: number[]
+}
+
+/**
+ * 获取角色列表(分页)
+ */
+export function listRoles(params: RoleListRequest): Promise<IPageResponse<RoleDTO>> {
+  return post('/admin/roles/list', params)
+}
+
+/**
+ * 获取全部启用角色(用于下拉选择)
+ */
+export function listAllRoles(): Promise<IListResponse<RoleSimpleDTO>> {
+  return get('/admin/roles/listAll')
+}
+
+/**
+ * 获取角色详情
+ */
+export function getRoleDetail(id: number): Promise<IBaseResponse<RoleDTO>> {
+  return get('/admin/roles/detail', { id })
+}
+
+/**
+ * 新增角色
+ */
+export function addRole(data: RoleAddRequest): Promise<IBaseResponse<RoleDTO>> {
+  return post('/admin/roles/add', data)
+}
+
+/**
+ * 更新角色
+ */
+export function updateRole(data: RoleUpdateRequest): Promise<IBaseResponse<RoleDTO>> {
+  return post('/admin/roles/update', data)
+}
+
+/**
+ * 删除角色
+ */
+export function deleteRole(id: number): Promise<IBaseResponse<void>> {
+  return post('/admin/roles/delete', null, { params: { id } })
+}
+
+/**
+ * 批量删除角色
+ */
+export function batchDeleteRoles(data: BatchDeleteRequest): Promise<IBaseResponse<number>> {
+  return post('/admin/roles/deleteBatch', data)
+}

+ 145 - 263
src/views/system/role/index.vue

@@ -5,37 +5,30 @@
       <el-form :model="searchForm" inline data-id="search-form">
         <el-form-item>
           <el-input
-            v-model.trim="searchForm.roleName"
-            :placeholder="t('角色名称')"
+            v-model.trim="searchForm.keyword"
+            :placeholder="t('角色名称/编码')"
             clearable
+            data-id="search-keyword"
             @keyup.enter="handleSearch"
           />
         </el-form-item>
         <el-form-item>
-          <el-input
-            v-model.trim="searchForm.roleCode"
-            :placeholder="t('角色编码')"
-            clearable
-            @keyup.enter="handleSearch"
-          />
-        </el-form-item>
-        <el-form-item>
-          <el-select v-model="searchForm.status" :placeholder="t('状态')" clearable>
+          <el-select v-model="searchForm.enabled" :placeholder="t('状态')" clearable data-id="search-status">
             <el-option :label="t('全部')" value="" />
-            <el-option :label="t('启用')" value="enabled" />
-            <el-option :label="t('禁用')" value="disabled" />
+            <el-option :label="t('启用')" :value="true" />
+            <el-option :label="t('禁用')" :value="false" />
           </el-select>
         </el-form-item>
         <el-form-item>
-          <el-button type="primary" @click="handleSearch">
+          <el-button type="primary" data-id="btn-search" @click="handleSearch">
             <Icon icon="mdi:magnify" width="16" height="16" style="margin-right: 4px" />
             {{ t('查询') }}
           </el-button>
-          <el-button type="info" @click="handleReset">
+          <el-button type="info" data-id="btn-reset" @click="handleReset">
             <Icon icon="mdi:refresh" width="16" height="16" style="margin-right: 4px" />
             {{ t('重置') }}
           </el-button>
-          <el-button type="primary" @click="handleAdd">
+          <el-button type="primary" data-id="btn-add" @click="handleAdd">
             <Icon icon="mdi:plus" width="16" height="16" style="margin-right: 4px" />
             {{ t('新增') }}
           </el-button>
@@ -52,26 +45,21 @@
         stripe
         size="default"
         height="100%"
+        data-id="role-table"
         @sort-change="handleSortChange"
       >
         <el-table-column prop="id" :label="t('ID')" width="80" />
-        <el-table-column
-          prop="roleName"
-          :label="t('角色名称')"
-          min-width="120"
-          sortable="custom"
-          show-overflow-tooltip
-        />
-        <el-table-column prop="roleCode" :label="t('角色编码')" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="name" :label="t('角色名称')" min-width="120" sortable="custom" show-overflow-tooltip />
+        <el-table-column prop="code" :label="t('角色编码')" min-width="120" show-overflow-tooltip />
         <el-table-column :label="t('用户数')" width="100" align="center">
           <template #default="{ row }">
-            <el-tag type="info" size="small">{{ row.userCount }}</el-tag>
+            <el-tag type="info" size="small">{{ row.userCount || 0 }}</el-tag>
           </template>
         </el-table-column>
         <el-table-column :label="t('状态')" width="100" align="center">
           <template #default="{ row }">
-            <el-tag :type="row.status === 'enabled' ? 'success' : 'danger'" size="small">
-              {{ row.status === 'enabled' ? t('启用') : t('禁用') }}
+            <el-tag :type="row.enabled ? 'success' : 'danger'" size="small">
+              {{ row.enabled ? t('启用') : t('禁用') }}
             </el-tag>
           </template>
         </el-table-column>
@@ -81,15 +69,18 @@
             {{ formatTime(row.createdAt) }}
           </template>
         </el-table-column>
-        <el-table-column :label="t('操作')" width="150" align="center" fixed="right">
+        <el-table-column :label="t('操作')" width="120" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link @click="handleEdit(row)">
+            <el-button type="primary" link data-id="btn-edit" @click="handleEdit(row)">
               <Icon icon="mdi:note-edit-outline" width="20" height="20" />
             </el-button>
-            <el-button type="primary" link @click="handlePermission(row)">
-              <Icon icon="mdi:shield-key" width="20" height="20" />
-            </el-button>
-            <el-button type="danger" link :disabled="row.roleCode === 'admin'" @click="handleDelete(row)">
+            <el-button
+              type="danger"
+              link
+              :disabled="row.code === 'ADMIN'"
+              data-id="btn-delete"
+              @click="handleDelete(row)"
+            >
               <Icon icon="mdi:delete" width="20" height="20" />
             </el-button>
           </template>
@@ -106,6 +97,7 @@
         :total="total"
         layout="total, sizes, prev, pager, next, jumper"
         background
+        data-id="pagination"
         @size-change="handleSizeChange"
         @current-change="handleCurrentChange"
       />
@@ -119,65 +111,47 @@
       size="500px"
       :close-on-click-modal="false"
       destroy-on-close
+      data-id="role-drawer"
     >
       <el-form ref="formRef" :model="form" :rules="rules" label-width="auto">
         <div class="role-form-container">
-          <el-form-item :label="t('角色名称')" prop="roleName">
-            <el-input v-model="form.roleName" :placeholder="t('请输入角色名称')" />
+          <el-form-item :label="t('角色名称')" prop="name">
+            <el-input v-model="form.name" :placeholder="t('请输入角色名称')" data-id="input-name" />
           </el-form-item>
-          <el-form-item :label="t('角色编码')" prop="roleCode">
-            <el-input v-model="form.roleCode" :disabled="isEdit" :placeholder="t('请输入角色编码')" />
+          <el-form-item :label="t('角色编码')" prop="code">
+            <el-input
+              v-model="form.code"
+              :disabled="isEdit"
+              :placeholder="t('请输入角色编码(大写字母、数字、下划线)')"
+              data-id="input-code"
+            />
           </el-form-item>
-          <el-form-item :label="t('排序')" prop="sort">
-            <el-input-number v-model="form.sort" :min="0" :max="999" />
-          </el-form-item>
-          <el-form-item :label="t('状态')" prop="status">
-            <el-radio-group v-model="form.status">
-              <el-radio value="enabled">{{ t('启用') }}</el-radio>
-              <el-radio value="disabled">{{ t('禁用') }}</el-radio>
+          <el-form-item :label="t('状态')" prop="enabled">
+            <el-radio-group v-model="form.enabled" data-id="radio-enabled">
+              <el-radio :value="true">{{ t('启用') }}</el-radio>
+              <el-radio :value="false">{{ t('禁用') }}</el-radio>
             </el-radio-group>
           </el-form-item>
           <el-form-item :label="t('描述')" prop="description">
-            <el-input v-model="form.description" type="textarea" :rows="3" :placeholder="t('请输入描述')" />
+            <el-input
+              v-model="form.description"
+              type="textarea"
+              :rows="3"
+              :placeholder="t('请输入描述')"
+              data-id="input-description"
+            />
           </el-form-item>
         </div>
       </el-form>
       <template #footer>
         <div class="drawer-footer">
-          <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
-          <el-button type="primary" :loading="submitting" @click="handleSubmit">
+          <el-button data-id="btn-cancel" @click="drawerVisible = false">{{ t('取消') }}</el-button>
+          <el-button type="primary" :loading="submitting" data-id="btn-submit" @click="handleSubmit">
             {{ isEdit ? t('更新') : t('添加') }}
           </el-button>
         </div>
       </template>
     </el-drawer>
-
-    <!-- 权限配置抽屉 -->
-    <el-drawer
-      v-model="permissionDrawerVisible"
-      :title="`${t('权限配置')} - ${currentRole?.roleName || ''}`"
-      direction="rtl"
-      size="500px"
-      :close-on-click-modal="false"
-      destroy-on-close
-    >
-      <el-tree
-        ref="treeRef"
-        :data="permissionTree"
-        show-checkbox
-        node-key="id"
-        :default-checked-keys="checkedPermissions"
-        :props="{ label: 'name', children: 'children' }"
-      />
-      <template #footer>
-        <div class="drawer-footer">
-          <el-button @click="permissionDrawerVisible = false">{{ t('取消') }}</el-button>
-          <el-button type="primary" :loading="permissionSubmitting" @click="handleSavePermission">
-            {{ t('保存') }}
-          </el-button>
-        </div>
-      </template>
-    </el-drawer>
   </div>
 </template>
 
@@ -188,115 +162,18 @@ import { Icon } from '@iconify/vue'
 import type { FormInstance, FormRules } from 'element-plus'
 import { useI18n } from 'vue-i18n'
 import { formatTime } from '@/utils/dayjs'
+import { listRoles, addRole, updateRole, deleteRole, type RoleDTO, type RoleListRequest } from '@/api/admin-role'
 
 const { t } = useI18n({ useScope: 'global' })
 
-// Mock 数据
-interface Role {
-  id: number
-  roleName: string
-  roleCode: string
-  userCount: number
-  status: 'enabled' | 'disabled'
-  description: string
-  sort: number
-  createdAt: string
-}
-
-interface Permission {
-  id: number
-  name: string
-  children?: Permission[]
-}
-
-const mockRoles: Role[] = [
-  {
-    id: 1,
-    roleName: '管理员',
-    roleCode: 'admin',
-    userCount: 1,
-    status: 'enabled',
-    description: '系统管理员,拥有所有权限',
-    sort: 0,
-    createdAt: '2024-01-01T10:00:00Z'
-  },
-  {
-    id: 2,
-    roleName: '操作员',
-    roleCode: 'operator',
-    userCount: 2,
-    status: 'enabled',
-    description: '可以操作设备和查看数据',
-    sort: 1,
-    createdAt: '2024-01-15T14:30:00Z'
-  },
-  {
-    id: 3,
-    roleName: '查看者',
-    roleCode: 'viewer',
-    userCount: 3,
-    status: 'enabled',
-    description: '只能查看数据,无法操作',
-    sort: 2,
-    createdAt: '2024-02-01T09:00:00Z'
-  },
-  {
-    id: 4,
-    roleName: '测试角色',
-    roleCode: 'test',
-    userCount: 0,
-    status: 'disabled',
-    description: '测试用角色',
-    sort: 99,
-    createdAt: '2024-02-15T16:00:00Z'
-  }
-]
-
-const mockPermissions: Permission[] = [
-  {
-    id: 1,
-    name: '仪表盘',
-    children: [{ id: 11, name: '查看仪表盘' }]
-  },
-  {
-    id: 2,
-    name: 'LSS 管理',
-    children: [
-      { id: 21, name: '查看 LSS 列表' },
-      { id: 22, name: '编辑 LSS' },
-      { id: 23, name: '删除 LSS' }
-    ]
-  },
-  {
-    id: 3,
-    name: '设备管理',
-    children: [
-      { id: 31, name: '查看设备列表' },
-      { id: 32, name: '添加设备' },
-      { id: 33, name: '编辑设备' },
-      { id: 34, name: '删除设备' }
-    ]
-  },
-  {
-    id: 4,
-    name: '系统管理',
-    children: [
-      { id: 41, name: '用户管理' },
-      { id: 42, name: '角色管理' }
-    ]
-  }
-]
-
 const loading = ref(false)
-const roleList = ref<Role[]>([])
+const roleList = ref<RoleDTO[]>([])
 const tableRef = ref()
-const treeRef = ref()
 
 // 搜索表单
-const searchForm = reactive({
-  roleName: '',
-  roleCode: '',
-  status: '' as '' | 'enabled' | 'disabled'
+const searchForm = reactive<RoleListRequest>({
+  keyword: '',
+  enabled: undefined
 })
 
 // 分页
@@ -315,60 +192,60 @@ const drawerVisible = ref(false)
 const isEdit = ref(false)
 const submitting = ref(false)
 const formRef = ref<FormInstance>()
-const currentRole = ref<Role | null>(null)
-
-// 权限配置
-const permissionDrawerVisible = ref(false)
-const permissionSubmitting = ref(false)
-const permissionTree = ref<Permission[]>(mockPermissions)
-const checkedPermissions = ref<number[]>([])
+const currentRole = ref<RoleDTO | null>(null)
 
 // 表单
 const form = reactive({
-  roleName: '',
-  roleCode: '',
-  sort: 0,
-  status: 'enabled' as 'enabled' | 'disabled',
+  name: '',
+  code: '',
+  enabled: true,
   description: ''
 })
 
+// 角色编码正则:大写字母开头,只允许大写字母、数字和下划线
+const codePattern = /^[A-Z][A-Z0-9_]*$/
+
 // 表单验证规则
 const rules = computed<FormRules>(() => ({
-  roleName: [{ required: true, message: t('请输入角色名称'), trigger: 'blur' }],
-  roleCode: [{ required: true, message: t('请输入角色编码'), trigger: 'blur' }]
+  name: [
+    { required: true, message: t('请输入角色名称'), trigger: 'blur' },
+    { max: 100, message: t('角色名称最多100个字符'), trigger: 'blur' }
+  ],
+  code: [
+    { required: true, message: t('请输入角色编码'), trigger: 'blur' },
+    { max: 50, message: t('角色编码最多50个字符'), trigger: 'blur' },
+    {
+      pattern: codePattern,
+      message: t('角色编码必须以大写字母开头,只允许大写字母、数字和下划线'),
+      trigger: 'blur'
+    }
+  ],
+  description: [{ max: 500, message: t('描述最多500个字符'), trigger: 'blur' }]
 }))
 
 // 获取列表
 async function getList() {
   loading.value = true
   try {
-    await new Promise((resolve) => setTimeout(resolve, 300))
-
-    let filtered = [...mockRoles]
-
-    // 搜索过滤
-    if (searchForm.roleName) {
-      filtered = filtered.filter((r) => r.roleName.includes(searchForm.roleName))
-    }
-    if (searchForm.roleCode) {
-      filtered = filtered.filter((r) => r.roleCode.includes(searchForm.roleCode))
-    }
-    if (searchForm.status) {
-      filtered = filtered.filter((r) => r.status === searchForm.status)
+    const params: RoleListRequest = {
+      page: currentPage.value,
+      size: pageSize.value,
+      keyword: searchForm.keyword || undefined,
+      enabled: searchForm.enabled,
+      sortBy: sortState.sortBy || undefined,
+      sortDir: sortState.sortDir || undefined
     }
 
-    // 排序
-    if (sortState.sortBy) {
-      filtered.sort((a, b) => {
-        const aVal = a[sortState.sortBy as keyof Role] as string
-        const bVal = b[sortState.sortBy as keyof Role] as string
-        return sortState.sortDir === 'ASC' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
-      })
+    const res = await listRoles(params)
+    if (res.success && res.data) {
+      roleList.value = res.data.list
+      total.value = res.data.total
+    } else {
+      ElMessage.error(res.errMessage || t('获取数据失败'))
     }
-
-    total.value = filtered.length
-    const start = (currentPage.value - 1) * pageSize.value
-    roleList.value = filtered.slice(start, start + pageSize.value)
+  } catch (error) {
+    console.error('Failed to load roles:', error)
+    ElMessage.error(t('获取数据失败'))
   } finally {
     loading.value = false
   }
@@ -380,9 +257,8 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.roleName = ''
-  searchForm.roleCode = ''
-  searchForm.status = ''
+  searchForm.keyword = ''
+  searchForm.enabled = undefined
   currentPage.value = 1
   getList()
 }
@@ -405,10 +281,9 @@ function handleCurrentChange(val: number) {
 }
 
 function resetForm() {
-  form.roleName = ''
-  form.roleCode = ''
-  form.sort = 0
-  form.status = 'enabled'
+  form.name = ''
+  form.code = ''
+  form.enabled = true
   form.description = ''
   formRef.value?.clearValidate()
 }
@@ -420,14 +295,13 @@ function handleAdd() {
   drawerVisible.value = true
 }
 
-function handleEdit(row: Role) {
+function handleEdit(row: RoleDTO) {
   isEdit.value = true
   currentRole.value = row
-  form.roleName = row.roleName
-  form.roleCode = row.roleCode
-  form.sort = row.sort
-  form.status = row.status
-  form.description = row.description
+  form.name = row.name
+  form.code = row.code
+  form.enabled = row.enabled
+  form.description = row.description || ''
   drawerVisible.value = true
 }
 
@@ -439,65 +313,72 @@ async function handleSubmit() {
 
     submitting.value = true
     try {
-      await new Promise((resolve) => setTimeout(resolve, 500))
-      ElMessage.success(isEdit.value ? t('更新成功') : t('添加成功'))
-      drawerVisible.value = false
-      getList()
+      if (isEdit.value && currentRole.value) {
+        const res = await updateRole({
+          id: currentRole.value.id,
+          code: form.code,
+          name: form.name,
+          description: form.description || undefined,
+          enabled: form.enabled
+        })
+        if (res.success) {
+          ElMessage.success(t('更新成功'))
+          drawerVisible.value = false
+          getList()
+        } else {
+          ElMessage.error(res.errMessage || t('更新失败'))
+        }
+      } else {
+        const res = await addRole({
+          code: form.code,
+          name: form.name,
+          description: form.description || undefined
+        })
+        if (res.success) {
+          ElMessage.success(t('添加成功'))
+          drawerVisible.value = false
+          getList()
+        } else {
+          ElMessage.error(res.errMessage || t('添加失败'))
+        }
+      }
+    } catch (error) {
+      console.error('Submit error:', error)
+      ElMessage.error(t('操作失败'))
     } finally {
       submitting.value = false
     }
   })
 }
 
-async function handleDelete(row: Role) {
-  if (row.roleCode === 'admin') {
+async function handleDelete(row: RoleDTO) {
+  if (row.code === 'ADMIN') {
     ElMessage.warning(t('管理员角色不能删除'))
     return
   }
 
   try {
     await ElMessageBox.confirm(
-      `你确定要删除这个角色吗?<br/><br/>角色名称:${row.roleName}<br/>角色编码:${row.roleCode}`,
+      `${t('确定要删除角色吗?')}<br/><br/>${t('角色名称')}:${row.name}<br/>${t('角色编码')}:${row.code}`,
       t('提示'),
       {
         type: 'warning',
         dangerouslyUseHTMLString: true
       }
     )
-    await new Promise((resolve) => setTimeout(resolve, 300))
-    ElMessage.success(t('删除成功'))
-    getList()
+
+    const res = await deleteRole(row.id)
+    if (res.success) {
+      ElMessage.success(t('删除成功'))
+      getList()
+    } else {
+      ElMessage.error(res.errMessage || t('删除失败'))
+    }
   } catch {
     // 取消
   }
 }
 
-function handlePermission(row: Role) {
-  currentRole.value = row
-  // Mock 已选权限
-  if (row.roleCode === 'admin') {
-    checkedPermissions.value = [11, 21, 22, 23, 31, 32, 33, 34, 41, 42]
-  } else if (row.roleCode === 'operator') {
-    checkedPermissions.value = [11, 21, 22, 31, 32, 33]
-  } else if (row.roleCode === 'viewer') {
-    checkedPermissions.value = [11, 21, 31]
-  } else {
-    checkedPermissions.value = []
-  }
-  permissionDrawerVisible.value = true
-}
-
-async function handleSavePermission() {
-  permissionSubmitting.value = true
-  try {
-    await new Promise((resolve) => setTimeout(resolve, 500))
-    ElMessage.success(t('权限配置保存成功'))
-    permissionDrawerVisible.value = false
-  } finally {
-    permissionSubmitting.value = false
-  }
-}
-
 onMounted(() => {
   getList()
 })
@@ -518,7 +399,7 @@ onMounted(() => {
   flex-shrink: 0;
   margin-bottom: 16px;
   padding: 16px 16px 4px 16px;
-  background: #f5f7fa;
+  background: var(--bg-hover);
 
   :deep(.el-form-item) {
     margin-bottom: 12px;
@@ -548,5 +429,6 @@ onMounted(() => {
   display: flex;
   justify-content: flex-end;
   gap: 12px;
+  padding: 0 16px;
 }
 </style>

+ 286 - 197
src/views/system/user/index.vue

@@ -5,32 +5,30 @@
       <el-form :model="searchForm" inline data-id="search-form">
         <el-form-item>
           <el-input
-            v-model.trim="searchForm.username"
-            :placeholder="t('用户名')"
+            v-model.trim="searchForm.keyword"
+            :placeholder="t('用户名/昵称')"
             clearable
+            data-id="search-keyword"
             @keyup.enter="handleSearch"
           />
         </el-form-item>
         <el-form-item>
-          <el-input v-model.trim="searchForm.realName" :placeholder="t('姓名')" clearable @keyup.enter="handleSearch" />
-        </el-form-item>
-        <el-form-item>
-          <el-select v-model="searchForm.status" :placeholder="t('状态')" clearable>
-            <el-option :label="t('全部')" value="" />
-            <el-option :label="t('启用')" value="enabled" />
-            <el-option :label="t('禁用')" value="disabled" />
+          <el-select v-model="searchForm.enabled" :placeholder="t('状态')" clearable data-id="search-status">
+            <el-option :label="t('全部')" :value="undefined" />
+            <el-option :label="t('启用')" :value="true" />
+            <el-option :label="t('禁用')" :value="false" />
           </el-select>
         </el-form-item>
         <el-form-item>
-          <el-button type="primary" @click="handleSearch">
+          <el-button type="primary" data-id="btn-search" @click="handleSearch">
             <Icon icon="mdi:magnify" width="16" height="16" style="margin-right: 4px" />
             {{ t('查询') }}
           </el-button>
-          <el-button type="info" @click="handleReset">
+          <el-button type="info" data-id="btn-reset" @click="handleReset">
             <Icon icon="mdi:refresh" width="16" height="16" style="margin-right: 4px" />
             {{ t('重置') }}
           </el-button>
-          <el-button type="primary" @click="handleAdd">
+          <el-button type="primary" data-id="btn-add" @click="handleAdd">
             <Icon icon="mdi:plus" width="16" height="16" style="margin-right: 4px" />
             {{ t('新增') }}
           </el-button>
@@ -47,41 +45,46 @@
         stripe
         size="default"
         height="100%"
+        data-id="user-table"
         @sort-change="handleSortChange"
       >
         <el-table-column prop="id" :label="t('ID')" width="80" />
         <el-table-column prop="username" :label="t('用户名')" min-width="120" sortable="custom" show-overflow-tooltip />
-        <el-table-column prop="realName" :label="t('姓名')" min-width="120" show-overflow-tooltip />
-        <el-table-column prop="email" :label="t('邮箱')" min-width="180" show-overflow-tooltip />
-        <el-table-column prop="phone" :label="t('手机号')" min-width="130" show-overflow-tooltip />
-        <el-table-column :label="t('角色')" min-width="120">
+        <el-table-column prop="nickname" :label="t('昵称')" min-width="120" show-overflow-tooltip />
+        <el-table-column :label="t('角色')" min-width="150">
           <template #default="{ row }">
-            <el-tag v-for="role in row.roles" :key="role" size="small" style="margin-right: 4px">
-              {{ role }}
+            <el-tag v-for="role in row.roles" :key="role.id" size="small" style="margin-right: 4px">
+              {{ role.name }}
             </el-tag>
+            <span v-if="!row.roles?.length" class="text-placeholder">-</span>
           </template>
         </el-table-column>
         <el-table-column :label="t('状态')" width="100" align="center">
           <template #default="{ row }">
-            <el-tag :type="row.status === 'enabled' ? 'success' : 'danger'" size="small">
-              {{ row.status === 'enabled' ? t('启用') : t('禁用') }}
+            <el-tag :type="row.enabled ? 'success' : 'danger'" size="small">
+              {{ row.enabled ? t('启用') : t('禁用') }}
             </el-tag>
           </template>
         </el-table-column>
+        <el-table-column :label="t('最后登录')" min-width="160">
+          <template #default="{ row }">
+            {{ row.lastLoginAt ? formatTime(row.lastLoginAt) : '-' }}
+          </template>
+        </el-table-column>
         <el-table-column :label="t('创建时间')" min-width="160">
           <template #default="{ row }">
             {{ formatTime(row.createdAt) }}
           </template>
         </el-table-column>
-        <el-table-column :label="t('操作')" width="150" align="center" fixed="right">
+        <el-table-column :label="t('操作')" width="160" align="center" fixed="right">
           <template #default="{ row }">
-            <el-button type="primary" link @click="handleEdit(row)">
+            <el-button type="primary" link data-id="btn-edit" @click="handleEdit(row)">
               <Icon icon="mdi:note-edit-outline" width="20" height="20" />
             </el-button>
-            <el-button type="primary" link @click="handleResetPassword(row)">
-              <Icon icon="mdi:lock-reset" width="20" height="20" />
+            <el-button type="warning" link data-id="btn-assign-role" @click="handleAssignRole(row)">
+              <Icon icon="mdi:account-key" width="20" height="20" />
             </el-button>
-            <el-button type="danger" link @click="handleDelete(row)">
+            <el-button type="danger" link data-id="btn-delete" @click="handleDelete(row)">
               <Icon icon="mdi:delete" width="20" height="20" />
             </el-button>
           </template>
@@ -98,6 +101,7 @@
         :total="total"
         layout="total, sizes, prev, pager, next, jumper"
         background
+        data-id="pagination"
         @size-change="handleSizeChange"
         @current-change="handleCurrentChange"
       />
@@ -111,49 +115,110 @@
       size="500px"
       :close-on-click-modal="false"
       destroy-on-close
+      data-id="user-drawer"
     >
       <el-form ref="formRef" :model="form" :rules="rules" label-width="auto">
         <div class="role-form-container">
           <el-form-item :label="t('用户名')" prop="username">
-            <el-input v-model="form.username" :disabled="isEdit" :placeholder="t('请输入用户名')" />
+            <el-input
+              v-model="form.username"
+              :disabled="isEdit"
+              :placeholder="t('请输入用户名')"
+              data-id="input-username"
+            />
           </el-form-item>
-          <el-form-item :label="t('姓名')" prop="realName">
-            <el-input v-model="form.realName" :placeholder="t('请输入姓名')" />
+          <el-form-item :label="t('昵称')" prop="nickname">
+            <el-input v-model="form.nickname" :placeholder="t('请输入昵称')" data-id="input-nickname" />
           </el-form-item>
-          <el-form-item :label="t('邮箱')" prop="email">
-            <el-input v-model="form.email" :placeholder="t('请输入邮箱')" />
-          </el-form-item>
-          <el-form-item :label="t('手机号')" prop="phone">
-            <el-input v-model="form.phone" :placeholder="t('请输入手机号')" />
-          </el-form-item>
-          <el-form-item :label="t('角色')" prop="roles">
-            <el-select v-model="form.roles" multiple :placeholder="t('请选择角色')" style="width: 100%">
-              <el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
+          <el-form-item :label="t('角色')" prop="roleIds">
+            <el-select
+              v-model="form.roleIds"
+              multiple
+              :placeholder="t('请选择角色')"
+              style="width: 100%"
+              data-id="select-roles"
+            >
+              <el-option v-for="role in roleOptions" :key="role.id" :label="role.name" :value="role.id" />
             </el-select>
           </el-form-item>
           <el-form-item v-if="!isEdit" :label="t('密码')" prop="password">
-            <el-input v-model="form.password" type="password" show-password :placeholder="t('请输入密码')" />
+            <el-input
+              v-model="form.password"
+              type="password"
+              show-password
+              :placeholder="t('请输入密码')"
+              data-id="input-password"
+            />
           </el-form-item>
-          <el-form-item :label="t('状态')" prop="status">
-            <el-radio-group v-model="form.status">
-              <el-radio value="enabled">{{ t('启用') }}</el-radio>
-              <el-radio value="disabled">{{ t('禁用') }}</el-radio>
-            </el-radio-group>
+          <el-form-item v-if="isEdit" :label="t('新密码')" prop="password">
+            <el-input
+              v-model="form.password"
+              type="password"
+              show-password
+              :placeholder="t('留空则不修改密码')"
+              data-id="input-new-password"
+            />
           </el-form-item>
-          <el-form-item :label="t('备注')" prop="remark">
-            <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="t('请输入备注')" />
+          <el-form-item :label="t('状态')" prop="enabled">
+            <el-radio-group v-model="form.enabled" data-id="radio-enabled">
+              <el-radio :value="true">{{ t('启用') }}</el-radio>
+              <el-radio :value="false">{{ t('禁用') }}</el-radio>
+            </el-radio-group>
           </el-form-item>
         </div>
       </el-form>
       <template #footer>
         <div class="drawer-footer">
-          <el-button @click="drawerVisible = false">{{ t('取消') }}</el-button>
-          <el-button type="primary" :loading="submitting" @click="handleSubmit">
+          <el-button data-id="btn-cancel" @click="drawerVisible = false">{{ t('取消') }}</el-button>
+          <el-button type="primary" :loading="submitting" data-id="btn-submit" @click="handleSubmit">
             {{ isEdit ? t('更新') : t('添加') }}
           </el-button>
         </div>
       </template>
     </el-drawer>
+
+    <!-- 分配角色对话框 -->
+    <el-dialog
+      v-model="roleDialogVisible"
+      :title="t('分配角色')"
+      width="500px"
+      :close-on-click-modal="false"
+      destroy-on-close
+      data-id="role-dialog"
+    >
+      <div class="role-dialog-content">
+        <div class="user-info">
+          <span class="label">{{ t('用户') }}:</span>
+          <span class="value">{{ assignRoleUser?.username }} ({{ assignRoleUser?.nickname || '-' }})</span>
+        </div>
+        <el-form label-width="80px">
+          <el-form-item :label="t('角色')">
+            <el-select
+              v-model="assignRoleId"
+              :placeholder="t('请选择角色')"
+              style="width: 100%"
+              clearable
+              data-id="assign-role-select"
+            >
+              <el-option v-for="role in roleOptions" :key="role.id" :label="role.name" :value="role.id" />
+            </el-select>
+          </el-form-item>
+        </el-form>
+      </div>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button data-id="btn-role-cancel" @click="roleDialogVisible = false">{{ t('取消') }}</el-button>
+          <el-button
+            type="primary"
+            :loading="assigningRole"
+            data-id="btn-role-confirm"
+            @click="handleConfirmAssignRole"
+          >
+            {{ t('确定') }}
+          </el-button>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -164,84 +229,26 @@ import { Icon } from '@iconify/vue'
 import type { FormInstance, FormRules } from 'element-plus'
 import { useI18n } from 'vue-i18n'
 import { formatTime } from '@/utils/dayjs'
+import {
+  listAccounts,
+  addAccount,
+  updateAccount,
+  deleteAccount,
+  type AdminDTO,
+  type AccountListRequest
+} from '@/api/admin-account'
+import { listAllRoles, type RoleSimpleDTO } from '@/api/admin-role'
 
 const { t } = useI18n({ useScope: 'global' })
 
-// Mock 数据
-interface User {
-  id: number
-  username: string
-  realName: string
-  email: string
-  phone: string
-  roles: string[]
-  status: 'enabled' | 'disabled'
-  createdAt: string
-  remark?: string
-}
-
-const mockUsers: User[] = [
-  {
-    id: 1,
-    username: 'admin',
-    realName: '系统管理员',
-    email: 'admin@example.com',
-    phone: '13800138000',
-    roles: ['管理员'],
-    status: 'enabled',
-    createdAt: '2024-01-01T10:00:00Z'
-  },
-  {
-    id: 2,
-    username: 'operator',
-    realName: '张三',
-    email: 'zhangsan@example.com',
-    phone: '13800138001',
-    roles: ['操作员'],
-    status: 'enabled',
-    createdAt: '2024-01-15T14:30:00Z'
-  },
-  {
-    id: 3,
-    username: 'viewer',
-    realName: '李四',
-    email: 'lisi@example.com',
-    phone: '13800138002',
-    roles: ['查看者'],
-    status: 'enabled',
-    createdAt: '2024-02-01T09:00:00Z'
-  },
-  {
-    id: 4,
-    username: 'test',
-    realName: '王五',
-    email: 'wangwu@example.com',
-    phone: '13800138003',
-    roles: ['操作员', '查看者'],
-    status: 'disabled',
-    createdAt: '2024-02-15T16:00:00Z'
-  },
-  {
-    id: 5,
-    username: 'user001',
-    realName: '赵六',
-    email: 'zhaoliu@example.com',
-    phone: '13800138004',
-    roles: ['查看者'],
-    status: 'enabled',
-    createdAt: '2024-03-01T11:30:00Z'
-  }
-]
-
 const loading = ref(false)
-const userList = ref<User[]>([])
+const userList = ref<AdminDTO[]>([])
 const tableRef = ref()
 
 // 搜索表单
-const searchForm = reactive({
-  username: '',
-  realName: '',
-  status: '' as '' | 'enabled' | 'disabled'
+const searchForm = reactive<AccountListRequest>({
+  keyword: '',
+  enabled: undefined
 })
 
 // 分页
@@ -260,68 +267,74 @@ const drawerVisible = ref(false)
 const isEdit = ref(false)
 const submitting = ref(false)
 const formRef = ref<FormInstance>()
-const currentUser = ref<User | null>(null)
+const currentUser = ref<AdminDTO | null>(null)
 
 // 角色选项
-const roleOptions = [
-  { label: '管理员', value: '管理员' },
-  { label: '操作员', value: '操作员' },
-  { label: '查看者', value: '查看者' }
-]
+const roleOptions = ref<RoleSimpleDTO[]>([])
+
+// 分配角色对话框
+const roleDialogVisible = ref(false)
+const assignRoleUser = ref<AdminDTO | null>(null)
+const assignRoleId = ref<number | undefined>(undefined)
+const assigningRole = ref(false)
 
 // 表单
 const form = reactive({
   username: '',
-  realName: '',
-  email: '',
-  phone: '',
-  roles: [] as string[],
+  nickname: '',
+  roleIds: [] as number[],
   password: '',
-  status: 'enabled' as 'enabled' | 'disabled',
-  remark: ''
+  enabled: true
 })
 
 // 表单验证规则
 const rules = computed<FormRules>(() => ({
-  username: [{ required: true, message: t('请输入用户名'), trigger: 'blur' }],
-  realName: [{ required: true, message: t('请输入姓名'), trigger: 'blur' }],
-  email: [{ type: 'email', message: t('请输入正确的邮箱'), trigger: 'blur' }],
-  roles: [{ required: true, message: t('请选择角色'), trigger: 'change' }],
-  password: [{ required: !isEdit.value, message: t('请输入密码'), trigger: 'blur' }]
+  username: [
+    { required: true, message: t('请输入用户名'), trigger: 'blur' },
+    { max: 50, message: t('用户名最多50个字符'), trigger: 'blur' }
+  ],
+  nickname: [{ max: 50, message: t('昵称最多50个字符'), trigger: 'blur' }],
+  password: [
+    { required: !isEdit.value, message: t('请输入密码'), trigger: 'blur' },
+    { max: 100, message: t('密码最多100个字符'), trigger: 'blur' }
+  ]
 }))
 
+// 加载角色选项
+async function loadRoleOptions() {
+  try {
+    const res = await listAllRoles()
+    if (res.success && res.data) {
+      roleOptions.value = res.data
+    }
+  } catch (error) {
+    console.error('Failed to load roles:', error)
+  }
+}
+
 // 获取列表
 async function getList() {
   loading.value = true
   try {
-    // 模拟 API 延迟
-    await new Promise((resolve) => setTimeout(resolve, 300))
-
-    let filtered = [...mockUsers]
-
-    // 搜索过滤
-    if (searchForm.username) {
-      filtered = filtered.filter((u) => u.username.includes(searchForm.username))
-    }
-    if (searchForm.realName) {
-      filtered = filtered.filter((u) => u.realName.includes(searchForm.realName))
-    }
-    if (searchForm.status) {
-      filtered = filtered.filter((u) => u.status === searchForm.status)
+    const params: AccountListRequest = {
+      page: currentPage.value,
+      size: pageSize.value,
+      keyword: searchForm.keyword || undefined,
+      enabled: searchForm.enabled,
+      sortBy: sortState.sortBy || undefined,
+      sortDir: sortState.sortDir || undefined
     }
 
-    // 排序
-    if (sortState.sortBy) {
-      filtered.sort((a, b) => {
-        const aVal = a[sortState.sortBy as keyof User] as string
-        const bVal = b[sortState.sortBy as keyof User] as string
-        return sortState.sortDir === 'ASC' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
-      })
+    const res = await listAccounts(params)
+    if (res.success && res.data) {
+      userList.value = res.data.list
+      total.value = res.data.total
+    } else {
+      ElMessage.error(res.errMessage || t('获取数据失败'))
     }
-
-    total.value = filtered.length
-    const start = (currentPage.value - 1) * pageSize.value
-    userList.value = filtered.slice(start, start + pageSize.value)
+  } catch (error) {
+    console.error('Failed to load accounts:', error)
+    ElMessage.error(t('获取数据失败'))
   } finally {
     loading.value = false
   }
@@ -333,9 +346,8 @@ function handleSearch() {
 }
 
 function handleReset() {
-  searchForm.username = ''
-  searchForm.realName = ''
-  searchForm.status = ''
+  searchForm.keyword = ''
+  searchForm.enabled = undefined
   currentPage.value = 1
   getList()
 }
@@ -359,13 +371,10 @@ function handleCurrentChange(val: number) {
 
 function resetForm() {
   form.username = ''
-  form.realName = ''
-  form.email = ''
-  form.phone = ''
-  form.roles = []
+  form.nickname = ''
+  form.roleIds = []
   form.password = ''
-  form.status = 'enabled'
-  form.remark = ''
+  form.enabled = true
   formRef.value?.clearValidate()
 }
 
@@ -376,16 +385,14 @@ function handleAdd() {
   drawerVisible.value = true
 }
 
-function handleEdit(row: User) {
+function handleEdit(row: AdminDTO) {
   isEdit.value = true
   currentUser.value = row
   form.username = row.username
-  form.realName = row.realName
-  form.email = row.email
-  form.phone = row.phone
-  form.roles = [...row.roles]
-  form.status = row.status
-  form.remark = row.remark || ''
+  form.nickname = row.nickname || ''
+  form.roleIds = row.roles?.map((r) => r.id) || []
+  form.password = ''
+  form.enabled = row.enabled
   drawerVisible.value = true
 }
 
@@ -397,47 +404,100 @@ async function handleSubmit() {
 
     submitting.value = true
     try {
-      await new Promise((resolve) => setTimeout(resolve, 500))
-      ElMessage.success(isEdit.value ? t('更新成功') : t('添加成功'))
-      drawerVisible.value = false
-      getList()
+      if (isEdit.value && currentUser.value) {
+        const res = await updateAccount({
+          id: currentUser.value.id,
+          nickname: form.nickname || undefined,
+          password: form.password || undefined,
+          enabled: form.enabled,
+          roleIds: form.roleIds.length > 0 ? form.roleIds : undefined
+        })
+        if (res.success) {
+          ElMessage.success(t('更新成功'))
+          drawerVisible.value = false
+          getList()
+        } else {
+          ElMessage.error(res.errMessage || t('更新失败'))
+        }
+      } else {
+        const res = await addAccount({
+          username: form.username,
+          password: form.password,
+          nickname: form.nickname || undefined,
+          roleIds: form.roleIds.length > 0 ? form.roleIds : undefined
+        })
+        if (res.success) {
+          ElMessage.success(t('添加成功'))
+          drawerVisible.value = false
+          getList()
+        } else {
+          ElMessage.error(res.errMessage || t('添加失败'))
+        }
+      }
+    } catch (error) {
+      console.error('Submit error:', error)
+      ElMessage.error(t('操作失败'))
     } finally {
       submitting.value = false
     }
   })
 }
 
-async function handleDelete(row: User) {
+function handleAssignRole(row: AdminDTO) {
+  assignRoleUser.value = row
+  assignRoleId.value = row.roles?.[0]?.id
+  roleDialogVisible.value = true
+}
+
+async function handleConfirmAssignRole() {
+  if (!assignRoleUser.value) return
+
+  assigningRole.value = true
+  try {
+    const res = await updateAccount({
+      id: assignRoleUser.value.id,
+      roleIds: assignRoleId.value ? [assignRoleId.value] : []
+    })
+    if (res.success) {
+      ElMessage.success(t('角色分配成功'))
+      roleDialogVisible.value = false
+      getList()
+    } else {
+      ElMessage.error(res.errMessage || t('角色分配失败'))
+    }
+  } catch (error) {
+    console.error('Assign role error:', error)
+    ElMessage.error(t('角色分配失败'))
+  } finally {
+    assigningRole.value = false
+  }
+}
+
+async function handleDelete(row: AdminDTO) {
   try {
     await ElMessageBox.confirm(
-      `你确定要删除这个用户吗?<br/><br/>用户名:${row.username}<br/>姓名:${row.realName}`,
+      `${t('确定要删除用户吗?')}<br/><br/>${t('用户名')}:${row.username}<br/>${t('昵称')}:${row.nickname || '-'}`,
       t('提示'),
       {
         type: 'warning',
         dangerouslyUseHTMLString: true
       }
     )
-    await new Promise((resolve) => setTimeout(resolve, 300))
-    ElMessage.success(t('删除成功'))
-    getList()
-  } catch {
-    // 取消
-  }
-}
 
-async function handleResetPassword(row: User) {
-  try {
-    await ElMessageBox.confirm(`确定要重置用户 "${row.username}" 的密码吗?`, t('提示'), {
-      type: 'warning'
-    })
-    await new Promise((resolve) => setTimeout(resolve, 300))
-    ElMessage.success(t('密码已重置为默认密码'))
+    const res = await deleteAccount(row.id)
+    if (res.success) {
+      ElMessage.success(t('删除成功'))
+      getList()
+    } else {
+      ElMessage.error(res.errMessage || t('删除失败'))
+    }
   } catch {
     // 取消
   }
 }
 
 onMounted(() => {
+  loadRoleOptions()
   getList()
 })
 </script>
@@ -457,7 +517,7 @@ onMounted(() => {
   flex-shrink: 0;
   margin-bottom: 16px;
   padding: 16px 16px 4px 16px;
-  background: #f5f7fa;
+  background: var(--bg-hover);
 
   :deep(.el-form-item) {
     margin-bottom: 12px;
@@ -487,5 +547,34 @@ onMounted(() => {
   display: flex;
   justify-content: flex-end;
   gap: 12px;
+  padding: 0 16px;
+}
+
+.text-placeholder {
+  color: var(--text-placeholder);
+}
+
+.role-dialog-content {
+  .user-info {
+    margin-bottom: 20px;
+    padding: 12px 16px;
+    background: var(--bg-hover);
+    border-radius: 4px;
+
+    .label {
+      color: var(--text-secondary);
+      margin-right: 8px;
+    }
+
+    .value {
+      font-weight: 500;
+    }
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
 }
 </style>

+ 309 - 0
tests/e2e/system-role.spec.ts

@@ -0,0 +1,309 @@
+import { test, expect, type Page } from '@playwright/test'
+
+// 测试账号配置
+const TEST_USERNAME = process.env.TEST_USERNAME || 'admin'
+const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456'
+
+// 生成唯一的角色编码
+const generateRoleCode = () => `TEST_${Date.now()}`
+
+// data-id 选择器辅助函数
+const byDataId = (id: string) => `[data-id="${id}"]`
+
+test.describe('系统角色管理 CRUD 测试', () => {
+  // 登录辅助函数
+  async function login(page: Page) {
+    await page.goto('/login')
+    await page.evaluate(() => {
+      localStorage.clear()
+      document.cookie.split(';').forEach((c) => {
+        document.cookie = c.replace(/^ +/, '').replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`)
+      })
+    })
+    await page.reload()
+
+    await page.getByPlaceholder('用户名').fill(TEST_USERNAME)
+    await page.getByPlaceholder('密码').fill(TEST_PASSWORD)
+    await page.getByRole('button', { name: '登录' }).click()
+    await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 })
+  }
+
+  test('角色管理页面正确显示', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 验证页面元素
+    await expect(page.locator(byDataId('btn-add'))).toBeVisible()
+    await expect(page.locator(byDataId('btn-search'))).toBeVisible()
+    await expect(page.locator(byDataId('btn-reset'))).toBeVisible()
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+
+    // 验证表头
+    await expect(page.locator('thead').getByText('ID')).toBeVisible()
+    await expect(page.locator('thead').getByText('角色名称')).toBeVisible()
+    await expect(page.locator('thead').getByText('角色编码')).toBeVisible()
+    await expect(page.locator('thead').getByText('用户数')).toBeVisible()
+    await expect(page.locator('thead').getByText('状态')).toBeVisible()
+    await expect(page.locator('thead').getByText('描述')).toBeVisible()
+  })
+
+  test('查询和重置功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+
+    // 输入搜索关键词
+    await page.locator(byDataId('search-keyword')).fill('ADMIN')
+
+    // 点击查询按钮
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+
+    // 点击重置按钮
+    await page.locator(byDataId('btn-reset')).click()
+    await page.waitForTimeout(500)
+
+    // 验证输入框被清空
+    await expect(page.locator(byDataId('search-keyword'))).toHaveValue('')
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+  })
+
+  test('新增角色完整流程', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    const roleCode = generateRoleCode()
+    const roleName = `测试角色_${Date.now()}`
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+
+    // 验证抽屉打开
+    const drawer = page.locator(byDataId('role-drawer'))
+    await expect(drawer).toBeVisible()
+    await expect(page.locator('.el-drawer__title')).toContainText('新增角色')
+
+    // 填写表单
+    await drawer.locator(byDataId('input-name')).fill(roleName)
+    await drawer.locator(byDataId('input-code')).fill(roleCode)
+    await drawer.locator(byDataId('input-description')).fill('E2E测试创建的角色')
+
+    // 等待提交按钮可用后点击
+    const submitBtn = page.locator(byDataId('btn-submit'))
+    await expect(submitBtn).toBeEnabled()
+    await submitBtn.click()
+
+    // 等待成功消息或抽屉关闭
+    const successPromise = page
+      .locator('.el-message--success')
+      .waitFor({ timeout: 10000 })
+      .catch(() => null)
+    const drawerClosePromise = expect(drawer).not.toBeVisible({ timeout: 10000 })
+
+    await Promise.race([successPromise, drawerClosePromise])
+
+    // 如果抽屉关闭了,说明操作已完成
+    await expect(drawer).not.toBeVisible({ timeout: 5000 })
+  })
+
+  test('编辑角色功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+    await page.waitForTimeout(1000)
+
+    // 点击第一行的编辑按钮
+    const editBtn = page.locator(byDataId('btn-edit')).first()
+
+    // 等待数据加载
+    await expect(editBtn).toBeVisible({ timeout: 10000 })
+
+    await editBtn.click()
+
+    // 验证抽屉打开
+    const drawer = page.locator(byDataId('role-drawer'))
+    await expect(drawer).toBeVisible()
+    await expect(page.locator('.el-drawer__title')).toContainText('编辑角色')
+
+    // 修改名称
+    const nameInput = drawer.locator(byDataId('input-name'))
+    await nameInput.clear()
+    await nameInput.fill(`编辑测试角色_${Date.now()}`)
+
+    // 修改描述
+    const descInput = drawer.locator(byDataId('input-description'))
+    await descInput.clear()
+    await descInput.fill('E2E编辑测试')
+
+    // 等待提交按钮可用后点击
+    const submitBtn = page.locator(byDataId('btn-submit'))
+    await expect(submitBtn).toBeEnabled()
+    await submitBtn.click()
+
+    // 验证抽屉关闭
+    await expect(drawer).not.toBeVisible({ timeout: 15000 })
+  })
+
+  test('删除角色功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+    await page.waitForTimeout(1000)
+
+    // 找到一个可删除的角色(非 ADMIN)的删除按钮
+    const deleteBtn = page.locator(`${byDataId('btn-delete')}:not([disabled])`).first()
+
+    // 如果没有可删除的角色,跳过此测试
+    const count = await deleteBtn.count()
+    if (count === 0) {
+      test.skip()
+      return
+    }
+
+    await deleteBtn.click()
+
+    // 确认删除对话框出现
+    await expect(page.locator('.el-message-box')).toBeVisible()
+    await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click()
+
+    // 等待确认对话框关闭
+    await expect(page.locator('.el-message-box')).not.toBeVisible({ timeout: 10000 })
+
+    // 验证表格仍然可见
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+  })
+
+  test('新增角色表单验证', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+    await expect(page.locator(byDataId('role-drawer'))).toBeVisible()
+
+    // 直接点击确定,不填写任何内容
+    await page.locator(byDataId('btn-submit')).click()
+
+    // 验证显示验证错误
+    const drawer = page.locator(byDataId('role-drawer'))
+    await expect(drawer.getByText('请输入角色名称')).toBeVisible()
+    await expect(drawer.getByText('请输入角色编码')).toBeVisible()
+  })
+
+  test('角色编码格式验证', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+    const drawer = page.locator(byDataId('role-drawer'))
+    await expect(drawer).toBeVisible()
+
+    // 填写角色名称
+    await drawer.locator(byDataId('input-name')).fill('测试角色')
+
+    // 填写不合法的角色编码(小写字母)
+    await drawer.locator(byDataId('input-code')).fill('test_role')
+
+    // 点击提交
+    await page.locator(byDataId('btn-submit')).click()
+
+    // 验证显示格式错误
+    await expect(drawer.getByText(/大写字母/)).toBeVisible({ timeout: 5000 })
+  })
+
+  test('取消新增角色', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+    const drawer = page.locator(byDataId('role-drawer'))
+    await expect(drawer).toBeVisible()
+
+    // 填写部分内容
+    await drawer.locator(byDataId('input-name')).fill('测试取消')
+    await drawer.locator(byDataId('input-code')).fill('TEST_CANCEL')
+
+    // 点击取消
+    await page.locator(byDataId('btn-cancel')).click()
+
+    // 验证抽屉关闭
+    await expect(drawer).not.toBeVisible()
+  })
+
+  test('从侧边栏导航到角色管理', async ({ page }) => {
+    await login(page)
+
+    // 展开系统管理菜单
+    await page.getByText('系统管理').first().click()
+    await page.waitForTimeout(300)
+
+    // 点击角色管理菜单项
+    await page.getByText('角色管理').first().click()
+
+    // 验证跳转到角色管理页面
+    await expect(page).toHaveURL(/\/system\/role/)
+    await expect(page.locator(byDataId('btn-add'))).toBeVisible()
+  })
+
+  test('状态筛选功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+
+    // 选择启用状态
+    await page.locator(byDataId('search-status')).click()
+    await page.getByText('启用').click()
+
+    // 点击查询
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+
+    // 选择禁用状态
+    await page.locator(byDataId('search-status')).click()
+    await page.getByText('禁用').click()
+
+    // 点击查询
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+  })
+
+  test('ADMIN 角色不能删除', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/role')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('role-table'))).toBeVisible()
+    await page.waitForTimeout(1000)
+
+    // 找到 ADMIN 角色行的删除按钮
+    const adminRow = page.locator('tr').filter({ hasText: 'ADMIN' })
+    const adminDeleteBtn = adminRow.locator(byDataId('btn-delete'))
+
+    // 如果找到了 ADMIN 角色,验证其删除按钮是禁用的
+    const count = await adminDeleteBtn.count()
+    if (count > 0) {
+      await expect(adminDeleteBtn).toBeDisabled()
+    }
+  })
+})

+ 257 - 0
tests/e2e/system-user.spec.ts

@@ -0,0 +1,257 @@
+import { test, expect, type Page } from '@playwright/test'
+
+// 测试账号配置
+const TEST_USERNAME = process.env.TEST_USERNAME || 'admin'
+const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456'
+
+// 生成唯一的用户名
+const generateUsername = () => `test_${Date.now()}`
+
+// data-id 选择器辅助函数
+const byDataId = (id: string) => `[data-id="${id}"]`
+
+test.describe('系统用户管理 CRUD 测试', () => {
+  // 登录辅助函数
+  async function login(page: Page) {
+    await page.goto('/login')
+    await page.evaluate(() => {
+      localStorage.clear()
+      document.cookie.split(';').forEach((c) => {
+        document.cookie = c.replace(/^ +/, '').replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`)
+      })
+    })
+    await page.reload()
+
+    await page.getByPlaceholder('用户名').fill(TEST_USERNAME)
+    await page.getByPlaceholder('密码').fill(TEST_PASSWORD)
+    await page.getByRole('button', { name: '登录' }).click()
+    await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 })
+  }
+
+  test('用户管理页面正确显示', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 验证页面元素
+    await expect(page.locator(byDataId('btn-add'))).toBeVisible()
+    await expect(page.locator(byDataId('btn-search'))).toBeVisible()
+    await expect(page.locator(byDataId('btn-reset'))).toBeVisible()
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+
+    // 验证表头
+    await expect(page.locator('thead').getByText('ID')).toBeVisible()
+    await expect(page.locator('thead').getByText('用户名')).toBeVisible()
+    await expect(page.locator('thead').getByText('昵称')).toBeVisible()
+    await expect(page.locator('thead').getByText('角色')).toBeVisible()
+    await expect(page.locator('thead').getByText('状态')).toBeVisible()
+  })
+
+  test('查询和重置功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+
+    // 输入搜索关键词
+    await page.locator(byDataId('search-keyword')).fill('admin')
+
+    // 点击查询按钮
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+
+    // 点击重置按钮
+    await page.locator(byDataId('btn-reset')).click()
+    await page.waitForTimeout(500)
+
+    // 验证输入框被清空
+    await expect(page.locator(byDataId('search-keyword'))).toHaveValue('')
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+  })
+
+  test('新增用户完整流程', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    const username = generateUsername()
+    const nickname = `测试用户_${Date.now()}`
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+
+    // 验证抽屉打开
+    const drawer = page.locator(byDataId('user-drawer'))
+    await expect(drawer).toBeVisible()
+    await expect(page.locator('.el-drawer__title')).toContainText('新增用户')
+
+    // 填写表单
+    await drawer.locator(byDataId('input-username')).fill(username)
+    await drawer.locator(byDataId('input-nickname')).fill(nickname)
+    await drawer.locator(byDataId('input-password')).fill('Test123456')
+
+    // 等待提交按钮可用后点击
+    const submitBtn = page.locator(byDataId('btn-submit'))
+    await expect(submitBtn).toBeEnabled()
+    await submitBtn.click()
+
+    // 等待成功消息或抽屉关闭
+    const successPromise = page
+      .locator('.el-message--success')
+      .waitFor({ timeout: 10000 })
+      .catch(() => null)
+    const drawerClosePromise = expect(drawer).not.toBeVisible({ timeout: 10000 })
+
+    await Promise.race([successPromise, drawerClosePromise])
+
+    // 如果抽屉关闭了,说明操作已完成
+    await expect(drawer).not.toBeVisible({ timeout: 5000 })
+  })
+
+  test('编辑用户功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+    await page.waitForTimeout(1000)
+
+    // 点击第一行的编辑按钮
+    const editBtn = page.locator(byDataId('btn-edit')).first()
+
+    // 等待数据加载
+    await expect(editBtn).toBeVisible({ timeout: 10000 })
+
+    await editBtn.click()
+
+    // 验证抽屉打开
+    const drawer = page.locator(byDataId('user-drawer'))
+    await expect(drawer).toBeVisible()
+    await expect(page.locator('.el-drawer__title')).toContainText('编辑用户')
+
+    // 修改昵称
+    const nicknameInput = drawer.locator(byDataId('input-nickname'))
+    await nicknameInput.clear()
+    await nicknameInput.fill(`编辑测试_${Date.now()}`)
+
+    // 等待提交按钮可用后点击
+    const submitBtn = page.locator(byDataId('btn-submit'))
+    await expect(submitBtn).toBeEnabled()
+    await submitBtn.click()
+
+    // 验证抽屉关闭
+    await expect(drawer).not.toBeVisible({ timeout: 15000 })
+  })
+
+  test('删除用户功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+    await page.waitForTimeout(1000)
+
+    // 找到第一个删除按钮并点击
+    const deleteBtn = page.locator(byDataId('btn-delete')).first()
+
+    // 确保有数据可以删除
+    await expect(deleteBtn).toBeVisible({ timeout: 10000 })
+
+    await deleteBtn.click()
+
+    // 确认删除对话框出现
+    await expect(page.locator('.el-message-box')).toBeVisible()
+    await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click()
+
+    // 等待确认对话框关闭
+    await expect(page.locator('.el-message-box')).not.toBeVisible({ timeout: 10000 })
+
+    // 验证表格仍然可见
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+  })
+
+  test('新增用户表单验证', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+    await expect(page.locator(byDataId('user-drawer'))).toBeVisible()
+
+    // 直接点击确定,不填写任何内容
+    await page.locator(byDataId('btn-submit')).click()
+
+    // 验证显示验证错误
+    const drawer = page.locator(byDataId('user-drawer'))
+    await expect(drawer.getByText('请输入用户名')).toBeVisible()
+    await expect(drawer.getByText('请输入密码')).toBeVisible()
+  })
+
+  test('取消新增用户', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 点击新增按钮
+    await page.locator(byDataId('btn-add')).click()
+    const drawer = page.locator(byDataId('user-drawer'))
+    await expect(drawer).toBeVisible()
+
+    // 填写部分内容
+    await drawer.locator(byDataId('input-username')).fill('test_cancel')
+
+    // 点击取消
+    await page.locator(byDataId('btn-cancel')).click()
+
+    // 验证抽屉关闭
+    await expect(drawer).not.toBeVisible()
+  })
+
+  test('从侧边栏导航到用户管理', async ({ page }) => {
+    await login(page)
+
+    // 展开系统管理菜单
+    await page.getByText('系统管理').first().click()
+    await page.waitForTimeout(300)
+
+    // 点击用户管理菜单项
+    await page.getByText('用户管理').first().click()
+
+    // 验证跳转到用户管理页面
+    await expect(page).toHaveURL(/\/system\/user/)
+    await expect(page.locator(byDataId('btn-add'))).toBeVisible()
+  })
+
+  test('状态筛选功能', async ({ page }) => {
+    await login(page)
+    await page.goto('/system/user')
+
+    // 等待表格加载
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+
+    // 选择启用状态
+    await page.locator(byDataId('search-status')).click()
+    await page.getByText('启用').click()
+
+    // 点击查询
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+
+    // 选择禁用状态
+    await page.locator(byDataId('search-status')).click()
+    await page.getByText('禁用').click()
+
+    // 点击查询
+    await page.locator(byDataId('btn-search')).click()
+    await page.waitForTimeout(500)
+
+    // 表格应该仍然可见
+    await expect(page.locator(byDataId('user-table'))).toBeVisible()
+  })
+})