diff --git a/clinical-web/README.md b/clinical-web/README.md index 8f8c429..3ce312c 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -157,6 +157,9 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` - `GET/POST /api/v1/templates`、`GET /api/v1/templates/{id}`:模板列表、详情; - `GET/POST /api/v1/templates/{id}/versions`、版本工作流接口:版本查询、创建、送审、驳回、通过、发布、停用、归档; - `GET /api/v1/users`、`GET /api/v1/roles`、`GET /api/v1/campuses`、`GET /api/v1/departments`:用户页真实查询及组织字典; +- `GET /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/roles`、`PUT /api/v1/users/{id}/management`:用户角色读取和用户资料/角色聚合保存; +- `POST /api/v1/users/{id}/password/reset`、`POST /api/v1/users/import/preview`、`POST /api/v1/users/import/commit`、`GET /api/v1/users/import/jobs/{jobId}`:管理员重置密码和用户导入流程; +- `GET /api/v1/roles/summary`、`GET/PUT /api/v1/roles/{roleId}/permissions`:角色人数统计和角色 API 权限分配; - `GET/POST/PUT/DELETE /api/v1/permissions`:API 权限分页查询、详情、新增、修改和停用; - `GET/POST/DELETE /api/v1/templates/{id}/permissions`:按模板查询和维护文档权限; - `GET /api/v1/audit-logs`:全局审计日志查询 API。 diff --git a/clinical-web/docs/document-permissions-api.md b/clinical-web/docs/document-permissions-api.md index 4ff1a1f..4786622 100644 --- a/clinical-web/docs/document-permissions-api.md +++ b/clinical-web/docs/document-permissions-api.md @@ -1,18 +1,20 @@ # 文档权限页面接口接入说明 -依据 default_OpenAPI (1).json,文档权限页面当前按“文档模板”维度接入权限。页面保留原型的三部分: +依据本机后端 `http://127.0.0.1:18080/v3/api-docs`,文档权限页面当前按“文档模板”维度接入权限。页面保留原型的三部分,并补充单用户直接授权: 1. **文档库权限矩阵**:说明可见、可用、可维护和继承规则。 2. **按角色授权**:选择一个文档模板,调整角色的 VIEW、USE、MAINTAIN。 3. **按科室授权**:沿用当前模板,调整科室的 VIEW、USE、MAINTAIN。 +4. **模板权限明细与直接授权**:可选择角色、科室或用户新增/删除直接绑定。 ## 已接入接口 | 用途 | 方法 | 路径 | | ---------------------- | ------ | --------------------------------------------------------- | | 查询可配置模板 | GET | /api/v1/templates | -| 查询角色 | GET | /api/v1/roles | +| 查询角色及人数 | GET | /api/v1/roles/summary | | 查询科室 | GET | /api/v1/departments | +| 查询用户 | GET | /api/v1/users | | 查询模板权限及继承结果 | GET | /api/v1/templates/{templateId}/permissions | | 绑定模板权限 | POST | /api/v1/templates/{templateId}/permissions | | 撤销模板权限 | DELETE | /api/v1/templates/{templateId}/permissions/{permissionId} | @@ -52,4 +54,4 @@ - 全局科室权限查询与批量保存接口; - 或提供带模板维度的批量权限查询/保存接口,避免前端逐个模板请求。 -另外,当前 GET /api/v1/templates 的语义是查询当前用户有权限查看的模板。若权限管理员需要配置自己当前不可见的模板,请后端提供管理员可见的模板列表语义或专用接口。 +另外,当前 GET /api/v1/templates 的语义是查询当前用户有权限查看的模板。若权限管理员需要配置自己当前不可见的模板,请后端提供管理员可见的模板列表语义或专用接口。用户直接授权已使用 `subjectType=USER`,不需要新增专用用户权限接口。 diff --git a/clinical-web/docs/users-permissions-api.md b/clinical-web/docs/users-permissions-api.md index dc4acbf..df7817f 100644 --- a/clinical-web/docs/users-permissions-api.md +++ b/clinical-web/docs/users-permissions-api.md @@ -1,336 +1,96 @@ -# 用户与权限页面接口核验与后端缺口 +# 用户与权限接口核验与接入说明 -> 核验基准:MEDISIGN 患者电子签 API 文档([https://ipad.shenynet.com/doc.html#/home](https://ipad.shenynet.com/doc.html#/home))。 +> 核验基准:本机后端 `http://127.0.0.1:18080/v3/api-docs`(接口前缀为 `/api`)。浏览器请求仍统一使用 `/api`,认证请求头为 `X-Token`。 ## 1. 核验结论 -当前页面已经可以使用后端接口完成用户列表和用户生命周期的主要操作:查询、分页、关键字搜索、院区筛选、查看详情、新增、编辑和停用。角色列表也已经接入,用于展示角色卡片。 +后端当前已经提供用户、角色、API 权限关系和模板权限所需的主要接口。前端原实现的主要问题不是代理地址,而是把已经存在的接口误判为缺失,导致角色显示、密码重置、批量导入和角色权限分配没有真正接通。 -最新接口文档没有提供用户与角色的关联数据,因此用户表中的角色只能显示“未返回角色”;角色接口也没有返回用户人数,角色卡片不会再根据当前用户列表臆造人数。 +本轮已完成以下接入: -页面上暂时保留但无法真正执行的功能是: +- 用户列表和详情正确映射后端 `LOCKED` 状态、角色摘要、院区/科室名称、版本号和首次改密标记; +- 新增用户提交 `roleIds`,编辑用户使用聚合管理接口完整替换资料和角色,并提交 `expectedVersion`; +- 角色卡片改用 `GET /api/v1/roles/summary` 获取服务端人数统计; +- 重置密码使用 `POST /api/v1/users/{userId}/password/reset`,前端校验 12 位密码并支持下次登录改密; +- 批量导入使用预览、确认提交和任务查询三段流程,不展示或记录密码、手机号和邮箱; +- 用户与角色的 API 权限分配使用 `GET/PUT /api/v1/roles/{roleId}/permissions`,不再把权限目录 CRUD 当成关系接口; +- 模板文档权限页支持 `USER` 授权对象,可对单个用户增加或删除模板权限; +- 登录后的受保护路由会刷新 `/api/v1/auth/me`,用户页菜单和路由至少按 `system:user:query` 做体验层门控,最终权限仍由后端校验。 -- 批量导入用户; -- 重置密码; -- 为用户分配或移除角色; -- 角色人数统计。 +## 2. 后端契约与前端映射 -另外,`/api/v1/permissions` 管理的是 API 权限定义(资源、路径和动作),不是用户或角色的权限分配关系。模板文档权限使用 `/api/v1/templates/{templateId}/permissions`,两者也不能混用。 +| 页面能力 | 后端接口 | 前端状态 | 关键约束 | +| -------------- | ------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------- | +| 用户列表 | `GET /api/v1/users` | 已接入 | `page` 从 1 开始,`size` 传后端分页参数;支持 `keyword`、`campusId` | +| 用户详情 | `GET /api/v1/users/{id}` | 已接入 | 角色从 `roles` 摘要映射为角色名称和 ID | +| 新增用户 | `POST /api/v1/users` | 已接入 | `username`、`password`、`displayName`、`campusId` 必填;密码至少 8 位;可传 `roleIds` | +| 编辑用户资料 | `PUT /api/v1/users/{id}` | API 已封装 | 不接收用户名和密码;支持 `clearPhone`、`clearEmail`、`expectedVersion` | +| 编辑用户与角色 | `PUT /api/v1/users/{id}/management` | 已接入 | 一次提交资料、数据范围和完整 `roleIds`;使用版本号避免覆盖并发修改 | +| 读取用户角色 | `GET /api/v1/users/{userId}/roles` | API 已封装 | 返回角色摘要、关系版本和更新时间 | +| 覆盖用户角色 | `PUT /api/v1/users/{userId}/roles` | API 已封装 | `roleIds` 是完整替换集合,可选 `expectedVersion` | +| 停用用户 | `DELETE /api/v1/users/{id}` | 已接入 | 后端语义为软停用,前端保留二次确认 | +| 重置密码 | `POST /api/v1/users/{userId}/password/reset` | 已接入 | `newPassword` 至少 12 位;响应不返回密码 | +| 导入预览 | `POST /api/v1/users/import/preview` | 已接入 | `multipart/form-data`,字段为 `file`,可选默认 `campusId` | +| 确认导入 | `POST /api/v1/users/import/commit` | 已接入 | 仅提交预览返回的 `importId`;预览存在错误时前端禁止提交 | +| 导入任务 | `GET /api/v1/users/import/jobs/{jobId}` | 已接入 | 展示服务端任务状态,不把文件内容写入日志 | +| 角色分页 | `GET /api/v1/roles` | 已接入 | `RoleResponse` 不含 `userCount`,只用于角色选项和基础信息 | +| 角色人数 | `GET /api/v1/roles/summary` | 已接入 | 人数由服务端统计,支持可选 `campusId` | +| API 权限目录 | `GET/POST/PUT/DELETE /api/v1/permissions` | API 已封装 | 这是权限定义目录,不是角色关系 | +| 角色 API 权限 | `GET/PUT /api/v1/roles/{roleId}/permissions` | 已接入 | `permissionIds` 完整替换,可选 `expectedVersion` | +| 当前用户 | `GET /api/v1/auth/me` | 已接入 | 刷新 `permissions`、角色、数据范围和 `passwordChangeRequired` | +| 模板权限 | `GET/POST/DELETE /api/v1/templates/{templateId}/permissions` | 已接入 | `subjectType` 支持 `ROLE`、`DEPARTMENT`、`USER`;继承记录不可直接删除 | -## 2. 当前已接入接口 +## 3. 重要 DTO 规则 -| 页面功能 | 接口 | 状态 | 说明 | -| -------- | --------------------------- | ------ | -------------------------------------------------------- | -| 用户列表 | `GET /api/v1/users` | 已接入 | 支持 `page`、`size`、`keyword`、`campusId`;前端已接分页 | -| 用户详情 | `GET /api/v1/users/{id}` | 已接入 | 用于打开编辑弹窗 | -| 新增用户 | `POST /api/v1/users` | 已接入 | 创建时提交账号、密码、基础资料、院区、科室和数据范围 | -| 编辑用户 | `PUT /api/v1/users/{id}` | 已接入 | 不修改用户名和密码 | -| 停用用户 | `DELETE /api/v1/users/{id}` | 已接入 | 后端是软停用,前端有二次确认 | -| 角色列表 | `GET /api/v1/roles` | 已接入 | 支持 `page`、`size`、`keyword`;最新响应没有 `userCount` | -| 院区字典 | `GET /api/v1/campuses` | 已接入 | 用于院区显示和用户表单 | -| 科室字典 | `GET /api/v1/departments` | 已接入 | 用于科室显示和用户表单,可按院区筛选 | +### 3.1 用户状态和并发版本 -### 2.1 已遵守的接口约束 +后端用户状态为 `ENABLED`、`DISABLED`、`LOCKED`。此前前端把除 `DISABLED` 外的所有值都当成启用,已修复为三态映射;锁定用户不能建立会话,不能仅按“停用”展示。 -- 所有请求使用统一响应包装,并通过 `X-Token` 携带登录令牌; -- 用户列表的 `page` 从 1 开始,`size` 范围为 1–200; -- 创建用户时密码只出现在请求中,不能出现在任何响应或日志中; -- 编辑接口不接收用户名和密码; -- 停用接口不执行物理删除,只将状态改为 `DISABLED`; -- 用户接口返回的手机号和邮箱可能是脱敏值。前端编辑时,如果用户没有重新填写,不会把脱敏值回传覆盖原联系方式; -- 用户角色、角色人数和最终权限判断必须由后端提供和校验,前端不自行推导。 +用户编辑响应带有 `version`。聚合编辑会将该版本作为 `expectedVersion` 提交,服务端返回并发冲突时前端不会自行覆盖新数据。 -## 3. 建议补充的后端接口 +### 3.2 脱敏联系方式 -### 3.1 用户角色关联(最高优先级) +详情可能返回脱敏手机号或邮箱。前端未修改脱敏字段时不回传该字段;用户明确清空时才提交 `clearPhone: true` 或 `clearEmail: true`。完整手机号、邮箱和密码不写入日志或审计展示。 -用户列表和详情最好直接在 `UserResponse` 中增加角色摘要,避免列表为每个用户额外请求一次接口: +### 3.3 批量导入 -```json -{ - "roles": [ - { - "id": "角色 UUID", - "code": "DOCTOR", - "name": "医生" - } - ] -} +预览响应只包含行号、校验状态、错误、用户名和姓名,不包含手机号、邮箱或密码。前端只有在 `invalidRows === 0` 时才启用确认按钮,提交后展示 `jobId` 和服务端状态。 + +## 4. 仍需后端确认或前端后续补齐的事项 + +1. OpenAPI 给出了权限资源和动作枚举,但没有给出完整的权限编码初始化清单。前端目前使用已存在于权限目录 Mock/约定中的 `system:user:query` 做用户页菜单与路由门控;`system:user:create`、`system:user:update`、`system:user:disable`、角色及权限关系的最终编码需要以后端权限中心实际数据为准。 +2. 当前页面已经能分配角色 API 权限,但 API 权限目录本身的新增、编辑、停用尚未做独立管理页面;对应 API 封装已存在。 +3. 后端提供了 `POST /api/v1/auth/password/change`,当前只完成请求类型和 API 封装,尚未在本轮新增强制改密弹窗。`passwordChangeRequired` 已保留在登录和当前用户模型中,产品若要求登录后强制改密,应补充全局改密流程。 +4. 导入任务契约当前状态为 `PREVIEWED`、`COMMITTED`、`EXPIRED`、`FAILED`;如果未来改为真正异步处理,应在后端扩展状态和前端轮询策略,不能由前端猜测完成状态。 +5. 所有管理接口仍必须由后端执行 `401/403`、数据范围、角色/权限状态和审计校验。前端隐藏菜单或按钮只改善体验,不能作为安全边界。 + +## 5. 联调验收 + +真实接口模式: + +```bash +VITE_USE_MOCK=false npm run dev ``` -同时提供角色关系的读取和覆盖更新接口,供用户编辑弹窗使用: +至少验证以下场景: -```http -GET /api/v1/users/{userId}/roles -PUT /api/v1/users/{userId}/roles -Content-Type: application/json +- 登录后 Network 中所有业务请求带 `X-Token`,并能看到一次 `/api/v1/auth/me`; +- 用户详情显示后端角色名称,编辑角色后刷新列表仍保持; +- `LOCKED` 用户显示为锁定而不是启用; +- 未修改脱敏联系方式时编辑不会覆盖原值,清空时发送清除标记; +- 重置密码不出现在任何响应、列表或前端日志; +- 导入存在错误时不能提交,零错误预览才允许提交并显示任务号; +- 角色 API 权限保存后重新读取,确认 `permissionIds` 是完整替换结果; +- 模板权限可选择单个用户,直接权限可删除,继承权限保持不可删除; +- 无用户查询权限的账号不能通过菜单或直接 URL进入用户页,后端 `403` 仍是最终判断。 + +## 6. 验证命令 + +本轮修改后应在 `clinical-web` 目录执行: + +```bash +npm run lint +npm run format:check +npx vue-tsc --noEmit +npm run build -- --outDir /tmp/clinical-web-build- ``` - -请求体建议为: - -```json -{ - "roleIds": ["角色 UUID 1", "角色 UUID 2"] -} -``` - -`PUT` 使用“完整替换”语义,便于前端重复提交保持幂等。响应建议返回用户和最新角色摘要: - -```json -{ - "code": 0, - "message": "OK", - "data": { - "userId": "用户 UUID", - "roles": [ - { - "id": "角色 UUID", - "code": "DOCTOR", - "name": "医生" - } - ], - "updatedAt": "2026-09-01T08:00:00Z" - } -} -``` - -后端应校验角色是否存在、是否停用、当前操作人是否有分配权限,并记录角色变更审计日志。建议错误码包括 `400`、`401`、`403`、`404` 和 `409`。 - -### 3.2 重置密码 - -建议提供: - -```http -POST /api/v1/users/{userId}/password/reset -Content-Type: application/json -``` - -请求体: - -```json -{ - "newPassword": "管理员输入的新密码", - "forceChangeOnNextLogin": true -} -``` - -响应使用 `ApiResponseVoid`,不返回密码: - -```json -{ - "code": 0, - "message": "密码已重置", - "data": {} -} -``` - -密码必须在服务端进行强度校验和哈希存储;密码不能写入日志、审计详情或响应。若产品要求服务端生成临时密码,应通过受控的一次性展示或院内安全渠道交付,不建议作为普通接口字段长期返回。建议错误码包括 `400`、`401`、`403`、`404` 和 `429`。 - -### 3.3 批量导入用户 - -批量导入不建议直接把文件上传后立即写库,建议拆成“预览校验 + 确认导入”: - -```http -POST /api/v1/users/import/preview -Content-Type: multipart/form-data -``` - -表单字段: - -| 字段 | 类型 | 说明 | -| ---------- | ---- | ---------------------------------------- | -| `file` | file | CSV 或 XLSX 文件,建议限制文件大小和行数 | -| `campusId` | UUID | 没有院区列时使用的默认院区,可选 | - -预览响应至少应包含: - -```json -{ - "code": 0, - "data": { - "importId": "导入批次 UUID", - "fileName": "users.xlsx", - "totalRows": 100, - "validRows": 96, - "invalidRows": 4, - "rows": [ - { - "rowNo": 12, - "status": "INVALID", - "errors": ["用户名已存在"], - "username": "zhangsan", - "displayName": "张三" - } - ] - } -} -``` - -确认导入: - -```http -POST /api/v1/users/import/commit -Content-Type: application/json -``` - -```json -{ - "importId": "导入批次 UUID" -} -``` - -如果导入量较大,确认接口返回任务号并异步执行: - -```http -GET /api/v1/users/import/jobs/{jobId} -``` - -导入文件建议支持 `username`、`displayName`、`employeeNo`、`phone`、`email`、`campusId`、`departmentId`、`roleCodes` 和 `dataScope` 列。密码应遵循统一的临时密码或首次登录设置策略,不建议在普通导入文件中保存明文密码。导入必须逐行返回校验错误,不能因为一行错误而静默跳过整批数据。 - -### 3.4 角色人数统计 - -当前 `GET /api/v1/roles` 的响应没有 `userCount`。如果原型需要保留角色卡片人数,建议新增专用汇总接口: - -```http -GET /api/v1/roles/summary?campusId={campusId} -``` - -响应建议为: - -```json -{ - "code": 0, - "data": [ - { - "roleId": "角色 UUID", - "code": "DOCTOR", - "name": "医生", - "description": "可查看并发起签署任务", - "status": "ENABLED", - "userCount": 18 - } - ] -} -``` - -需要和后端确认人数口径:建议只统计当前操作人数据权限范围内、状态为 `ENABLED` 的用户,并按当前页面选择的院区过滤;如果产品要统计全院,应明确忽略页面院区筛选。统计接口也必须执行当前用户的权限范围校验。 - -### 3.5 API 权限分配(仅在产品需要时补充) - -现有 `/api/v1/permissions` 只是 API 权限目录的增删改查。若需要让管理员把 API 权限分配给角色,还需要关系接口: - -```http -GET /api/v1/roles/{roleId}/permissions -PUT /api/v1/roles/{roleId}/permissions -``` - -请求体可以使用完整替换语义: - -```json -{ - "permissionIds": ["API 权限 UUID 1", "API 权限 UUID 2"] -} -``` - -这组接口与模板文档权限接口不同。模板的角色、科室和用户授权继续使用: - -```http -GET /api/v1/templates/{templateId}/permissions -POST /api/v1/templates/{templateId}/permissions -DELETE /api/v1/templates/{templateId}/permissions/{permissionId} -``` - -## 4. 接口错误与审计要求 - -所有新增接口建议继续使用统一响应格式,并至少覆盖: - -| HTTP 状态 | 场景 | -| --------- | ---------------------------------- | -| `400` | 参数、格式、文件内容或业务校验失败 | -| `401` | 未登录或令牌失效 | -| `403` | 当前用户没有对应管理权限 | -| `404` | 用户、角色、导入批次或权限不存在 | -| `409` | 用户名重复、角色关系并发冲突等 | -| `429` | 密码重置或批量任务触发频率限制 | -| `500` | 服务端异常 | - -用户创建、编辑、停用、密码重置、角色变更和批量导入都应产生审计记录,至少包含操作人、目标用户、操作类型、结果、时间和链路追踪 ID;不得记录密码、完整手机号、身份证号或完整病历信息。 - -## 5. 推荐实施顺序 - -1. 先补用户角色摘要和角色绑定接口,解除用户表“未返回角色”的限制; -2. 补密码重置接口,前端再把现有提示改为重置密码弹窗; -3. 补批量导入预览、确认和任务查询; -4. 根据产品是否保留角色卡片人数,补角色统计接口; -5. 如果要管理 API 权限,再补角色与 API 权限的关联接口,不能复用 `/api/v1/permissions` CRUD 代替。 - -## 6. 以最新 OpenAPI 核验授权链路(2026-09-02) - -### 6.1 已有能力与当前状态 - -| 核验项 | 最新接口/实现 | 状态 | -| ------------------ | ----------------------------------------------------------------------- | ------------------------------------ | -| 登录认证 | `POST /api/v1/auth/login` 返回 `X-Token`、`dataScope` 和 `permissions` | 已具备 | -| 当前用户刷新 | `GET /api/v1/auth/me` 返回 `UserResponse`,该 schema 没有 `permissions` | 字段不足 | -| 用户页面登录保护 | 路由只判断是否存在 Token | 已具备认证,缺少权限门控 | -| 用户/角色/权限 API | `/users`、`/roles`、`/permissions` 的 CRUD 接口均在 OpenAPI 中 | API 封装已接入;权限目录暂无对应页面 | -| 用户角色关联 | 最新 OpenAPI 没有用户-角色关系接口 | 后端缺口 | -| 角色 API 权限关联 | 最新 OpenAPI 没有角色-API 权限关系接口 | 后端缺口 | -| 模板文档权限 | `/api/v1/templates/{templateId}/permissions` | 独立能力,不能替代 API 权限 | - -当前侧边栏会向所有已登录用户展示“用户与权限”和“文档权限”,直接输入管理端 URL 也可以进入;按钮是否真正可执行目前依赖后端返回 `403`。这不能替代前端菜单/路由门控,但安全边界必须仍由后端执行。 - -### 6.2 后端需要确认或补充的内容 - -1. **补齐当前用户权限刷新** - - 建议修改 `GET /api/v1/auth/me` 的成功响应,使其至少包含登录摘要中的 `dataScope` 和 `permissions: string[]`。权限集合只返回当前用户实际拥有的启用权限,不返回密码或未脱敏敏感信息。若不改现有接口,需新增等价的 `GET /api/v1/auth/me/permissions`,但应只保留一个权威来源。 - -2. **定义并初始化权限编码** - - OpenAPI 只建议使用 `system:<资源>:<动作>` 格式,没有给出本页面实际权限编码。建议后端确认并初始化: - - - `system:user:query`、`system:user:create`、`system:user:update`、`system:user:disable`; - - `system:role:query`、`system:role:create`、`system:role:update`、`system:role:disable`; - - `system:permission:query`、`system:permission:create`、`system:permission:update`、`system:permission:disable`。 - - 上述编码是建议值,最终以后端权限中心的命名为准;报表权限还应单独定义 query/export 权限,不能用页面名称或前端隐藏按钮代替。 - -3. **提供 API 权限关系接口** - - `/api/v1/permissions` 只负责权限目录增删改查。若管理端需要真正分配 API 权限,需提供: - - ```http - GET /api/v1/roles/{roleId}/permissions - PUT /api/v1/roles/{roleId}/permissions - Content-Type: application/json - ``` - - ```json - { "permissionIds": ["API 权限 UUID 1", "API 权限 UUID 2"] } - ``` - - 用户角色关系仍需提供: - - ```http - GET /api/v1/users/{userId}/roles - PUT /api/v1/users/{userId}/roles - ``` - - 关系更新建议使用完整替换、幂等语义,校验角色/权限状态和当前操作人权限,并记录审计日志。 - -4. **所有管理接口必须服务端授权** - - 用户、角色、API 权限及模板权限接口都应按操作区分 query/create/update/disable 权限;用户 `dataScope` 只能限制数据范围,不能替代操作权限。客户端传入的 `campusId`、`departmentId` 不得扩大操作人的数据范围,越权统一返回 `403`。 - -5. **补齐错误和审计契约** - - 所有授权关系和管理写接口建议明确 `400/401/403/404/409/500`;权限变更、用户停用、角色绑定和 API 权限绑定应记录操作人、目标、动作、结果、时间和 traceId,不记录密码、完整手机号、身份证号或病历内容。 - -### 6.3 前端联调验收 - -- 登录后调用 `/auth/me` 能拿到最新权限,权限撤销后旧会话访问受保护接口立即得到 `403`; -- 无用户查询权限的账号不能看到用户数据,直接访问 URL 也不能绕过后端; -- 只有对应 create/update/disable 权限的按钮才可用,前端隐藏只是体验优化,不能作为安全措施; -- 用户角色和角色 API 权限变更后,重新登录或刷新当前用户即可看到最新权限; -- Mock 模式仅用于页面演示,真实权限验证必须设置 `VITE_USE_MOCK=false`,使用不同权限账号检查 Network 请求和 401/403 响应。 diff --git a/clinical-web/src/api/auth.ts b/clinical-web/src/api/auth.ts index e5fb7dd..5776ecb 100644 --- a/clinical-web/src/api/auth.ts +++ b/clinical-web/src/api/auth.ts @@ -1,6 +1,10 @@ import axios from 'axios' -import { ApiResponseError, unwrapApiResponse } from '@/utils/api-response' +import { + ApiResponseError, + unwrapApiResponse, + unwrapNullableApiResponse, +} from '@/utils/api-response' import { request } from '@/utils/request' import type { AuthenticatedUser, @@ -8,6 +12,7 @@ import type { CurrentUserResponse, LoginRequest, LoginResponse, + PasswordChangeRequest, } from '@/types/auth' import type { ApiResponse } from '@/types/common' @@ -76,6 +81,11 @@ export async function logout(): Promise { unwrapApiResponse(response) } +export async function changePassword(payload: PasswordChangeRequest): Promise { + const response = await request.post>('/v1/auth/password/change', payload) + unwrapNullableApiResponse(response) +} + export function getAuthErrorMessage(error: unknown) { if (error instanceof ApiResponseError) { return error.message diff --git a/clinical-web/src/api/management/roles.ts b/clinical-web/src/api/management/roles.ts index 4718112..8544163 100644 --- a/clinical-web/src/api/management/roles.ts +++ b/clinical-web/src/api/management/roles.ts @@ -5,9 +5,12 @@ import type { ApiResponse } from '@/types/common' import type { BackendCollection, CreateRoleRequest, + RolePermissionsResponseDto, + RolePermissionsUpdateRequest, RoleRecord, RoleQuery, RoleResponseDto, + RoleSummaryResponseDto, UpdateRoleRequest, } from './types' @@ -15,18 +18,35 @@ const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' const fallbackColors = ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d'] -function normalizeRole(dto: RoleResponseDto, index: number): RoleRecord { +function normalizeRole(dto: RoleResponseDto, index: number, userCount?: number | null): RoleRecord { return { id: dto.id, code: dto.code, name: dto.name, description: dto.description ?? '—', color: fallbackColors[index % fallbackColors.length], - userCount: dto.userCount ?? undefined, + userCount: userCount ?? undefined, status: dto.status ?? 'ENABLED', + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + version: dto.version ?? undefined, } } +function normalizeRoleSummary(dto: RoleSummaryResponseDto, index: number): RoleRecord { + return normalizeRole( + { + id: dto.roleId, + code: dto.code, + name: dto.name, + description: dto.description, + status: dto.status, + }, + index, + dto.userCount, + ) +} + function toBackendQuery(query: RoleQuery) { const params: Record = { page: Math.max(query.page, 1), @@ -54,10 +74,59 @@ export function getRoles(query: RoleQuery = { page: 1, pageSize: 200 }): Promise const records = Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? []) - return records.map(normalizeRole) + return records.map((role, index) => normalizeRole(role, index)) }) } +export function getRoleSummaries(campusId?: string): Promise { + if (useMockData) { + return Promise.resolve([]) + } + + return request + .get>>('/v1/roles/summary', { + params: campusId ? { campusId } : undefined, + }) + .then(unwrapApiResponse) + .then((data) => { + const records = Array.isArray(data) + ? data + : (data.records ?? data.items ?? data.content ?? []) + return records.map(normalizeRoleSummary) + }) +} + +export async function getRolePermissions(roleId: string): Promise { + if (useMockData) { + return { + roleId, + permissions: [], + version: 0, + updatedAt: '', + } + } + + const response = await request.get>( + `/v1/roles/${encodeURIComponent(roleId)}/permissions`, + ) + return unwrapApiResponse(response) +} + +export async function updateRolePermissions( + roleId: string, + payload: RolePermissionsUpdateRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行角色权限写操作') + } + + const response = await request.put>( + `/v1/roles/${encodeURIComponent(roleId)}/permissions`, + payload, + ) + return unwrapApiResponse(response) +} + export function createRole(payload: CreateRoleRequest): Promise { if (useMockData) { return Promise.reject(new Error('Mock 模式不执行角色写操作')) diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index 3cd6157..5b365fc 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -124,6 +124,9 @@ export interface RoleRecord { color?: string userCount?: number status: RoleStatus + createdAt?: string | null + updatedAt?: string | null + version?: number } export interface RoleQuery extends PageQuery { @@ -138,6 +141,17 @@ export interface RoleResponseDto { name: string description?: string | null status?: RoleStatus | null + createdAt?: string | null + updatedAt?: string | null + version?: number | null +} + +export interface RoleSummaryResponseDto { + roleId: string + code: string + name: string + description?: string | null + status?: RoleStatus | null userCount?: number | null } @@ -391,7 +405,7 @@ export interface ReportExportJobResponseDto { downloadPath?: string | null } -export type UserStatus = 'enabled' | 'disabled' +export type UserStatus = 'enabled' | 'disabled' | 'locked' export interface UserQuery extends PageQuery { keyword?: string @@ -416,7 +430,10 @@ export interface UserRecord { departmentId?: string | null departmentName?: string roleNames?: string[] + roleIds?: string[] dataScope?: string + version?: number + passwordChangeRequired?: boolean } export type UserListResponse = PageResult @@ -429,12 +446,24 @@ export interface UserResponseDto { phone?: string | null email?: string | null campusId?: string | null + campusName?: string | null departmentId?: string | null + departmentName?: string | null + roles?: UserRoleSummaryDto[] | null dataScope?: string | null - status?: string | null + status?: BackendUserStatus | null lastLoginAt?: string | null createdAt?: string | null updatedAt?: string | null + version?: number | null + passwordChangeRequired?: boolean +} + +export interface UserRoleSummaryDto { + id: string + code: string + name: string + status?: RoleStatus | null } export interface CreateUserRequest { @@ -448,10 +477,11 @@ export interface CreateUserRequest { departmentId?: string | null status?: BackendUserStatus dataScope?: BackendDataScope + roleIds?: string[] } -export type BackendUserStatus = 'ENABLED' | 'DISABLED' | string -export type BackendDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL' | string +export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED' +export type BackendDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL' export interface UpdateUserRequest { displayName: string @@ -462,6 +492,90 @@ export interface UpdateUserRequest { departmentId?: string | null status: BackendUserStatus dataScope: BackendDataScope + clearPhone?: boolean + clearEmail?: boolean + expectedVersion?: number +} + +export interface UserManagementUpdateRequest { + displayName: string + employeeNo?: string + phone?: string + email?: string + clearPhone?: boolean + clearEmail?: boolean + campusId: string + departmentId?: string | null + status: BackendUserStatus + dataScope: BackendDataScope + roleIds: string[] + expectedVersion?: number +} + +export interface UserRolesResponseDto { + userId: string + roles: UserRoleSummaryDto[] + version: number + updatedAt: string +} + +export interface UserRolesUpdateRequest { + roleIds: string[] + expectedVersion?: number +} + +export interface RolePermissionsResponseDto { + roleId: string + permissions: ApiPermissionResponseDto[] + version: number + updatedAt: string +} + +export interface RolePermissionsUpdateRequest { + permissionIds: string[] + expectedVersion?: number +} + +export interface PasswordResetRequest { + newPassword: string + forceChangeOnNextLogin?: boolean +} + +export type UserImportRowStatus = 'VALID' | 'INVALID' + +export interface UserImportPreviewRowDto { + rowNo: number + status: UserImportRowStatus + errors: string[] + username?: string | null + displayName?: string | null +} + +export interface UserImportPreviewResponseDto { + importId: string + fileName: string + totalRows: number + validRows: number + invalidRows: number + rows: UserImportPreviewRowDto[] + expiresAt: string +} + +export interface UserImportCommitRequest { + importId: string +} + +export type UserImportJobStatus = 'PREVIEWED' | 'COMMITTED' | 'EXPIRED' | 'FAILED' | string + +export interface UserImportJobResponseDto { + jobId: string + status: UserImportJobStatus + totalRows?: number | null + validRows?: number | null + invalidRows?: number | null + createdAt?: string | null + expiresAt?: string | null + committedAt?: string | null } export type PermissionSubjectType = 'user' | 'role' diff --git a/clinical-web/src/api/management/users.ts b/clinical-web/src/api/management/users.ts index dd1bb7d..630073f 100644 --- a/clinical-web/src/api/management/users.ts +++ b/clinical-web/src/api/management/users.ts @@ -8,11 +8,18 @@ import type { BackendCollection, BackendPage, CreateUserRequest, + PasswordResetRequest, UpdateUserRequest, + UserImportCommitRequest, + UserImportJobResponseDto, + UserImportPreviewResponseDto, + UserManagementUpdateRequest, UserListResponse, UserQuery, UserRecord, UserResponseDto, + UserRolesResponseDto, + UserRolesUpdateRequest, } from './types' const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' @@ -70,24 +77,37 @@ function formatDateTime(value: string | null | undefined) { } function normalizeStatus(value: string | null | undefined): UserRecord['status'] { + if (value === 'LOCKED') { + return 'locked' + } + return value === 'DISABLED' || value === 'INACTIVE' ? 'disabled' : 'enabled' } function mapUser(dto: UserResponseDto): UserRecord { + const roles = dto.roles ?? [] + const roleNames = roles.map((role) => role.name).filter(Boolean) + return { id: dto.id, name: dto.displayName, account: dto.username, - department: dto.departmentId ? '未知科室' : '未指定科室', - role: '未返回角色', + department: dto.departmentName ?? (dto.departmentId ? '未知科室' : '未指定科室'), + role: roleNames.length ? roleNames.join('、') : '未分配角色', status: normalizeStatus(dto.status), lastLoginAt: formatDateTime(dto.lastLoginAt), employeeNo: dto.employeeNo, phone: dto.phone, email: dto.email, campusId: dto.campusId, + campusName: dto.campusName ?? undefined, departmentId: dto.departmentId, + departmentName: dto.departmentName ?? undefined, + roleNames, + roleIds: roles.map((role) => role.id), dataScope: dto.dataScope ?? '—', + version: dto.version ?? undefined, + passwordChangeRequired: dto.passwordChangeRequired, } } @@ -203,6 +223,113 @@ export function updateUser(id: string, payload: UpdateUserRequest): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行用户写操作')) + } + + return request + .put>(`/v1/users/${encodeURIComponent(id)}/management`, payload) + .then(unwrapApiResponse) + .then(mapUser) +} + +export async function getUserRoles(userId: string): Promise { + if (useMockData) { + return { + userId, + roles: [], + version: 0, + updatedAt: '', + } + } + + const response = await request.get>( + `/v1/users/${encodeURIComponent(userId)}/roles`, + ) + return unwrapApiResponse(response) +} + +export async function updateUserRoles( + userId: string, + payload: UserRolesUpdateRequest, +): Promise { + if (useMockData) { + return Promise.reject(new Error('Mock 模式不执行用户角色写操作')) + } + + const response = await request.put>( + `/v1/users/${encodeURIComponent(userId)}/roles`, + payload, + ) + return unwrapApiResponse(response) +} + +export async function resetUserPassword( + userId: string, + payload: PasswordResetRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行密码重置') + } + + const response = await request.post>( + `/v1/users/${encodeURIComponent(userId)}/password/reset`, + payload, + ) + unwrapNullableApiResponse(response) +} + +export async function previewUserImport( + file: File, + campusId?: string, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行用户导入') + } + + const formData = new FormData() + formData.append('file', file) + + if (campusId) { + formData.append('campusId', campusId) + } + + const response = await request.post>( + '/v1/users/import/preview', + formData, + ) + return unwrapApiResponse(response) +} + +export async function commitUserImport( + payload: UserImportCommitRequest, +): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行用户导入') + } + + const response = await request.post>( + '/v1/users/import/commit', + payload, + ) + return unwrapApiResponse(response) +} + +export async function getUserImportJob(jobId: string): Promise { + if (useMockData) { + throw new Error('Mock 模式不执行用户导入') + } + + const response = await request.get>( + `/v1/users/import/jobs/${encodeURIComponent(jobId)}`, + ) + return unwrapApiResponse(response) +} + export function deleteUser(id: string): Promise { if (useMockData) { return Promise.reject(new Error('Mock 模式不执行用户写操作')) diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index cdc46cd..d772bf8 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -1,4 +1,5 @@ + + + + diff --git a/clinical-web/src/views/management/users/components/UserEditorDialog.vue b/clinical-web/src/views/management/users/components/UserEditorDialog.vue index 068b58c..d65f82e 100644 --- a/clinical-web/src/views/management/users/components/UserEditorDialog.vue +++ b/clinical-web/src/views/management/users/components/UserEditorDialog.vue @@ -1,7 +1,7 @@ + + + + diff --git a/clinical-web/src/views/management/users/components/UserPasswordResetDialog.vue b/clinical-web/src/views/management/users/components/UserPasswordResetDialog.vue new file mode 100644 index 0000000..ec9cbf2 --- /dev/null +++ b/clinical-web/src/views/management/users/components/UserPasswordResetDialog.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/clinical-web/src/views/management/users/components/UserTable.vue b/clinical-web/src/views/management/users/components/UserTable.vue index 28e6d56..a36996b 100644 --- a/clinical-web/src/views/management/users/components/UserTable.vue +++ b/clinical-web/src/views/management/users/components/UserTable.vue @@ -16,6 +16,7 @@ const emit = defineEmits<{ const statusLabels: Record = { enabled: '启用', disabled: '停用', + locked: '锁定', } @@ -40,7 +41,9 @@ const statusLabels: Record = {
+ + + + diff --git a/clinical-web/src/views/management/users/types.ts b/clinical-web/src/views/management/users/types.ts index b644739..3715a28 100644 --- a/clinical-web/src/views/management/users/types.ts +++ b/clinical-web/src/views/management/users/types.ts @@ -1,4 +1,6 @@ -export type UserStatus = 'enabled' | 'disabled' +import type { BackendDataScope } from '@/api/management/types' + +export type UserStatus = 'enabled' | 'disabled' | 'locked' export interface RoleSummary { id: string @@ -20,8 +22,11 @@ export interface UserTableRow { department: string departmentId?: string | null role: string + roleNames?: string[] + roleIds?: string[] dataScope: string status: UserStatus + version?: number } export interface UserEditorForm { @@ -37,5 +42,9 @@ export interface UserEditorForm { campusId: string departmentId: string status: UserStatus - dataScope: string + dataScope: BackendDataScope + roleIds: string[] + clearPhone?: boolean + clearEmail?: boolean + version?: number }