fix: 接入用户与权限后端接口

This commit is contained in:
xy
2026-09-03 12:18:57 +08:00
parent e43a118dee
commit 48e5293509
19 changed files with 1982 additions and 375 deletions
+3
View File
@@ -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。
@@ -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`,不需要新增专用用户权限接口。
+76 -316
View File
@@ -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-<date>
```
请求体建议为:
```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 响应。
+11 -1
View File
@@ -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<void> {
unwrapApiResponse(response)
}
export async function changePassword(payload: PasswordChangeRequest): Promise<void> {
const response = await request.post<ApiResponse<null>>('/v1/auth/password/change', payload)
unwrapNullableApiResponse(response)
}
export function getAuthErrorMessage(error: unknown) {
if (error instanceof ApiResponseError) {
return error.message
+72 -3
View File
@@ -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<string, string | number> = {
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<RoleRecord[]> {
if (useMockData) {
return Promise.resolve([])
}
return request
.get<ApiResponse<BackendCollection<RoleSummaryResponseDto>>>('/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<RolePermissionsResponseDto> {
if (useMockData) {
return {
roleId,
permissions: [],
version: 0,
updatedAt: '',
}
}
const response = await request.get<ApiResponse<RolePermissionsResponseDto>>(
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
)
return unwrapApiResponse(response)
}
export async function updateRolePermissions(
roleId: string,
payload: RolePermissionsUpdateRequest,
): Promise<RolePermissionsResponseDto> {
if (useMockData) {
throw new Error('Mock 模式不执行角色权限写操作')
}
const response = await request.put<ApiResponse<RolePermissionsResponseDto>>(
`/v1/roles/${encodeURIComponent(roleId)}/permissions`,
payload,
)
return unwrapApiResponse(response)
}
export function createRole(payload: CreateRoleRequest): Promise<RoleRecord> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行角色写操作'))
+118 -4
View File
@@ -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<UserRecord>
@@ -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'
+129 -2
View File
@@ -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<User
.then(mapUser)
}
export function updateUserManagement(
id: string,
payload: UserManagementUpdateRequest,
): Promise<UserRecord> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
}
return request
.put<ApiResponse<UserResponseDto>>(`/v1/users/${encodeURIComponent(id)}/management`, payload)
.then(unwrapApiResponse)
.then(mapUser)
}
export async function getUserRoles(userId: string): Promise<UserRolesResponseDto> {
if (useMockData) {
return {
userId,
roles: [],
version: 0,
updatedAt: '',
}
}
const response = await request.get<ApiResponse<UserRolesResponseDto>>(
`/v1/users/${encodeURIComponent(userId)}/roles`,
)
return unwrapApiResponse(response)
}
export async function updateUserRoles(
userId: string,
payload: UserRolesUpdateRequest,
): Promise<UserRolesResponseDto> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行用户角色写操作'))
}
const response = await request.put<ApiResponse<UserRolesResponseDto>>(
`/v1/users/${encodeURIComponent(userId)}/roles`,
payload,
)
return unwrapApiResponse(response)
}
export async function resetUserPassword(
userId: string,
payload: PasswordResetRequest,
): Promise<void> {
if (useMockData) {
throw new Error('Mock 模式不执行密码重置')
}
const response = await request.post<ApiResponse<null>>(
`/v1/users/${encodeURIComponent(userId)}/password/reset`,
payload,
)
unwrapNullableApiResponse(response)
}
export async function previewUserImport(
file: File,
campusId?: string,
): Promise<UserImportPreviewResponseDto> {
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<ApiResponse<UserImportPreviewResponseDto>>(
'/v1/users/import/preview',
formData,
)
return unwrapApiResponse(response)
}
export async function commitUserImport(
payload: UserImportCommitRequest,
): Promise<UserImportJobResponseDto> {
if (useMockData) {
throw new Error('Mock 模式不执行用户导入')
}
const response = await request.post<ApiResponse<UserImportJobResponseDto>>(
'/v1/users/import/commit',
payload,
)
return unwrapApiResponse(response)
}
export async function getUserImportJob(jobId: string): Promise<UserImportJobResponseDto> {
if (useMockData) {
throw new Error('Mock 模式不执行用户导入')
}
const response = await request.get<ApiResponse<UserImportJobResponseDto>>(
`/v1/users/import/jobs/${encodeURIComponent(jobId)}`,
)
return unwrapApiResponse(response)
}
export function deleteUser(id: string): Promise<void> {
if (useMockData) {
return Promise.reject(new Error('Mock 模式不执行用户写操作'))
+15 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useLoginStore } from '@/stores/login'
@@ -6,6 +7,7 @@ import { useLoginStore } from '@/stores/login'
interface MenuItem {
path: string
label: string
permission?: string
}
interface MenuGroup {
@@ -29,13 +31,24 @@ const menuGroups: MenuGroup[] = [
items: [
{ path: '/management/documents', label: '文档库管理' },
{ path: '/management/reports', label: '报表分析' },
{ path: '/management/users', label: '用户与权限' },
{ path: '/management/users', label: '用户与权限', permission: 'system:user:query' },
{ path: '/management/document-permissions', label: '文档权限' },
{ path: '/management/settings', label: '系统设置' },
],
},
]
const visibleMenuGroups = computed(() =>
menuGroups
.map((group) => ({
...group,
items: group.items.filter(
(item) => !item.permission || loginStore.hasPermission(item.permission),
),
}))
.filter((group) => group.items.length),
)
async function handleLogout() {
try {
await loginStore.logout()
@@ -74,7 +87,7 @@ async function handleLogout() {
</div>
<nav class="menu-nav" aria-label="主导航">
<div v-for="group in menuGroups" :key="group.label" class="menu-group">
<div v-for="group in visibleMenuGroups" :key="group.label" class="menu-group">
<div class="menu-group-title">{{ group.label }}</div>
<RouterLink
v-for="item in group.items"
+29 -1
View File
@@ -10,6 +10,7 @@ import UserPermissionsView from '@/views/management/users/index.vue'
import ConsentTasksView from '@/views/workbench/signing/index.vue'
import DashboardView from '@/views/workbench/home/index.vue'
import { useLoginStore } from '@/stores/login'
import { readStoredAuthSession } from '@/utils/auth-storage'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -65,7 +66,11 @@ const router = createRouter({
path: 'users',
name: 'management-users',
component: UserPermissionsView,
meta: { title: '用户与权限', group: '管理' },
meta: {
title: '用户与权限',
group: '管理',
permission: 'system:user:query',
},
},
{
path: 'document-permissions',
@@ -96,6 +101,11 @@ router.beforeEach((to) => {
const loginStore = useLoginStore()
if (to.name === 'login' && loginStore.isLoggedIn) {
if (!readStoredAuthSession()) {
loginStore.clearSession()
return true
}
return { name: 'workbench-home' }
}
@@ -106,6 +116,24 @@ router.beforeEach((to) => {
}
}
if (to.meta.requiresAuth) {
return loginStore
.ensureCurrentUser()
.then(() => {
const requiredPermission = to.meta.permission as string | string[] | undefined
if (requiredPermission && !loginStore.hasPermission(requiredPermission)) {
return { name: 'workbench-home' }
}
return true
})
.catch(() => ({
name: 'login',
query: { redirect: to.fullPath },
}))
}
return true
})
+47
View File
@@ -21,16 +21,32 @@ export const useLoginStore = defineStore('login', () => {
const token = ref(storedSession?.token ?? '')
const expiresAt = ref(storedSession?.expiresAt ?? '')
const user = ref<AuthenticatedUser | null>(storedSession?.user ?? null)
const currentUserReady = ref(false)
let currentUserRequest: Promise<AuthenticatedUser> | null = null
const isLoggedIn = computed(() => Boolean(token.value))
const userName = computed(() => user.value?.displayName || user.value?.username || '医护用户')
const department = computed(() => getUserContext(user.value))
function hasPermission(required: string | string[]) {
const requiredPermissions = Array.isArray(required) ? required : [required]
if (!requiredPermissions.length) {
return true
}
const permissions = user.value?.permissions ?? []
return requiredPermissions.every(
(permission) => permissions.includes(permission) || permissions.includes('*'),
)
}
function setSession(session: LoginResponse) {
saveAuthSession(session)
token.value = session.token
expiresAt.value = session.expiresAt
user.value = session.user
currentUserReady.value = false
}
function setCurrentUser(nextUser: AuthenticatedUser) {
@@ -50,6 +66,8 @@ export const useLoginStore = defineStore('login', () => {
token.value = ''
expiresAt.value = ''
user.value = null
currentUserReady.value = false
currentUserRequest = null
}
async function login(payload: LoginRequest) {
@@ -64,6 +82,33 @@ export const useLoginStore = defineStore('login', () => {
return currentUser
}
async function ensureCurrentUser() {
if (!token.value) {
return null
}
if (currentUserReady.value && user.value) {
return user.value
}
if (!currentUserRequest) {
currentUserRequest = refreshCurrentUser()
.then((currentUser) => {
currentUserReady.value = true
return currentUser
})
.catch((error: unknown) => {
currentUserReady.value = false
throw error
})
.finally(() => {
currentUserRequest = null
})
}
return currentUserRequest
}
async function logout() {
try {
await logoutRequest()
@@ -81,6 +126,8 @@ export const useLoginStore = defineStore('login', () => {
department,
login,
refreshCurrentUser,
ensureCurrentUser,
hasPermission,
logout,
clearSession,
}
+20 -1
View File
@@ -1,6 +1,13 @@
export type AuthDataScope = 'ALL' | 'CAMPUS' | 'DEPARTMENT' | 'READ_ONLY_ALL'
export type AuthUserStatus = 'ENABLED' | 'DISABLED'
export type AuthUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
export interface AuthRoleSummary {
id: string
code: string
name: string
status?: 'ENABLED' | 'DISABLED' | string
}
export interface LoginRequest {
username: string
@@ -9,6 +16,11 @@ export interface LoginRequest {
captchaCode?: string
}
export interface PasswordChangeRequest {
currentPassword: string
newPassword: string
}
export interface CaptchaResponse {
captchaId: string
imageBase64: string
@@ -23,6 +35,8 @@ export interface AuthenticatedUser {
departmentId: string | null
dataScope: AuthDataScope
permissions: string[]
passwordChangeRequired?: boolean
roles?: AuthRoleSummary[]
}
export interface LoginResponse {
@@ -39,4 +53,9 @@ export interface CurrentUserResponse extends AuthenticatedUser {
lastLoginAt: string | null
createdAt: string
updatedAt: string
campusName?: string | null
departmentName?: string | null
version: number
roles: AuthRoleSummary[]
passwordChangeRequired: boolean
}
@@ -10,25 +10,30 @@ import {
getTemplatePermissions,
isPermissionMockEnabled,
} from '@/api/management/document-permissions'
import { getRoles } from '@/api/management/roles'
import { getUsers } from '@/api/management/users'
import { getRoleSummaries } from '@/api/management/roles'
import type {
DepartmentRecord,
DocumentRecord,
RoleRecord,
TemplatePermissionRecord,
UserRecord,
} from '@/api/management/types'
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
import TemplatePermissionPanel from './components/TemplatePermissionPanel.vue'
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
import type {
DepartmentPermissionRow,
PermissionCellState,
PermissionKey,
PermissionSubjectOption,
RolePermissionRow,
TemplatePermissionForm,
} from './types'
type PermissionSubjectType = 'ROLE' | 'DEPARTMENT'
type PermissionSubjectType = 'ROLE' | 'DEPARTMENT' | 'USER'
interface PermissionResolution {
enabled: boolean
@@ -40,6 +45,7 @@ const permissionKeys: PermissionKey[] = ['VIEW', 'USE', 'MAINTAIN']
const loading = ref(true)
const roleRecords = ref<RoleRecord[]>([])
const departmentRecords = ref<DepartmentRecord[]>([])
const userRecords = ref<UserRecord[]>([])
const roleRows = ref<RolePermissionRow[]>([])
const departmentRows = ref<DepartmentPermissionRow[]>([])
const templates = ref<DocumentRecord[]>([])
@@ -54,6 +60,24 @@ const selectedTemplate = computed(() =>
templates.value.find((template) => template.id === selectedTemplateId.value),
)
const templateSubjects = computed<PermissionSubjectOption[]>(() => [
...roleRecords.value.map((role) => ({
id: role.id,
type: 'ROLE' as const,
label: `${role.name}(${role.code})`,
})),
...departmentRecords.value.map((department) => ({
id: department.id,
type: 'DEPARTMENT' as const,
label: department.name,
})),
...userRecords.value.map((user) => ({
id: user.id,
type: 'USER' as const,
label: `${user.name}(${user.account})`,
})),
])
function resolvePermission(
subjectType: PermissionSubjectType,
subjectId: string,
@@ -119,6 +143,23 @@ function syncPermissionRows() {
}))
}
async function getAllUsersForPermission() {
const firstPage = await getUsers({ page: 1, pageSize: 200, status: 'all' })
const pageCount = Math.ceil(firstPage.total / firstPage.pageSize)
if (pageCount <= 1) {
return firstPage.records
}
const remainingPages = await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
getUsers({ page: index + 2, pageSize: 200, status: 'all' }),
),
)
return [firstPage, ...remainingPages].flatMap((page) => page.records)
}
async function loadPermissions() {
loading.value = true
@@ -135,11 +176,14 @@ async function loadPermissions() {
return
}
const [templatesResult, rolesResult, departmentsResult] = await Promise.allSettled([
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
getRoles({ page: 1, pageSize: 200 }),
getDepartments(),
])
const [templatesResult, rolesResult, departmentsResult, usersResult] = await Promise.allSettled(
[
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
getRoleSummaries(),
getDepartments(),
getAllUsersForPermission(),
],
)
if (templatesResult.status === 'rejected') {
throw templatesResult.reason
@@ -149,10 +193,15 @@ async function loadPermissions() {
roleRecords.value = rolesResult.status === 'fulfilled' ? rolesResult.value : []
departmentRecords.value =
departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
userRecords.value = usersResult.status === 'fulfilled' ? usersResult.value : []
syncPermissionRows()
if (rolesResult.status === 'rejected' || departmentsResult.status === 'rejected') {
ElMessage.warning('角色或科室列表加载不完整,部分授权对象暂不可配置')
if (
rolesResult.status === 'rejected' ||
departmentsResult.status === 'rejected' ||
usersResult.status === 'rejected'
) {
ElMessage.warning('角色、科室或用户列表加载不完整,部分授权对象暂不可配置')
}
selectedTemplateId.value = templates.value[0]?.id ?? ''
@@ -160,6 +209,7 @@ async function loadPermissions() {
} catch {
roleRecords.value = []
departmentRecords.value = []
userRecords.value = []
roleRows.value = []
departmentRows.value = []
templates.value = []
@@ -330,11 +380,52 @@ async function setSubjectPermission(
}
}
async function addTemplatePermission(form: TemplatePermissionForm) {
if (!selectedTemplateId.value || permissionSaving.value) {
return
}
permissionSaving.value = true
try {
await createTemplatePermission(selectedTemplateId.value, form)
await loadTemplatePermissions()
ElMessage.success('模板权限已添加')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板权限添加失败,请稍后重试')
} finally {
permissionSaving.value = false
}
}
async function removeTemplatePermission(permissionId: string) {
if (!selectedTemplateId.value || permissionSaving.value) {
return
}
permissionSaving.value = true
try {
await deleteTemplatePermission(selectedTemplateId.value, permissionId)
await loadTemplatePermissions()
ElMessage.success('模板权限已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板权限删除失败,请稍后重试')
} finally {
permissionSaving.value = false
}
}
function handleTemplateChange(event: Event) {
selectedTemplateId.value = (event.target as HTMLSelectElement).value
void loadTemplatePermissions()
}
function handlePanelTemplateChange(templateId: string) {
selectedTemplateId.value = templateId
void loadTemplatePermissions()
}
function retryTemplatePermissions() {
void loadTemplatePermissions()
}
@@ -435,6 +526,26 @@ onMounted(loadPermissions)
@toggle="toggleDepartmentPermission"
/>
</section>
<section v-if="!isPermissionMockEnabled" class="page-card direct-permission-card">
<div class="card-heading section-heading">
<div class="heading-copy">
<h2>模板权限明细与直接授权</h2>
<p>可对角色、科室或单个用户新增和删除模板权限;继承权限不能直接删除。</p>
</div>
</div>
<TemplatePermissionPanel
:templates="templates"
:selected-template-id="selectedTemplateId"
:permissions="templatePermissions"
:subjects="templateSubjects"
:loading="permissionLoading"
:saving="permissionSaving"
@update:selected-template-id="handlePanelTemplateChange"
@add="addTemplatePermission"
@delete="removeTemplatePermission"
/>
</section>
</template>
</section>
</template>
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ApiPermissionRecord, RoleRecord } from '@/api/management/types'
const props = defineProps<{
roles: RoleRecord[]
permissions: ApiPermissionRecord[]
selectedRoleId: string
selectedPermissionIds: string[]
loading: boolean
saving: boolean
error: string
}>()
const emit = defineEmits<{
selectRole: [roleId: string]
togglePermission: [permissionId: string]
save: []
}>()
const groupedPermissions = computed(() => {
const groups = new Map<string, ApiPermissionRecord[]>()
for (const permission of props.permissions) {
const group = groups.get(permission.resourceType) ?? []
group.push(permission)
groups.set(permission.resourceType, group)
}
return [...groups.entries()]
})
function isSelected(permissionId: string) {
return props.selectedPermissionIds.includes(permissionId)
}
</script>
<template>
<section class="page-card api-permission-card">
<div class="card-heading">
<div>
<h2>角色 API 权限</h2>
<p>这里维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
</div>
<span class="heading-spacer" />
<button
type="button"
class="table-button"
:disabled="saving || loading || !selectedRoleId"
@click="emit('save')"
>
{{ saving ? '保存中…' : '保存角色权限' }}
</button>
</div>
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
<div v-else-if="error" class="error-state">{{ error }}</div>
<div v-else-if="!roles.length" class="empty-state">暂无可配置角色</div>
<div v-else-if="!permissions.length" class="empty-state">
暂无 API 权限目录或当前账号无查询权限
</div>
<div v-else class="api-permission-layout">
<label class="role-selector">
<span>角色</span>
<select
:value="selectedRoleId"
:disabled="saving"
@change="emit('selectRole', ($event.target as HTMLSelectElement).value)"
>
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}({{ role.code }})
</option>
</select>
</label>
<div class="permission-groups">
<fieldset v-for="[resourceType, records] in groupedPermissions" :key="resourceType">
<legend>{{ resourceType }}</legend>
<label v-for="permission in records" :key="permission.id" class="permission-item">
<input
type="checkbox"
:checked="isSelected(permission.id)"
:disabled="saving"
@change="emit('togglePermission', permission.id)"
/>
<span>
<strong>{{ permission.name }}</strong>
<small>
{{ permission.code }} · {{ permission.action }}
<template v-if="permission.status === 'DISABLED'"> · 已停用</template>
</small>
</span>
</label>
</fieldset>
</div>
</div>
</section>
</template>
<style scoped>
.api-permission-card {
margin-top: 14px;
}
.card-heading {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14px;
}
.card-heading p {
margin: 4px 0 0;
color: var(--mut);
font-size: 11.5px;
}
.heading-spacer {
flex: 1;
}
.table-button {
padding: 5px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.table-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.loading-state,
.error-state,
.empty-state {
padding: 28px 16px;
color: var(--mut);
font-size: 12px;
text-align: center;
}
.error-state {
color: var(--err);
}
.api-permission-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 18px;
padding: 16px;
}
.role-selector {
display: grid;
align-content: start;
gap: 6px;
}
.role-selector span {
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.role-selector select {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
font-size: 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.permission-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.permission-groups fieldset {
min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 7px;
}
.permission-groups legend {
padding: 0 5px;
color: var(--brand);
font-size: 11.5px;
font-weight: 700;
}
.permission-item {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 6px 0;
cursor: pointer;
}
.permission-item input {
margin-top: 2px;
}
.permission-item span {
display: grid;
gap: 2px;
min-width: 0;
}
.permission-item strong {
color: var(--ink);
font-size: 12px;
}
.permission-item small {
overflow: hidden;
color: var(--mut);
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.api-permission-layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import type { CampusRecord, DepartmentRecord, RoleRecord } from '@/api/management/types'
import type { UserEditorForm } from '../types'
@@ -10,6 +10,7 @@ const props = defineProps<{
initial: UserEditorForm | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
roles: RoleRecord[]
saving: boolean
}>()
@@ -38,11 +39,17 @@ function createEmptyForm(): UserEditorForm {
departmentId: '',
status: 'enabled',
dataScope: 'DEPARTMENT',
roleIds: [],
clearPhone: false,
clearEmail: false,
}
}
function resetForm() {
Object.assign(form, props.initial ? { ...props.initial } : createEmptyForm())
Object.assign(
form,
props.initial ? { ...props.initial, roleIds: [...props.initial.roleIds] } : createEmptyForm(),
)
error.message = ''
}
@@ -54,10 +61,12 @@ function handleCampusChange() {
function markPhoneEdited() {
form.phoneMasked = false
form.clearPhone = !form.phone
}
function markEmailEdited() {
form.emailMasked = false
form.clearEmail = !form.email
}
function submit() {
@@ -78,11 +87,25 @@ function submit() {
return
}
if (!isEditing.value && form.password.length < 8) {
error.message = '初始密码至少需要 8 位'
return
}
if (!form.campusId) {
error.message = '请选择院区'
return
}
const disabledRole = props.roles.find(
(role) => form.roleIds.includes(role.id) && role.status === 'DISABLED',
)
if (disabledRole) {
error.message = `角色“${disabledRole.name}”已停用,请移除后再保存`
return
}
emit('save', {
...form,
username: form.username.trim(),
@@ -205,6 +228,7 @@ watch(
<select v-model="form.status">
<option value="enabled">启用</option>
<option value="disabled">停用</option>
<option value="locked">锁定</option>
</select>
</label>
<label>
@@ -216,10 +240,21 @@ watch(
<option value="READ_ONLY_ALL">全院只读</option>
</select>
</label>
<label class="form-grid__full">
<span>角色</span>
<select v-model="form.roleIds" multiple size="4">
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}({{ role.code }}){{
role.status === 'DISABLED' ? '(已停用)' : ''
}}
</option>
</select>
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
</label>
</div>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">
用户角色暂未提供绑定接口;接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交。
接口返回的手机号和邮箱可能已脱敏,未重新填写的脱敏字段不会提交;清空已保存联系方式会明确提交清除操作。
</p>
</form>
@@ -305,6 +340,10 @@ watch(
gap: 6px;
}
.form-grid__full {
grid-column: 1 / -1;
}
.form-grid label > span {
color: var(--ink);
font-size: 12px;
@@ -339,6 +378,17 @@ watch(
background: #f5f8f9;
}
.form-grid select[multiple] {
min-height: 94px;
}
.form-grid small {
color: var(--mut);
font-size: 11px;
font-weight: 400;
line-height: 1.5;
}
.form-error {
margin: 12px 0 0;
color: var(--err);
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
CampusRecord,
UserImportJobResponseDto,
UserImportPreviewResponseDto,
} from '@/api/management/types'
const props = defineProps<{
visible: boolean
campuses: CampusRecord[]
defaultCampusId?: string
preview: UserImportPreviewResponseDto | null
job: UserImportJobResponseDto | null
loading: boolean
}>()
const emit = defineEmits<{
close: []
preview: [payload: { file: File; campusId?: string }]
commit: []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFileName = ref('')
const selectedCampusId = ref('')
const error = ref('')
const invalidRows = computed(() =>
(props.preview?.rows ?? []).filter((row) => row.status === 'INVALID'),
)
const canCommit = computed(() =>
Boolean(props.preview && props.preview.invalidRows === 0 && !props.job),
)
function resetForm() {
selectedFileName.value = ''
selectedCampusId.value = props.defaultCampusId ?? ''
error.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
function chooseFile() {
fileInput.value?.click()
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
error.value = ''
if (!file) {
selectedFileName.value = ''
return
}
selectedFileName.value = file.name
emit('preview', {
file,
campusId: selectedCampusId.value || undefined,
})
}
function submitCommit() {
if (!canCommit.value) {
error.value = props.preview?.invalidRows ? '请先修正文件中的校验错误' : '请先选择并校验文件'
return
}
error.value = ''
emit('commit')
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
watch(
() => props.defaultCampusId,
(campusId) => {
if (props.visible && !selectedCampusId.value) {
selectedCampusId.value = campusId ?? ''
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card"
role="dialog"
aria-modal="true"
aria-labelledby="user-import-title"
>
<header class="dialog-header">
<div>
<strong id="user-import-title">批量导入用户</strong>
<p>先上传文件预览校验,通过后再确认导入</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<div class="dialog-body">
<input
ref="fileInput"
class="visually-hidden"
type="file"
accept=".csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"
@change="handleFileChange"
/>
<div class="upload-row">
<button
type="button"
class="button button--ghost"
:disabled="loading"
@click="chooseFile"
>
选择 CSV / XLSX
</button>
<span class="file-name">{{ selectedFileName || '尚未选择文件' }}</span>
</div>
<label class="campus-field">
<span>默认院区(文件没有院区列时使用)</span>
<select v-model="selectedCampusId">
<option value="">不指定</option>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
<div v-if="loading" class="loading-state">正在上传并校验文件…</div>
<template v-if="preview">
<div class="summary-grid">
<div>
<strong>{{ preview.totalRows }}</strong
><span>总行数</span>
</div>
<div class="summary-ok">
<strong>{{ preview.validRows }}</strong
><span>通过</span>
</div>
<div class="summary-error">
<strong>{{ preview.invalidRows }}</strong
><span>错误</span>
</div>
</div>
<p class="expires-tip">预览有效期:{{ preview.expiresAt }}</p>
<div v-if="invalidRows.length" class="error-list">
<strong>校验错误</strong>
<ul>
<li v-for="row in invalidRows" :key="row.rowNo">
第 {{ row.rowNo }} 行
<span v-if="row.username || row.displayName">
({{ row.username || row.displayName }})
</span>
:{{ row.errors.join(';') || '数据不符合要求' }}
</li>
</ul>
</div>
<p v-else class="success-tip">文件校验通过,可以确认导入。</p>
</template>
<div v-if="job" class="job-state">
导入任务已提交:{{ job.status }}(任务号 {{ job.jobId }})
</div>
<p v-if="error" class="form-error">{{ error }}</p>
<p class="form-tip">
预览响应不会返回手机号、邮箱或密码;导入文件请按后端约定准备,敏感字段不要写入日志或截图。
</p>
</div>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="button button--ghost" @click="emit('close')">关闭</button>
<button
type="button"
class="button"
:disabled="loading || !canCommit"
@click="submitCommit"
>
确认导入
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 620px;
max-width: 96vw;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.dialog-header,
.dialog-footer {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
}
.dialog-header {
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12px;
}
.close-button {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
display: grid;
gap: 14px;
padding: 18px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
clip-path: inset(50%);
}
.upload-row {
display: flex;
gap: 10px;
align-items: center;
}
.file-name {
min-width: 0;
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.campus-field {
display: grid;
gap: 6px;
}
.campus-field span {
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.campus-field select {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.summary-grid > div {
display: grid;
gap: 4px;
padding: 10px;
text-align: center;
background: #f5f9fa;
border-radius: 7px;
}
.summary-grid strong {
color: var(--ink);
font-size: 18px;
}
.summary-grid span,
.expires-tip,
.loading-state {
color: var(--mut);
font-size: 11.5px;
}
.summary-ok strong,
.success-tip {
color: var(--ok);
}
.summary-error strong,
.form-error {
color: var(--err);
}
.expires-tip {
margin: -4px 0 0;
}
.error-list {
max-height: 160px;
padding: 10px;
overflow-y: auto;
color: var(--err);
font-size: 11.5px;
line-height: 1.6;
background: var(--err-l);
border-radius: 7px;
}
.error-list strong {
font-size: 12px;
}
.error-list ul {
padding-left: 18px;
margin: 5px 0 0;
}
.success-tip,
.form-error,
.job-state {
margin: 0;
font-size: 12px;
}
.job-state {
color: var(--brand);
}
.form-tip {
padding: 9px 10px;
margin: 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.6;
background: #f7fafb;
border-radius: 6px;
}
.dialog-footer {
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.footer-spacer {
flex: 1;
}
.button {
padding: 8px 16px;
color: #fff;
font-size: 12.5px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.button--ghost {
color: var(--brand);
background: transparent;
border: 1px solid var(--brand);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
</style>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
const props = defineProps<{
visible: boolean
userName: string
saving: boolean
}>()
const emit = defineEmits<{
close: []
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean }]
}>()
const form = reactive({
newPassword: '',
confirmPassword: '',
forceChangeOnNextLogin: true,
})
const error = reactive({ message: '' })
function resetForm() {
form.newPassword = ''
form.confirmPassword = ''
form.forceChangeOnNextLogin = true
error.message = ''
}
function submit() {
error.message = ''
if (form.newPassword.length < 12) {
error.message = '新密码至少需要 12 位'
return
}
if (form.newPassword !== form.confirmPassword) {
error.message = '两次输入的新密码不一致'
return
}
emit('reset', {
newPassword: form.newPassword,
forceChangeOnNextLogin: form.forceChangeOnNextLogin,
})
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section
class="dialog-card"
role="dialog"
aria-modal="true"
aria-labelledby="password-reset-title"
>
<header class="dialog-header">
<div>
<strong id="password-reset-title">重置用户密码</strong>
<p>为“{{ userName }}”设置新的登录密码</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<form class="dialog-body" @submit.prevent="submit">
<label>
<span>新密码 <em>*</em></span>
<input
v-model="form.newPassword"
type="password"
autocomplete="new-password"
minlength="12"
placeholder="至少 12 位"
/>
</label>
<label>
<span>确认新密码 <em>*</em></span>
<input
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
minlength="12"
placeholder="再次输入新密码"
/>
</label>
<label class="checkbox-label">
<input v-model="form.forceChangeOnNextLogin" type="checkbox" />
<span>要求用户下次登录时修改密码</span>
</label>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">密码仅提交给服务端校验和存储,不会展示在列表、响应或日志中。</p>
</form>
<footer class="dialog-footer">
<span class="footer-spacer" />
<button type="button" class="button button--ghost" @click="emit('close')">取消</button>
<button type="button" class="button" :disabled="saving" @click="submit">
{{ saving ? '提交中…' : '确认重置' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.dialog-card {
width: 460px;
max-width: 96vw;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.dialog-header,
.dialog-footer {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
}
.dialog-header {
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12px;
}
.close-button {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.dialog-body {
display: grid;
gap: 14px;
padding: 18px;
}
.dialog-body label {
display: grid;
gap: 6px;
}
.dialog-body label > span {
color: var(--ink);
font-size: 12px;
font-weight: 700;
}
.dialog-body em {
color: var(--err);
font-style: normal;
}
.dialog-body input[type='password'] {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.dialog-body input[type='password']:focus {
outline: none;
border-color: var(--brand);
}
.checkbox-label {
display: flex !important;
grid-template-columns: none;
gap: 8px !important;
align-items: center;
}
.checkbox-label input {
width: auto;
}
.checkbox-label span {
font-weight: 400 !important;
}
.form-error {
margin: 0;
color: var(--err);
font-size: 12px;
}
.form-tip {
padding: 9px 10px;
margin: 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.6;
background: #f7fafb;
border-radius: 6px;
}
.dialog-footer {
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.footer-spacer {
flex: 1;
}
.button {
padding: 8px 16px;
color: #fff;
font-size: 12.5px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.button--ghost {
color: var(--brand);
background: transparent;
border: 1px solid var(--brand);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
</style>
@@ -16,6 +16,7 @@ const emit = defineEmits<{
const statusLabels: Record<UserStatus, string> = {
enabled: '启用',
disabled: '停用',
locked: '锁定',
}
</script>
@@ -40,7 +41,9 @@ const statusLabels: Record<UserStatus, string> = {
<div class="user-cell">
<span
class="user-avatar"
:style="{ backgroundColor: roleColors[row.role] ?? '#8a9aa3' }"
:style="{
backgroundColor: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
}"
aria-hidden="true"
>
{{ row.name.slice(0, 1) }}
@@ -55,8 +58,8 @@ const statusLabels: Record<UserStatus, string> = {
<span
class="role-tag"
:style="{
color: roleColors[row.role] ?? '#8a9aa3',
backgroundColor: `${roleColors[row.role] ?? '#8a9aa3'}22`,
color: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
backgroundColor: `${roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3'}22`,
}"
>
{{ row.role }}
@@ -164,6 +167,11 @@ const statusLabels: Record<UserStatus, string> = {
background: #eceef0;
}
.status-locked {
color: #a05a00;
background: #fff1d8;
}
.actions-cell {
white-space: nowrap;
}
+335 -25
View File
@@ -3,13 +3,34 @@ import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { getRoles } from '@/api/management/roles'
import { deleteUser, getUserDetail, getUsers, isUserMockEnabled } from '@/api/management/users'
import { createUser, updateUser } from '@/api/management/users'
import { getPermissions } from '@/api/management/permissions'
import {
getRolePermissions,
getRoleSummaries,
getRoles,
updateRolePermissions,
} from '@/api/management/roles'
import {
commitUserImport,
createUser,
deleteUser,
getUserDetail,
getUserImportJob,
getUsers,
isUserMockEnabled,
previewUserImport,
resetUserPassword,
updateUserManagement,
} from '@/api/management/users'
import type {
BackendDataScope,
BackendUserStatus,
ApiPermissionRecord,
CampusRecord,
DepartmentRecord,
RoleRecord,
UserImportJobResponseDto,
UserImportPreviewResponseDto,
UserRecord as ApiUserRecord,
} from '@/api/management/types'
import { useAppStore } from '@/stores/app'
@@ -17,6 +38,9 @@ import { useAppStore } from '@/stores/app'
import RoleSummaryCards from './components/RoleSummaryCards.vue'
import UserTable from './components/UserTable.vue'
import UserEditorDialog from './components/UserEditorDialog.vue'
import UserImportDialog from './components/UserImportDialog.vue'
import UserPasswordResetDialog from './components/UserPasswordResetDialog.vue'
import RoleApiPermissionPanel from './components/RoleApiPermissionPanel.vue'
import { roleColors, roleSummaries, users } from './mock'
import type { RoleSummary, UserEditorForm, UserTableRow } from './types'
@@ -24,6 +48,7 @@ const appStore = useAppStore()
const loading = ref(true)
const keyword = ref('')
const roleRows = ref<RoleSummary[]>([])
const roleOptions = ref<RoleRecord[]>([])
const userRows = ref<UserTableRow[]>([])
const roleColorMap = ref<Record<string, string>>(roleColors)
const userPage = ref(1)
@@ -35,6 +60,20 @@ const editorVisible = ref(false)
const editorInitial = ref<UserEditorForm | null>(null)
const editorSaving = ref(false)
const disablingUserId = ref<string | null>(null)
const passwordResetVisible = ref(false)
const passwordResetTarget = ref<UserTableRow | null>(null)
const passwordResetSaving = ref(false)
const importVisible = ref(false)
const importLoading = ref(false)
const importPreview = ref<UserImportPreviewResponseDto | null>(null)
const importJob = ref<UserImportJobResponseDto | null>(null)
const apiPermissions = ref<ApiPermissionRecord[]>([])
const selectedRoleId = ref('')
const selectedRolePermissionIds = ref<string[]>([])
const rolePermissionVersion = ref<number | undefined>()
const apiPermissionLoading = ref(false)
const apiPermissionSaving = ref(false)
const apiPermissionError = ref('')
let userRequestId = 0
const mockCampuses: CampusRecord[] = [
@@ -84,6 +123,15 @@ async function loadUsers() {
campuses.value = mockCampuses.map((campus) => ({ ...campus }))
departments.value = mockDepartments.map((department) => ({ ...department }))
roleRows.value = roleSummaries.map((role) => ({ ...role }))
roleOptions.value = roleSummaries.map((role) => ({
id: role.id,
code: role.id,
name: role.name,
description: role.description,
color: role.color,
userCount: role.userCount ?? undefined,
status: 'ENABLED' as const,
}))
userRows.value = users.map((user) => ({ ...user }))
roleColorMap.value = roleColors
userTotal.value = userRows.value.length
@@ -100,13 +148,20 @@ async function loadUsers() {
const selectedCampusId = campusRecords.find(
(campus) => campus.name === appStore.selectedCampus,
)?.id
const usersResult = await getUsers({
page: userPage.value,
pageSize: userPageSize.value,
keyword: keyword.value.trim() || undefined,
status: 'all',
campusId: selectedCampusId,
})
const [usersResult, roleSummaryResult] = await Promise.allSettled([
getUsers({
page: userPage.value,
pageSize: userPageSize.value,
keyword: keyword.value.trim() || undefined,
status: 'all',
campusId: selectedCampusId,
}),
getRoleSummaries(selectedCampusId),
])
if (usersResult.status === 'rejected') {
throw usersResult.reason
}
if (requestId !== userRequestId) {
return
@@ -124,22 +179,33 @@ async function loadUsers() {
)
campuses.value = campusesResult.status === 'fulfilled' ? campusesResult.value : []
departments.value = departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
const apiUsers = usersResult.records
const apiUsers = usersResult.value.records
userRows.value = apiUsers.map((user) => mapUserRow(user, campusNames, departmentNames))
userTotal.value = usersResult.total
userTotal.value = usersResult.value.total
const roles = rolesResult.status === 'fulfilled' ? rolesResult.value : []
roleRows.value = roles.map((role) => mapRoleSummary(role))
const summaryRoles = roleSummaryResult.status === 'fulfilled' ? roleSummaryResult.value : roles
roleOptions.value = roles.length ? roles : summaryRoles
roleRows.value = summaryRoles.map((role) => mapRoleSummary(role))
roleColorMap.value = Object.fromEntries(
roles.map((role) => [role.name, role.color ?? '#8a9aa3']),
roleOptions.value.map((role) => [role.name, role.color ?? '#8a9aa3']),
)
if (rolesResult.status === 'rejected' || roleSummaryResult.status === 'rejected') {
ElMessage.warning('角色列表或人数统计加载不完整,部分角色信息可能暂不可用')
}
if (!apiPermissions.value.length) {
void loadApiPermissionCatalog()
}
} catch {
if (requestId !== userRequestId) {
return
}
roleRows.value = []
roleOptions.value = []
userRows.value = []
userTotal.value = 0
ElMessage.error('用户与角色加载失败,请稍后重试')
@@ -150,6 +216,103 @@ async function loadUsers() {
}
}
async function loadSelectedRolePermissions() {
if (!selectedRoleId.value) {
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
return
}
const response = await getRolePermissions(selectedRoleId.value)
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
rolePermissionVersion.value = response.version
}
async function loadApiPermissionCatalog() {
if (isUserMockEnabled || apiPermissionLoading.value) {
return
}
apiPermissionLoading.value = true
apiPermissionError.value = ''
try {
const result = await getPermissions({ page: 1, pageSize: 200 })
apiPermissions.value = result.records
if (!roleOptions.value.some((role) => role.id === selectedRoleId.value)) {
selectedRoleId.value = roleOptions.value[0]?.id ?? ''
}
await loadSelectedRolePermissions()
} catch {
apiPermissionError.value = 'API 权限目录或角色权限加载失败,请检查当前账号权限'
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
} finally {
apiPermissionLoading.value = false
}
}
async function selectRoleForApiPermissions(roleId: string) {
selectedRoleId.value = roleId
apiPermissionLoading.value = true
apiPermissionError.value = ''
try {
await loadSelectedRolePermissions()
} catch {
selectedRolePermissionIds.value = []
rolePermissionVersion.value = undefined
apiPermissionError.value = '该角色的 API 权限加载失败,请稍后重试'
} finally {
apiPermissionLoading.value = false
}
}
function toggleApiPermission(permissionId: string) {
if (selectedRolePermissionIds.value.includes(permissionId)) {
selectedRolePermissionIds.value = selectedRolePermissionIds.value.filter(
(id) => id !== permissionId,
)
return
}
selectedRolePermissionIds.value = [...selectedRolePermissionIds.value, permissionId]
}
async function saveRoleApiPermissions() {
if (!selectedRoleId.value || apiPermissionSaving.value) {
return
}
const disabledPermission = apiPermissions.value.find(
(permission) =>
selectedRolePermissionIds.value.includes(permission.id) && permission.status === 'DISABLED',
)
if (disabledPermission) {
ElMessage.warning(`权限“${disabledPermission.name}”已停用,请取消勾选后再保存`)
return
}
apiPermissionSaving.value = true
try {
const response = await updateRolePermissions(selectedRoleId.value, {
permissionIds: selectedRolePermissionIds.value,
expectedVersion: rolePermissionVersion.value,
})
selectedRolePermissionIds.value = response.permissions.map((permission) => permission.id)
rolePermissionVersion.value = response.version
ElMessage.success('角色 API 权限已保存')
} catch {
ElMessage.error('角色 API 权限保存失败,请稍后重试')
} finally {
apiPermissionSaving.value = false
}
}
function mapUserRow(
user: ApiUserRecord,
campusNames: Map<string, string>,
@@ -177,8 +340,11 @@ function mapUserRow(
department: departmentNames.get(user.departmentId ?? '') ?? user.department ?? '未指定科室',
departmentId: user.departmentId,
role: user.role,
roleNames: user.roleNames,
roleIds: user.roleIds,
dataScope: dataScopeLabels[user.dataScope ?? ''] ?? user.dataScope ?? '—',
status: user.status,
version: user.version,
}
}
@@ -202,7 +368,14 @@ function showAddUserMessage() {
}
function showImportMessage() {
ElMessage.info('当前接口文档未提供批量导入用户能力')
if (isUserMockEnabled) {
ElMessage.info('批量导入需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
}
importPreview.value = null
importJob.value = null
importVisible.value = true
}
async function editUser(row: UserTableRow) {
@@ -234,6 +407,10 @@ async function editUser(row: UserTableRow) {
'',
status: user.status,
dataScope: toBackendDataScope(user.dataScope),
roleIds: user.roleIds ?? [],
clearPhone: false,
clearEmail: false,
version: user.version,
}
editorVisible.value = true
} catch {
@@ -242,7 +419,79 @@ async function editUser(row: UserTableRow) {
}
function resetPassword(user: UserTableRow) {
ElMessage.info(`当前接口文档未提供重置${user.name}密码的能力,请由认证系统或管理员流程处理`)
if (isUserMockEnabled) {
ElMessage.info('密码重置需要切换到真实接口模式(VITE_USE_MOCK=false)')
return
}
passwordResetTarget.value = user
passwordResetVisible.value = true
}
async function handlePasswordReset(payload: {
newPassword: string
forceChangeOnNextLogin: boolean
}) {
const target = passwordResetTarget.value
if (!target) {
return
}
passwordResetSaving.value = true
try {
await resetUserPassword(target.id, payload)
passwordResetVisible.value = false
passwordResetTarget.value = null
ElMessage.success(`用户“${target.name}”的密码已重置`)
} catch {
ElMessage.error('密码重置失败,请稍后重试')
} finally {
passwordResetSaving.value = false
}
}
async function handleImportPreview(payload: { file: File; campusId?: string }) {
importLoading.value = true
importPreview.value = null
importJob.value = null
try {
importPreview.value = await previewUserImport(payload.file, payload.campusId)
} catch {
ElMessage.error('用户文件校验失败,请检查文件格式和内容')
} finally {
importLoading.value = false
}
}
async function handleImportCommit() {
const preview = importPreview.value
if (!preview || preview.invalidRows > 0) {
return
}
importLoading.value = true
try {
const job = await commitUserImport({ importId: preview.importId })
importJob.value = job
try {
importJob.value = await getUserImportJob(job.jobId)
} catch {
// 提交已经成功,任务状态查询失败不影响本次导入结果。
}
ElMessage.success('用户导入任务已提交')
await loadUsers()
} catch {
ElMessage.error('用户导入提交失败,请稍后重试')
} finally {
importLoading.value = false
}
}
function searchUsers() {
@@ -299,7 +548,23 @@ function toBackendDataScope(value: string | undefined) {
全院只读: 'READ_ONLY_ALL',
}
return map[value ?? ''] ?? value ?? 'DEPARTMENT'
return (map[value ?? ''] ?? value ?? 'DEPARTMENT') as BackendDataScope
}
function toBackendUserStatus(value: UserTableRow['status']): BackendUserStatus {
const map: Record<UserTableRow['status'], BackendUserStatus> = {
enabled: 'ENABLED',
disabled: 'DISABLED',
locked: 'LOCKED',
}
return map[value]
}
function getRoleNames(roleIds: string[]) {
return roleIds
.map((roleId) => roleOptions.value.find((role) => role.id === roleId)?.name)
.filter((name): name is string => Boolean(name))
}
function getCampusName(id: string) {
@@ -326,11 +591,12 @@ async function saveUser(form: UserEditorForm) {
campusId: form.campusId,
department: getDepartmentName(form.departmentId),
departmentId: form.departmentId || null,
role: form.id
? (userRows.value.find((item) => item.id === form.id)?.role ?? '未分配角色')
: '未分配角色',
role: getRoleNames(form.roleIds).join('、') || '未分配角色',
roleNames: getRoleNames(form.roleIds),
roleIds: [...form.roleIds],
dataScope: form.dataScope,
status: form.status,
version: form.version,
}
const index = userRows.value.findIndex((item) => item.id === row.id)
if (index === -1) {
@@ -339,15 +605,25 @@ async function saveUser(form: UserEditorForm) {
userRows.value[index] = row
}
} else if (form.id) {
await updateUser(form.id, {
await updateUserManagement(form.id, {
displayName: form.displayName,
employeeNo: form.employeeNo || undefined,
...(form.phoneMasked ? {} : { phone: form.phone || undefined }),
...(form.emailMasked ? {} : { email: form.email || undefined }),
...(form.phoneMasked
? {}
: form.clearPhone
? { clearPhone: true }
: { phone: form.phone || undefined }),
...(form.emailMasked
? {}
: form.clearEmail
? { clearEmail: true }
: { email: form.email || undefined }),
campusId: form.campusId,
departmentId: form.departmentId || null,
status: form.status.toUpperCase(),
status: toBackendUserStatus(form.status),
dataScope: form.dataScope,
roleIds: form.roleIds,
expectedVersion: form.version,
})
await loadUsers()
} else {
@@ -360,8 +636,9 @@ async function saveUser(form: UserEditorForm) {
email: form.email || undefined,
campusId: form.campusId,
departmentId: form.departmentId || null,
status: form.status.toUpperCase(),
status: toBackendUserStatus(form.status),
dataScope: form.dataScope,
roleIds: form.roleIds,
})
await loadUsers()
}
@@ -442,6 +719,20 @@ onMounted(loadUsers)
</button>
</div>
</section>
<RoleApiPermissionPanel
v-if="!isUserMockEnabled"
:roles="roleOptions"
:permissions="apiPermissions"
:selected-role-id="selectedRoleId"
:selected-permission-ids="selectedRolePermissionIds"
:loading="apiPermissionLoading"
:saving="apiPermissionSaving"
:error="apiPermissionError"
@select-role="selectRoleForApiPermissions"
@toggle-permission="toggleApiPermission"
@save="saveRoleApiPermissions"
/>
</template>
<UserEditorDialog
@@ -449,10 +740,29 @@ onMounted(loadUsers)
:initial="editorInitial"
:campuses="campuses"
:departments="departments"
:roles="roleOptions"
:saving="editorSaving"
@close="editorVisible = false"
@save="saveUser"
/>
<UserPasswordResetDialog
:visible="passwordResetVisible"
:user-name="passwordResetTarget?.name ?? ''"
:saving="passwordResetSaving"
@close="passwordResetVisible = false"
@reset="handlePasswordReset"
/>
<UserImportDialog
:visible="importVisible"
:campuses="campuses"
:default-campus-id="campuses.find((campus) => campus.name === appStore.selectedCampus)?.id"
:preview="importPreview"
:job="importJob"
:loading="importLoading"
@close="importVisible = false"
@preview="handleImportPreview"
@commit="handleImportCommit"
/>
</section>
</template>
@@ -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
}