diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index af648cf..8372e88 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -149,9 +149,11 @@ func platformScopedRequestAllowed(ctx *gin.Context, menus []platformbase.Menu) b return hasMenuIdentity(menus, "staff_add") } if ctx.Request.Method == "GET" { - required := staffMenuIdentity(ctx.Query("role_code")) - return required != "" && (hasMenuIdentity(menus, required) || - (required == "staff_delivery" && hasMenuIdentity(menus, "gasorder_basic"))) + return staffListRolesAllowed( + menus, + ctx.Query("role_code"), + ctx.Query("role_codes"), + ) } return false } @@ -217,6 +219,33 @@ func staffMenuIdentity(roleCode string) string { } } +// staffListRolesAllowed 要求查询中的每一种工作人员角色都具备对应菜单权限。 +func staffListRolesAllowed(menus []platformbase.Menu, roleCode, roleCodes string) bool { + roleCode = strings.TrimSpace(roleCode) + roleCodes = strings.TrimSpace(roleCodes) + if (roleCode == "") == (roleCodes == "") { + return false + } + requested := []string{roleCode} + if roleCodes != "" { + requested = strings.Split(roleCodes, ",") + } + seen := map[string]bool{} + for _, role := range requested { + role = strings.TrimSpace(role) + required := staffMenuIdentity(role) + if required == "" || seen[role] { + return false + } + seen[role] = true + if !hasMenuIdentity(menus, required) && + !(required == "staff_delivery" && hasMenuIdentity(menus, "gasorder_basic")) { + return false + } + } + return true +} + func hasMenuIdentity(menus []platformbase.Menu, identity string) bool { for _, menu := range menus { if menu.Identity == identity { diff --git a/backend/api/internal/logic/platform/platform/access_test.go b/backend/api/internal/logic/platform/platform/access_test.go index 8a9c405..bf53aa2 100644 --- a/backend/api/internal/logic/platform/platform/access_test.go +++ b/backend/api/internal/logic/platform/platform/access_test.go @@ -76,3 +76,31 @@ func TestLocationScopeValuesAreExplicit(t *testing.T) { t.Fatal("ambiguous location scope was accepted") } } + +func TestStaffListMultiRoleQueryRequiresEveryRoleMenu(t *testing.T) { + allMenus := []platformbase.Menu{ + {Identity: "staff_installer"}, + {Identity: "staff_delivery"}, + {Identity: "staff_operations"}, + } + if !staffListRolesAllowed(allMenus, "", "installer,delivery,operations") { + t.Fatal("authorized multi-role staff query was rejected") + } + partialMenus := []platformbase.Menu{{Identity: "staff_installer"}} + if staffListRolesAllowed(partialMenus, "", "installer,delivery,operations") { + t.Fatal("partial staff permission granted a multi-role query") + } + if staffListRolesAllowed(allMenus, "delivery", "installer,delivery") { + t.Fatal("ambiguous staff role filters were accepted") + } +} + +func TestOrderMenuCanOnlyQueryDeliveryStaff(t *testing.T) { + menus := []platformbase.Menu{{Identity: "gasorder_basic"}} + if !staffListRolesAllowed(menus, "delivery", "") { + t.Fatal("order management could not query delivery staff") + } + if staffListRolesAllowed(menus, "installer", "") { + t.Fatal("order management could query installer staff") + } +} diff --git a/backend/api/internal/logic/platform/staff/staff.go b/backend/api/internal/logic/platform/staff/staff.go index 2792588..1039fbf 100644 --- a/backend/api/internal/logic/platform/staff/staff.go +++ b/backend/api/internal/logic/platform/staff/staff.go @@ -1,6 +1,7 @@ package staff import ( + "strconv" "strings" "git.apinb.com/bsm-sdk/core/errcode" @@ -12,23 +13,80 @@ import ( "github.com/gin-gonic/gin" ) -// ListStaff 查询服务人员分页列表。 -func ListStaff(ctx *gin.Context) { - roleCode := strings.TrimSpace(ctx.Query("role_code")) - if roleCode == "" { - common.ListPage[models.StaffAccount](ctx) - return +type staffListFilters struct { + RoleCodes []string + Status *int + WorkStatus string +} + +// parseStaffListFilters 将工作人员列表查询参数收敛为闭集条件,拒绝含糊或冲突值。 +func parseStaffListFilters(roleCode, roleCodes, status, workStatus string) (staffListFilters, bool) { + filters := staffListFilters{} + roleCode = strings.TrimSpace(roleCode) + roleCodes = strings.TrimSpace(roleCodes) + if roleCode != "" && roleCodes != "" { + return filters, false } - if !validStaffRole(roleCode) { + if roleCode != "" { + filters.RoleCodes = []string{roleCode} + } else if roleCodes != "" { + seen := map[string]bool{} + for _, value := range strings.Split(roleCodes, ",") { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return staffListFilters{}, false + } + seen[value] = true + filters.RoleCodes = append(filters.RoleCodes, value) + } + } + for _, value := range filters.RoleCodes { + if !validStaffRole(value) { + return staffListFilters{}, false + } + } + status = strings.TrimSpace(status) + if status != "" { + value, err := strconv.Atoi(status) + if err != nil || !common.IsGenericRecordStatus(value) || value == common.StatusArchived { + return staffListFilters{}, false + } + filters.Status = &value + } + workStatus = strings.TrimSpace(workStatus) + if workStatus != "" && !validWorkStatus(workStatus) { + return staffListFilters{}, false + } + filters.WorkStatus = workStatus + return filters, true +} + +// ListStaff 查询服务人员分页列表,并应用调用方显式声明的角色与在岗状态条件。 +func ListStaff(ctx *gin.Context) { + filters, ok := parseStaffListFilters( + ctx.Query("role_code"), + ctx.Query("role_codes"), + ctx.Query("status"), + ctx.Query("work_status"), + ) + if !ok { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } page, size := common.PageSize(ctx) var list []models.StaffAccount var total int64 - query := common.ApplyKeywordFilter(ctx, - common.ActiveRecords(impl.DBService.Model(&models.StaffAccount{})).Where("role_code = ?", roleCode), - &models.StaffAccount{}) + query := common.ActiveRecords(impl.DBService.Model(&models.StaffAccount{})) + if len(filters.RoleCodes) > 0 { + query = query.Where("role_code IN ?", filters.RoleCodes) + } + if filters.Status != nil { + query = query.Where("status = ?", *filters.Status) + } + if filters.WorkStatus != "" { + query = query.Where("work_status = ?", filters.WorkStatus) + } + query = common.ApplyKeywordFilter(ctx, query, &models.StaffAccount{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return diff --git a/backend/api/internal/logic/platform/staff/staff_test.go b/backend/api/internal/logic/platform/staff/staff_test.go index ecb6bf3..2436f5a 100644 --- a/backend/api/internal/logic/platform/staff/staff_test.go +++ b/backend/api/internal/logic/platform/staff/staff_test.go @@ -23,3 +23,33 @@ func TestStaffRoleIsClosedEnumeration(t *testing.T) { } } } + +func TestStaffListFiltersAcceptExplicitBusinessScopes(t *testing.T) { + filters, ok := parseStaffListFilters("delivery", "", "1", "on_duty") + if !ok || len(filters.RoleCodes) != 1 || filters.RoleCodes[0] != "delivery" { + t.Fatal("delivery assignment filters were rejected") + } + if filters.Status == nil || *filters.Status != 1 || filters.WorkStatus != "on_duty" { + t.Fatal("staff status filters were not preserved") + } + filters, ok = parseStaffListFilters("", "installer,delivery,operations", "1", "") + if !ok || len(filters.RoleCodes) != 3 { + t.Fatal("multi-role service-relation filters were rejected") + } +} + +func TestStaffListFiltersRejectAmbiguousOrUnknownValues(t *testing.T) { + tests := [][4]string{ + {"delivery", "installer,delivery", "", ""}, + {"", "delivery,delivery", "", ""}, + {"admin", "", "", ""}, + {"", "", "3", ""}, + {"", "", "enabled", ""}, + {"", "", "", "available"}, + } + for _, test := range tests { + if _, ok := parseStaffListFilters(test[0], test[1], test[2], test[3]); ok { + t.Fatalf("invalid staff filters were accepted: %#v", test) + } + } +} diff --git a/docs/操作日志_工作人员资质关联修复_20260811.md b/docs/操作日志_工作人员资质关联修复_20260811.md new file mode 100644 index 0000000..064160a --- /dev/null +++ b/docs/操作日志_工作人员资质关联修复_20260811.md @@ -0,0 +1,71 @@ +# 工作人员资质关联修复操作日志 + +操作时间:2026-08-11 18:28:22 +操作类型:修改、扩展 +影响模块:平台总后台工作人员资质、订单分配、用户服务关系、平台工作人员列表接口 + +## 操作前状态 + +- 平台前端只要加载 `/staff_account` 关联选项,就会无条件附加 `role_code=delivery`。 +- 从安装人员或运维人员页面进入资质新建页时,预填 UUID 无法在配送人员选项中匹配,选择器只能显示裸 UUID。 +- 资质新建页仍允许更换工作人员,可能破坏“指定人员下的资质管理”上下文。 +- `owner_name` 来自 URL,缺少服务端人员详情校验;直接访问、角色变化和归档人员没有统一阻断规则。 +- 工作人员列表接口只支持单一 `role_code`,不能表达“全部角色中的启用人员”或“启用且在岗的配送人员”。 + +## 具体操作 + +- 新增工作人员关系策略: + - 人员资质按来源菜单角色查询,并锁定已预填人员。 + - 订单分配只查询启用且在岗的配送人员。 + - 用户服务关系查询安装、配送、运维三类启用人员。 + - 未声明策略的工作人员关联立即报错,不再继承隐含默认值。 +- 新增资质来源校验: + - 显式传递 `staff_type`,并允许从安全站内 `return_to` 回退解析。 + - 始终按 UUID 查询服务端人员详情,不信任 `owner_name`。 + - 人员缺失、归档、无权访问或真实角色与来源冲突时阻止创建。 +- 优化人员展示:主值显示“姓名(角色)”,资质页 UUID 作为次要可复制信息;详情与表单都会按当前值补载关系记录,避免分页筛选后退化成裸标识。 +- 扩展工作人员列表接口:新增可选 `role_codes`、`status`、`work_status` 参数;保留原 `role_code` 和无过滤查询兼容性。 +- 扩展平台访问控制:多角色查询要求调用账号拥有每一种目标角色对应的菜单权限;订单菜单仍仅可查询配送人员。 +- 增加前后端回归测试和前端策略检查命令。 + +## 操作后状态 + +- 安装人员资质新建页能够稳定显示“曹(安装人员)”,人员控件不可修改。 +- 页面只显示可复制的唯一标识尾号,不再把完整 UUID 当作人员名称候选项。 +- 无人员/角色来源的直接新建地址显示“无法执行此操作”,不渲染可保存表单。 +- 角色冲突不会静默改绑;用户需要从工作人员当前角色菜单重新进入。 +- 三个工作人员关联场景各自拥有明确的角色、启用状态和在岗状态契约。 +- 后端仍以真实工作人员记录和菜单权限作为最终鉴权依据,前端查询参数不能扩大权限。 + +## 代码变更 + +- `frontend/platform_admin/src/api/resource-staff-relation.ts`:新增角色闭集、字段过滤、返回路径解析和资质所有者校验。 +- `frontend/platform_admin/src/api/resources.ts`:为三种工作人员关联字段声明独立策略,并兼容扩展关系字段构造函数。 +- `frontend/platform_admin/src/views/resource/use-resource-relations.ts`:删除全局配送人员特例,按字段加载/搜索并补载当前关系值。 +- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:接入资质人员服务端校验、锁定和异常阻断。 +- `frontend/platform_admin/src/views/resource/use-staff-credential-owner-guard.ts`:拆分资质所有者回查、角色校验与错误结果。 +- `frontend/platform_admin/src/views/resource/load-resource-record-relations.ts`:拆分关系补载与动态平台角色加载,控制主页面文件规模。 +- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`、`ResourceDetailContent.vue`、`resource-display.ts`:展示人员姓名、角色和可复制标识。 +- `frontend/platform_admin/src/views/shared/CrudListPage.vue`:在工作人员、资质列表及记录页面之间传递 `staff_type`。 +- `backend/api/internal/logic/platform/staff/staff.go`:解析并应用角色、状态和工作状态过滤。 +- `backend/api/internal/logic/platform/platform/access.go`:校验单角色和多角色工作人员查询权限。 +- `backend/api/internal/logic/platform/staff/staff_test.go`、`platform/access_test.go`:覆盖过滤闭集和访问控制。 +- `frontend/platform_admin/scripts/check-staff-relation-policy.mjs`、`package.json`:新增前端关系策略回归检查。 + +## 验证结果 + +- `npm.cmd run type:check`:通过。 +- `npm.cmd run staff-relations:check`:通过。 +- `npm.cmd run build`:通过,Vite 生产构建完成。 +- `go test ./internal/logic/platform/staff ./internal/logic/platform/platform`:通过。 +- `go test ./internal/logic/platform/...`:通过。 +- 本地浏览器只读验证:安装人员名称和角色正确回显,人员控件禁用,唯一标识可复制;直接访问缺少上下文时正确阻断;未提交测试数据。 +- `git diff --check`:通过。 + +## 风险评估 + +- 多角色工作人员查询采用严格权限交集:调用账号必须同时拥有安装、配送和运维人员菜单权限;权限不足时服务端拒绝整次查询,避免返回部分结果造成误解。 +- 关系下拉仍以每次最多 100 条加载并支持关键字搜索;已保存或预填人员会按 UUID 独立补载。 +- 资质编辑和详情按工作人员当前角色鉴权;角色变更不会删除历史资质,但旧角色来源的新建上下文会被阻断。 +- 本次不修改数据库结构、资质归属关系或工作人员角色变更规则,不触碰现有运行日志和 `runtime/` 数据。 +- `ResourceRecordPage.vue` 原本已超过 500 行;本次把新增校验和关系加载职责拆到两个独立文件,将主文件由实施中峰值 588 行降至 522 行,未继续重构既有保存和状态流程以控制改动范围。 diff --git a/docs/项目文档_工作人员资质关联_v1.0.md b/docs/项目文档_工作人员资质关联_v1.0.md new file mode 100644 index 0000000..6163c2e --- /dev/null +++ b/docs/项目文档_工作人员资质关联_v1.0.md @@ -0,0 +1,121 @@ +# 工作人员资质关联项目文档 v1.0 + +## 1. 项目概述 + +- 项目名称:平台总后台工作人员资质关联修复。 +- 项目目标:消除安装/运维人员资质页面显示裸 UUID 的问题,并建立可维护的工作人员字段级过滤和安全校验机制。 +- 主要功能:角色化人员查询、资质所有者锁定、姓名与角色回显、异常来源阻断、工作人员状态过滤。 +- 技术栈:Vue 3、TypeScript、Vue Router、Arco Design、Go、Gin、GORM。 +- 运行环境:平台总后台 `frontend/platform_admin`;平台 API `backend/api`。 + +## 2. 目录结构 + +```text +platforms/ +├── frontend/platform_admin/ +│ ├── scripts/ +│ │ └── check-staff-relation-policy.mjs # 工作人员关系策略回归检查 +│ └── src/ +│ ├── api/ +│ │ ├── resource-staff-relation.ts # 角色、过滤和资质来源校验 +│ │ ├── resource-display.ts # 工作人员姓名与角色展示 +│ │ └── resources.ts # 字段级工作人员关系声明 +│ └── views/ +│ ├── resource/ +│ │ ├── ResourceRecordPage.vue # 资质所有者校验与异常阻断 +│ │ ├── ResourceFieldForm.vue # 锁定人员与标识复制 +│ │ ├── use-staff-credential-owner-guard.ts # 资质所有者守卫 +│ │ ├── load-resource-record-relations.ts # 关系与角色加载器 +│ │ └── use-resource-relations.ts # 策略化关系加载与补载 +│ └── shared/CrudListPage.vue # 角色上下文路由传递 +├── backend/api/internal/logic/platform/ +│ ├── staff/staff.go # 人员列表组合过滤 +│ └── platform/access.go # 人员查询与资质访问控制 +└── docs/ # 项目文档与操作日志 +``` + +## 3. 核心文件说明 + +### 3.1 `resource-staff-relation.ts` + +- `StaffRelationPolicy`:声明角色范围、启用状态、工作状态、预填锁定和标识展示能力。 +- `staffRelationFilters`:把字段策略转换为稳定查询参数;工作人员字段缺少策略时立即失败。 +- `resolveCredentialStaffRole`:组合显式 `staff_type` 和安全返回路径,拒绝来源冲突。 +- `credentialOwnerValidationMessage`:校验 UUID、服务端人员记录、归档状态和真实角色。 + +### 3.2 `use-resource-relations.ts` + +- `loadField`、`searchField`:根据完整字段配置加载和搜索关系,取代按资源地址写死的全局行为。 +- `ensureValues`:对表单或详情中的已保存 UUID 独立补载,避免前 100 条限制导致裸标识回显。 +- 普通关系字段保持原接口和加载行为,字段级工作人员策略属于向下兼容扩展。 + +### 3.3 `staff.go` + +- `parseStaffListFilters`:解析互斥的 `role_code`/`role_codes`,并验证 `status`、`work_status` 闭集。 +- `ListStaff`:在排除归档记录的基础上按角色集合、实体状态和工作状态精确过滤。 +- 原有单角色和无过滤查询继续有效,不修改响应结构。 + +### 3.4 `access.go` + +- 单角色查询要求对应工作人员菜单权限。 +- 多角色查询要求每一种角色权限都满足,禁止借助组合参数扩大数据范围。 +- 订单管理权限仍只获得配送人员查询能力。 +- 资质写入继续根据请求中的人员 UUID 查询真实角色后鉴权,不信任前端 `staff_type`。 + +## 4. 业务行为 + +| 场景 | 人员范围 | 页面行为 | +| --- | --- | --- | +| 安装人员资质 | 当前安装人员 | 姓名与角色回显,人员锁定 | +| 配送人员资质 | 当前配送人员 | 姓名与角色回显,人员锁定 | +| 运维人员资质 | 当前运维人员 | 姓名与角色回显,人员锁定 | +| 订单分配 | 启用且在岗的配送人员 | 可搜索、可选择 | +| 用户服务关系 | 三类启用工作人员 | 可搜索、可选择 | +| 缺少资质上下文 | 无 | 阻止创建并提示从工作人员页面进入 | +| 来源角色冲突 | 无 | 阻止创建并提示从当前角色菜单重新进入 | + +## 5. 接口约定 + +`GET /staff_account` 新增以下可选参数: + +- `role_code`:单一角色,取值为 `installer`、`delivery`、`operations`。 +- `role_codes`:逗号分隔的多角色集合;不得与 `role_code` 同时出现,不得重复。 +- `status`:通用实体状态;关系选择当前使用 `1` 表示启用,归档状态不允许作为活动列表过滤值。 +- `work_status`:工作状态,取值为 `on_duty` 或 `off_duty`。 + +无效、冲突或越权参数返回现有非法参数/权限错误结构,不改变公共响应协议。 + +## 6. 变更记录 + +- 删除前端 `/staff_account` 全局强制配送人员过滤。 +- 新增三类业务字段的显式工作人员策略。 +- 新增资质人员服务端回查、角色校验和只读展示。 +- 新增工作人员组合过滤及多角色权限校验。 +- 新增前端策略检查和 Go 单元测试。 +- 数据库结构和公共写入接口未变化。 + +## 7. 维护指南 + +- 新增任何 `/staff_account` 关联字段时,必须配置 `staffRelation`,明确角色、实体状态和工作状态。 +- 不能在通用关系加载器中根据资源地址添加角色默认值。 +- 新增角色前需同时更新前后端角色闭集、角色中文名称、菜单映射和回归测试。 +- 需要展示历史人员时使用当前值补载;创建型选择器则按业务策略过滤,二者不能混为同一规则。 +- 资质来源名称必须从服务端人员详情取得,禁止信任 URL 中的 `owner_name`。 +- 修改工作人员过滤参数后运行: + +```powershell +cd frontend/platform_admin +npm.cmd run staff-relations:check +npm.cmd run type:check +npm.cmd run build + +cd ../../backend/api +go test ./internal/logic/platform/... +``` + +## 8. 已知边界 + +- 关系列表单次最多返回 100 条,更多记录通过关键字搜索获取。 +- 多角色查询不返回“调用者有权访问的部分集合”;任何目标角色权限不足都会拒绝整次请求。 +- 本次只覆盖平台总后台;气站和配送点后台继续使用各自组织范围内的工作人员接口。 +- 不新增人员姓名历史快照;人员已归档后,当前平台权限模型会阻止继续新建资质。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 7e88197..86153bf 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -17,6 +17,7 @@ "account-roles:check": "node scripts/check-account-role-presentation.mjs", "avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs", "staff-organization:check": "node scripts/check-staff-organization-linkage.mjs", + "staff-relations:check": "node scripts/check-staff-relation-policy.mjs", "audit:platform": "node scripts/check-backend-contract.mjs", "lint": "biome lint .", "lint:fix": "biome lint --write .", diff --git a/frontend/platform_admin/scripts/check-staff-relation-policy.mjs b/frontend/platform_admin/scripts/check-staff-relation-policy.mjs new file mode 100644 index 0000000..6731f5e --- /dev/null +++ b/frontend/platform_admin/scripts/check-staff-relation-policy.mjs @@ -0,0 +1,122 @@ +/** + * 功能:验证工作人员关联字段过滤、资质来源校验和路由上下文传递契约。 + * 版本:v1.0.0 + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { transformWithOxc } from 'vite'; + +const policyURL = new URL( + '../src/api/resource-staff-relation.ts', + import.meta.url, +); +const source = await readFile(policyURL, 'utf8'); +const transformed = await transformWithOxc(source, policyURL.pathname); +const moduleURL = `data:text/javascript;base64,${Buffer.from(transformed.code).toString('base64')}`; +const { + credentialOwnerValidationMessage, + resolveCredentialStaffRole, + staffRelationFilters, + staffRoleFromReturnPath, +} = await import(moduleURL); + +assert.deepEqual( + staffRelationFilters( + { + relation: '/staff_account', + staffRelation: { + roles: ['delivery'], + enabledOnly: true, + workStatus: 'on_duty', + }, + }, + {}, + ), + { role_code: 'delivery', status: '1', work_status: 'on_duty' }, + '订单分配必须只查询启用且在岗的配送人员', +); + +assert.deepEqual( + staffRelationFilters( + { + relation: '/staff_account', + staffRelation: { + roles: ['installer', 'delivery', 'operations'], + enabledOnly: true, + }, + }, + {}, + ), + { role_codes: 'installer,delivery,operations', status: '1' }, + '用户服务关系必须查询全部角色中的启用人员', +); + +assert.deepEqual( + staffRelationFilters( + { + relation: '/staff_account', + staffRelation: { roles: 'context' }, + }, + { staffType: 'installer' }, + ), + { role_code: 'installer' }, + '人员资质必须继承当前工作人员菜单角色', +); + +assert.throws( + () => staffRelationFilters({ relation: '/staff_account' }, {}), + /缺少显式过滤策略/, + '未声明策略的工作人员关系必须立即失败', +); + +assert.equal( + staffRoleFromReturnPath( + '/staff/credential?return_to=%2Fstaff%2Foperations%3Fpage%3D2', + ), + 'operations', + '返回路径必须能回退解析工作人员角色', +); +assert.equal( + resolveCredentialStaffRole( + 'installer', + '/staff/credential?staff_type=delivery', + ).error, + '工作人员角色与来源菜单不一致', +); +assert.equal( + credentialOwnerValidationMessage('staff-a', 'installer', { + identity: 'staff-a', + role_code: 'delivery', + status: 1, + }), + '工作人员角色已变化,请从当前角色菜单重新进入', +); + +const resourcesSource = await readFile( + new URL('../src/api/resources.ts', import.meta.url), + 'utf8', +); +assert.equal( + (resourcesSource.match(/staffRelation:/g) ?? []).length, + 3, + '三个工作人员关联场景都必须显式声明策略', +); + +const relationsSource = await readFile( + new URL('../src/views/resource/use-resource-relations.ts', import.meta.url), + 'utf8', +); +assert.doesNotMatch( + relationsSource, + /resource === ['"]\/staff_account['"].*delivery/, + '关系加载器不得恢复全局配送人员特例', +); + +const listSource = await readFile( + new URL('../src/views/shared/CrudListPage.vue', import.meta.url), + 'utf8', +); +assert.match(listSource, /staff_type: staffType\.value/); +assert.match(listSource, /'staff_type'/); + +console.log('工作人员关联与资质上下文检查通过'); diff --git a/frontend/platform_admin/src/api/resource-display.ts b/frontend/platform_admin/src/api/resource-display.ts index 25561de..1b37bd2 100644 --- a/frontend/platform_admin/src/api/resource-display.ts +++ b/frontend/platform_admin/src/api/resource-display.ts @@ -3,6 +3,7 @@ * 版本:v1.1.0 */ import dayjs from 'dayjs'; +import { staffRoleLabel } from './resource-staff-relation'; import type { ResourceField, ResourceUiDefinition } from './resources'; import type { RecordPageMode, ResourceRow } from './resource-page-rules'; @@ -110,6 +111,14 @@ export function optionLabel(option: ResourceRow) { ); } +/** 工作人员关系额外展示角色,其他关系保持原有可读名称。 */ +export function relationOptionLabel(field: ResourceField, option: ResourceRow) { + const label = optionLabel(option); + return field.relation === '/staff_account' + ? `${label}(${staffRoleLabel(option.role_code)})` + : label; +} + function relationLabel( field: ResourceField, identity: string, @@ -119,6 +128,9 @@ function relationLabel( (option) => String(option.identity) === identity, ); if (!match) return identity; + if (field.relation === '/staff_account') { + return relationOptionLabel(field, match); + } return field.displayRelationLabel ? optionLabel(match) : `${optionLabel(match)} · ${identity}`; @@ -161,7 +173,10 @@ export function displayResourceField( field: ResourceField, row: ResourceRow, relationOptions: Record, - fieldOptions: Record> = {}, + fieldOptions: Record< + string, + Array<{ label: string; value: string | number }> + > = {}, ) { const value = row[field.key] ?? row[`${field.key}_masked`]; if (value == null || value === '') return field.emptyText ?? '-'; @@ -169,9 +184,7 @@ export function displayResourceField( Object.prototype.hasOwnProperty.call(fieldOptions, field.key) || Boolean(field.options); const options = fieldOptions[field.key] ?? field.options; - const option = options?.find( - (item) => String(item.value) === String(value), - ); + const option = options?.find((item) => String(item.value) === String(value)); if (option) return option.label; if (hasOptionSource && field.unknownValueLabel) { return `${field.unknownValueLabel}(${String(value)})`; diff --git a/frontend/platform_admin/src/api/resource-staff-relation.ts b/frontend/platform_admin/src/api/resource-staff-relation.ts new file mode 100644 index 0000000..1da7a8a --- /dev/null +++ b/frontend/platform_admin/src/api/resource-staff-relation.ts @@ -0,0 +1,136 @@ +/** + * 功能:定义工作人员关联字段的显式过滤、角色展示与资质来源校验策略。 + * 版本:v1.0.0 + */ + +export const staffRoleCodes = ['installer', 'delivery', 'operations'] as const; + +export type StaffRoleCode = (typeof staffRoleCodes)[number]; + +export type StaffRelationPolicy = { + roles: readonly StaffRoleCode[] | 'context'; + enabledOnly?: boolean; + workStatus?: 'on_duty' | 'off_duty'; + lockPrefilled?: boolean; + showIdentityCopy?: boolean; +}; + +export type StaffRelationContext = { + staffType?: string; +}; + +type StaffRelationField = { + relation?: string; + staffRelation?: StaffRelationPolicy; +}; + +type StaffOwner = { + identity?: unknown; + role_code?: unknown; + status?: unknown; +}; + +const roleLabels: Record = { + installer: '安装人员', + delivery: '配送人员', + operations: '运维人员', +}; + +/** 将外部字符串收敛为工作人员闭集角色。 */ +export function normalizeStaffRole(value: unknown): StaffRoleCode | '' { + const role = String(value ?? '').trim(); + return staffRoleCodes.includes(role as StaffRoleCode) + ? (role as StaffRoleCode) + : ''; +} + +/** 返回工作人员角色的中文名称。 */ +export function staffRoleLabel(value: unknown) { + const role = normalizeStaffRole(value); + return role ? roleLabels[role] : '未知角色'; +} + +/** + * 为工作人员关联字段构造服务端过滤条件;缺少显式策略时立即失败, + * 防止业务字段再次继承隐含的配送人员默认值。 + */ +export function staffRelationFilters( + field: StaffRelationField, + context: StaffRelationContext = {}, +) { + if (field.relation !== '/staff_account') return {}; + if (!field.staffRelation) { + throw new Error('工作人员关联字段缺少显式过滤策略'); + } + const roles = + field.staffRelation.roles === 'context' + ? [normalizeStaffRole(context.staffType)].filter( + (role): role is StaffRoleCode => Boolean(role), + ) + : [...field.staffRelation.roles]; + if (!roles.length) throw new Error('工作人员关联字段缺少有效角色上下文'); + const filters: Record = + roles.length === 1 + ? { role_code: roles[0] } + : { role_codes: roles.join(',') }; + if (field.staffRelation.enabledOnly) filters.status = '1'; + if (field.staffRelation.workStatus) { + filters.work_status = field.staffRelation.workStatus; + } + return filters; +} + +/** 从站内返回地址中推断工作人员菜单角色,仅用作显式参数缺失时的回退。 */ +export function staffRoleFromReturnPath(returnTo: string) { + let current = returnTo; + for (let depth = 0; depth < 3 && current; depth += 1) { + const url = new URL(current, 'http://codex.local'); + const queryRole = normalizeStaffRole(url.searchParams.get('staff_type')); + if (queryRole) return queryRole; + const pathRole = [ + ['/staff/installers', 'installer'], + ['/staff/delivery', 'delivery'], + ['/staff/operations', 'operations'], + ].find(([path]) => url.pathname.startsWith(path))?.[1]; + if (pathRole) return pathRole as StaffRoleCode; + current = url.searchParams.get('return_to') ?? ''; + } + return ''; +} + +/** 解析资质页面角色来源,并拒绝显式参数与返回路径互相冲突。 */ +export function resolveCredentialStaffRole( + explicitRole: unknown, + returnTo: string, +) { + const explicit = normalizeStaffRole(explicitRole); + const inferred = staffRoleFromReturnPath(returnTo); + if (explicit && inferred && explicit !== inferred) { + return { role: '' as const, error: '工作人员角色与来源菜单不一致' }; + } + const role = explicit || inferred; + return role + ? { role, error: '' } + : { + role: '' as const, + error: '缺少工作人员角色来源,请从工作人员页面进入', + }; +} + +/** 校验资质所有者与经服务端查询得到的真实人员记录是否一致。 */ +export function credentialOwnerValidationMessage( + ownerIdentity: string, + expectedRole: StaffRoleCode, + owner: StaffOwner | undefined, +) { + if (!ownerIdentity) return '缺少工作人员唯一标识,请从工作人员页面进入'; + if (!owner) return '工作人员不存在、已归档或无权访问'; + if (String(owner.identity ?? '') !== ownerIdentity) { + return '工作人员唯一标识与查询结果不一致'; + } + if (normalizeStaffRole(owner.role_code) !== expectedRole) { + return '工作人员角色已变化,请从当前角色菜单重新进入'; + } + if (Number(owner.status) === 3) return '已归档工作人员不能新增资质'; + return ''; +} diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index aa4e898..babe719 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -41,6 +41,7 @@ export type ResourceField = { readonlyOnCreate?: boolean; unknownValueLabel?: string; relationLinkage?: ResourceRelationLinkage; + staffRelation?: import('./resource-staff-relation').StaffRelationPolicy; }; export type DetailAction = { @@ -289,8 +290,13 @@ function f(key: string, options: Partial = {}): ResourceField { return { key, label, type, ...options }; } -function relation(key: string, resource: string, required = false): ResourceField { - return f(key, { type: 'identity', relation: resource, required }); +function relation( + key: string, + resource: string, + required = false, + options: Partial = {}, +): ResourceField { + return f(key, { ...options, type: 'identity', relation: resource, required }); } /** 创建固定管理员角色字段,页面展示中文名称,接口仍使用稳定编码。 */ @@ -349,10 +355,10 @@ export const resources: ResourceUiDefinition[] = [ { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('gas_basic_identity', { label: '气站', listLabel: '气站名称', type: 'identity', relation: '/gas_basic', displayRelationLabel: true, emptyText: '平台直属', placeholder: '请选择气站,留空表示平台直属' }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' }, define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员'), relation('delivery_basic_identity', '/delivery_basic', true)]), { ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' }, - define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), + define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: true } }), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), { ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' }, define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]), - define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account')]), + define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account', false, { staffRelation: { roles: ['installer', 'delivery', 'operations'], enabledOnly: true } })]), define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address'), f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), f('remark')]), define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]), @@ -374,7 +380,7 @@ export const resources: ResourceUiDefinition[] = [ ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), f('creator_type', { required: true, type: 'select', options: [{ label: '用户', value: 'user' }, { label: '工作人员', value: 'staff' }, { label: '配送站', value: 'delivery' }, { label: '气站', value: 'gas' }] }), f('creator_identity', { required: true }), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [ - { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, + { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: ['delivery'], enabledOnly: true, workStatus: 'on_duty' } }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, { name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } }, { name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } }, { name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason, visibleFor: { field: 'order_status', values: [20] } }, diff --git a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue index 0322756..373156e 100644 --- a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue +++ b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue @@ -72,7 +72,7 @@ const ownershipKeys = [ /** 业务动作字段不启用记录页联动,只沿用普通关联搜索。 */ function searchRelation(field: ResourceField, keyword: string) { - relations.search(field.relation, keyword); + relations.searchField(field, keyword); } watch( diff --git a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue index b00ba1a..fd4edfa 100644 --- a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue +++ b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue @@ -10,6 +10,10 @@ {{ entry.label }}
{{ entry.value }}
+
+ {{ entry.value }} + +
{{ entry.value }} @@ -71,6 +75,7 @@ type DetailEntry = { value: string; objectValue: boolean; wide: boolean; + identityValue: string; }; const entries = computed(() => { @@ -115,6 +120,8 @@ const entries = computed(() => { label: resourceFieldLabel(props.definition, actualKey), value: display, objectValue, + identityValue: + field?.staffRelation?.showIdentityCopy && value ? String(value) : '', wide: objectValue || /(address|terms|content|body|remark|reason|params|args)$/.test(key), @@ -189,6 +196,10 @@ const collections = computed(() => white-space: pre-wrap; word-break: break-word; } +.detail-relation-value { + display: grid; + gap: 4px; +} .json-value { max-height: 360px; margin: 0; diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue index 8858804..4e2f83a 100644 --- a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -67,26 +67,34 @@ {{ option.label }} - - + - {{ optionLabel(option) }} - - + + {{ relationOptionLabel(field, option) }} + + +
+ 人员标识 + +
+ import { computed } from 'vue'; -import { optionLabel } from '@/api/resource-display'; +import { relationOptionLabel } from '@/api/resource-display'; import type { ResourceField } from '@/api/resources'; import type { ResourceRow } from '@/api/resource-page-rules'; import type { PlatformRole } from '@/api/platform'; +import IdentityText from '@/components/IdentityText.vue'; const props = withDefaults( defineProps<{ @@ -175,6 +184,17 @@ function selectOptions(field: ResourceField) { :deep(.arco-picker) { width: 100%; } +.relation-control { + display: grid; + gap: 6px; +} +.relation-identity { + display: flex; + gap: 8px; + align-items: center; + color: var(--color-text-3); + font-size: 12px; +} @media (max-width: 900px) { .field-grid { grid-template-columns: 1fr; diff --git a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue index 0591b69..d2bb759 100644 --- a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue @@ -131,7 +131,7 @@ import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon'; import { computed, onMounted, reactive, ref } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { ApiError } from '@/api/http'; -import { platformApi, type PlatformRole } from '@/api/platform'; +import type { PlatformRole } from '@/api/platform'; import { resourceApi } from '@/api/resource'; import { buildResourcePayload } from '@/api/resource-form'; import { @@ -169,9 +169,10 @@ import ResourceDetailContent from './ResourceDetailContent.vue'; import ResourceFieldForm from './ResourceFieldForm.vue'; import ResourceWalletSummary from './ResourceWalletSummary.vue'; import { createResourceRecordNavigation } from './resource-record-navigation'; +import { loadResourceRecordRelations } from './load-resource-record-relations'; import { useResourceAvatar } from './use-resource-avatar'; import { useResourceRelationLinkage } from './use-resource-relation-linkage'; -import { useResourceRelations } from './use-resource-relations'; +import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard'; import { useUnsavedRecord } from './use-unsaved-record'; const route = useRoute(); @@ -199,7 +200,22 @@ const roleOptions = ref([]); const fieldOptions = reactive< Record> >({}); -const relations = useResourceRelations(); +const staffOwnerGuard = useStaffCredentialOwnerGuard({ + definitionName: () => definition.value.name, + mode: () => mode.value, + record: () => record.value, + ownerIdentity: () => + String( + mode.value === 'create' + ? (route.query.owner_identity ?? '') + : (record.value.staff_account_identity ?? ''), + ), + explicitStaffType: () => route.query.staff_type, + routeStaffType: () => route.query.staff_type ?? route.meta.staffType, + returnPath: () => returnPath.value, +}); +const resolvedStaffType = staffOwnerGuard.resolvedStaffType; +const relations = staffOwnerGuard.relations; const relationLinkage = useResourceRelationLinkage(form, relations); const actionVisible = ref(false); const activeAction = ref(); @@ -212,8 +228,7 @@ const clearAvatar = avatar.clear; const accountSummary = computed(() => usesAccountSummary(definition.value)); const accountSummaryVisible = computed( () => - accountSummary.value && - (mode.value !== 'create' || hasAvatarField.value), + accountSummary.value && (mode.value !== 'create' || hasAvatarField.value), ); const hasAvatarField = computed(() => definition.value.fields.some((field) => field.key === 'avatar'), @@ -246,12 +261,16 @@ const editableKeySet = computed( const readonlyKeys = computed(() => mode.value === 'create' ? formFields.value - .filter((field) => field.readonlyOnCreate) + .filter( + (field) => + field.readonlyOnCreate || + (field.staffRelation?.lockPrefilled && Boolean(form[field.key])), + ) .map((field) => field.key) : mode.value === 'edit' ? formFields.value - .filter((field) => !editableKeySet.value.has(field.key)) - .map((field) => field.key) + .filter((field) => !editableKeySet.value.has(field.key)) + .map((field) => field.key) : [], ); const requiredKeys = computed(() => @@ -320,6 +339,7 @@ async function initialize() { } loading.value = true; errorMessage.value = ''; + staffOwnerGuard.reset(); try { if (mode.value !== 'create') detail.value = await resourceApi.detail( @@ -336,10 +356,24 @@ async function initialize() { mode.value === 'create' ? String(route.query.relation_key ?? '') : '', ownerIdentity: mode.value === 'create' ? String(route.query.owner_identity ?? '') : '', - staffType: - mode.value === 'create' ? String(route.meta.staffType ?? '') : '', + staffType: mode.value === 'create' ? resolvedStaffType.value : '', + }); + const ownerGuardResult = await staffOwnerGuard.prepare(); + if (!ownerGuardResult.ok) { + errorStatus.value = ownerGuardResult.status; + errorMessage.value = ownerGuardResult.message; + return; + } + await loadResourceRecordRelations({ + detailMode: mode.value === 'detail', + definition: definition.value, + formFields: formFields.value, + record: record.value, + relations, + relationLinkage, + fieldOptions, + roleOptions, }); - await loadRelationsAndRoles(); // 钱包和头像属于附加信息,读取失败时不能阻断主详情或基础表单。 await Promise.allSettled([loadWallet(), loadAvatar()]); unsaved.markInitialized(); @@ -359,31 +393,6 @@ async function initialize() { } } -async function loadRelationsAndRoles() { - if (mode.value === 'detail') { - await relations.preload(definition.value.fields); - } else { - await relationLinkage.preload(formFields.value); - } - if ( - definition.value.fields.some((field) => field.key === 'platform_role_code') - ) { - try { - const roles = (await platformApi.listRole()).list; - fieldOptions.platform_role_code = roles.map((role) => ({ - label: role.name, - value: role.role_code, - })); - roleOptions.value = roles.filter( - (role) => !role.is_system && role.status === 1, - ); - } catch (error) { - fieldOptions.platform_role_code = []; - Message.warning(`平台角色加载失败:${(error as Error).message}`); - } - } -} - async function loadWallet() { if ( !definition.value.walletOwnerType || diff --git a/frontend/platform_admin/src/views/resource/load-resource-record-relations.ts b/frontend/platform_admin/src/views/resource/load-resource-record-relations.ts new file mode 100644 index 0000000..140b1e7 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/load-resource-record-relations.ts @@ -0,0 +1,57 @@ +/** + * 功能:统一加载资源记录页的关系选项和平台角色选项。 + * 版本:v1.0.0 + */ +import { Message } from '@arco-design/web-vue'; +import type { Ref } from 'vue'; +import { platformApi, type PlatformRole } from '@/api/platform'; +import type { ResourceField, ResourceUiDefinition } from '@/api/resources'; +import type { ResourceRow } from '@/api/resource-page-rules'; +import type { ResourceRelations } from './use-resource-relations'; + +type LoaderOptions = { + detailMode: boolean; + definition: ResourceUiDefinition; + formFields: ResourceField[]; + record: ResourceRow; + relations: ResourceRelations; + relationLinkage: { preload: (fields: ResourceField[]) => Promise }; + fieldOptions: Record< + string, + Array<{ label: string; value: string | number }> + >; + roleOptions: Ref; +}; + +/** 加载当前页面实际使用的关系和动态平台角色。 */ +export async function loadResourceRecordRelations(options: LoaderOptions) { + if (options.detailMode) { + await options.relations.preload(options.definition.fields); + await options.relations.ensureValues( + options.definition.fields, + options.record, + ); + } else { + await options.relationLinkage.preload(options.formFields); + } + if ( + !options.definition.fields.some( + (field) => field.key === 'platform_role_code', + ) + ) { + return; + } + try { + const roles = (await platformApi.listRole()).list; + options.fieldOptions.platform_role_code = roles.map((role) => ({ + label: role.name, + value: role.role_code, + })); + options.roleOptions.value = roles.filter( + (role) => !role.is_system && role.status === 1, + ); + } catch (error) { + options.fieldOptions.platform_role_code = []; + Message.warning(`平台角色加载失败:${(error as Error).message}`); + } +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts index dfc0c67..e26b047 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts @@ -57,10 +57,11 @@ export function useResourceRelationLinkage( } async function reloadChildOptions() { - const resource = active?.childField.relation; + const childField = active?.childField; + const resource = childField?.relation; if (!resource) return; relations.cancelSearch(resource); - await relations.load(resource, '', childRequest()); + await relations.loadField(childField, '', childRequest()); } /** 预加载普通关联项,并单独按父级条件加载启用联动的子选项。 */ @@ -69,6 +70,7 @@ export function useResourceRelationLinkage( configure(fields); if (!active?.childField.relationLinkage || !active.childField.relation) { await relations.preload(fields); + await relations.ensureValues(fields, form); initialized = true; return; } @@ -108,11 +110,7 @@ export function useResourceRelationLinkage( return; } if ( - shouldClearLinkedOption( - parentIdentity, - option, - linkage.optionParentKey, - ) + shouldClearLinkedOption(parentIdentity, option, linkage.optionParentKey) ) { form[active.childField.key] = ''; Message.info('所属气站已变更,请重新选择配送点'); @@ -169,10 +167,10 @@ export function useResourceRelationLinkage( function search(field: ResourceField, keyword: string) { if (!field.relation) return; if (field.key === active?.childField.key) { - relations.search(field.relation, keyword, childRequest()); + relations.searchField(field, keyword, childRequest()); return; } - relations.search(field.relation, keyword); + relations.searchField(field, keyword); } /** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */ diff --git a/frontend/platform_admin/src/views/resource/use-resource-relations.ts b/frontend/platform_admin/src/views/resource/use-resource-relations.ts index a90cbfb..2dc59f3 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relations.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relations.ts @@ -5,6 +5,10 @@ import { Message } from '@arco-design/web-vue'; import { onBeforeUnmount, reactive } from 'vue'; import { resourceApi } from '@/api/resource'; +import { + staffRelationFilters, + type StaffRelationContext, +} from '@/api/resource-staff-relation'; import type { ResourceField } from '@/api/resources'; import type { ResourceRow } from '@/api/resource-page-rules'; import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy'; @@ -14,7 +18,9 @@ export type ResourceRelationLoadOptions = { preserveIdentities?: string[]; }; -export function useResourceRelations() { +export function useResourceRelations( + context: () => StaffRelationContext = () => ({}), +) { const options = reactive>({}); const loading = reactive>({}); const timers = new Map>(); @@ -54,7 +60,6 @@ export function useResourceRelations() { ...(request.filters ?? {}), ...(keyword ? { keyword } : {}), }; - if (resource === '/staff_account') filters.role_code = 'delivery'; const rows = ( await resourceApi.list(resource, 1, 100, filters) ).list; @@ -74,6 +79,22 @@ export function useResourceRelations() { } } + /** 按字段的显式关系策略加载选项,工作人员字段不得依赖全局默认值。 */ + function loadField( + field: ResourceField, + keyword = '', + request: ResourceRelationLoadOptions = {}, + ) { + if (!field.relation) return Promise.resolve(); + return load(field.relation, keyword, { + ...request, + filters: { + ...staffRelationFilters(field, context()), + ...(request.filters ?? {}), + }, + }); + } + /** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */ async function ensure(resource: string, identity: string) { if (!identity) return undefined; @@ -97,12 +118,29 @@ export function useResourceRelations() { /** 预加载当前页面实际使用的全部关系字段。 */ async function preload(fields: ResourceField[]) { - const resources = new Set( - fields - .map((field) => field.relation) - .filter((value): value is string => Boolean(value)), + const fieldsByResource = new Map(); + for (const field of fields) { + if (field.relation && !fieldsByResource.has(field.relation)) { + fieldsByResource.set(field.relation, field); + } + } + await Promise.all( + [...fieldsByResource.values()].map((field) => loadField(field)), ); - await Promise.all([...resources].map((resource) => load(resource))); + } + + /** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */ + async function ensureValues(fields: ResourceField[], values: ResourceRow) { + const requests = fields.flatMap((field) => { + if (!field.relation) return []; + const value = values[field.key]; + const identities = Array.isArray(value) ? value : [value]; + return identities + .map((identity) => String(identity ?? '')) + .filter(Boolean) + .map((identity) => ensure(field.relation as string, identity)); + }); + await Promise.all(requests); } /** 对远程关系选项进行防抖搜索。 */ @@ -122,6 +160,22 @@ export function useResourceRelations() { ); } + /** 按字段的显式关系策略执行防抖搜索。 */ + function searchField( + field: ResourceField, + keyword: string, + request: ResourceRelationLoadOptions = {}, + ) { + if (!field.relation) return; + search(field.relation, keyword, { + ...request, + filters: { + ...staffRelationFilters(field, context()), + ...(request.filters ?? {}), + }, + }); + } + /** 取消关联资源尚未触发的防抖搜索。 */ function cancelSearch(resource: string) { const timer = timers.get(resource); @@ -137,9 +191,12 @@ export function useResourceRelations() { options, loading, load, + loadField, ensure, + ensureValues, preload, search, + searchField, cancelSearch, }; } diff --git a/frontend/platform_admin/src/views/resource/use-staff-credential-owner-guard.ts b/frontend/platform_admin/src/views/resource/use-staff-credential-owner-guard.ts new file mode 100644 index 0000000..d66e1e8 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/use-staff-credential-owner-guard.ts @@ -0,0 +1,96 @@ +/** + * 功能:校验工作人员资质页面的所有者、来源角色和访问状态。 + * 版本:v1.0.0 + */ +import { ref } from 'vue'; +import type { LocationQueryValue } from 'vue-router'; +import { ApiError } from '@/api/http'; +import { resourceApi } from '@/api/resource'; +import { + credentialOwnerValidationMessage, + normalizeStaffRole, + resolveCredentialStaffRole, + type StaffRoleCode, +} from '@/api/resource-staff-relation'; +import type { RecordPageMode, ResourceRow } from '@/api/resource-page-rules'; +import { useResourceRelations } from './use-resource-relations'; + +type GuardOptions = { + definitionName: () => string; + mode: () => RecordPageMode; + record: () => ResourceRow; + ownerIdentity: () => string; + explicitStaffType: () => LocationQueryValue | LocationQueryValue[]; + routeStaffType: () => unknown; + returnPath: () => string; +}; + +type GuardResult = + | { ok: true } + | { ok: false; status: '403' | '404'; message: string }; + +/** 创建资质所有者守卫,并向关系加载器提供已验证的角色上下文。 */ +export function useStaffCredentialOwnerGuard(options: GuardOptions) { + const resolvedStaffType = ref( + normalizeStaffRole(options.routeStaffType()), + ); + const relations = useResourceRelations(() => ({ + staffType: resolvedStaffType.value, + })); + + /** 每次页面重新初始化时重置路由角色,避免复用上一次记录的上下文。 */ + function reset() { + resolvedStaffType.value = normalizeStaffRole(options.routeStaffType()); + } + + async function prepare(): Promise { + if (options.definitionName() !== 'staff_credential') return { ok: true }; + const ownerIdentity = options.ownerIdentity(); + if (options.mode() === 'create') { + const resolved = resolveCredentialStaffRole( + options.explicitStaffType(), + options.returnPath(), + ); + if (resolved.error) { + return { ok: false, status: '403', message: resolved.error }; + } + resolvedStaffType.value = resolved.role; + } + if (!ownerIdentity) { + return { + ok: false, + status: '403', + message: '缺少工作人员唯一标识,请从工作人员页面进入', + }; + } + let owner: ResourceRow; + try { + owner = await resourceApi.detail( + '/staff_account', + ownerIdentity, + ); + } catch (error) { + return { + ok: false, + status: + error instanceof ApiError && error.status === 404 ? '404' : '403', + message: `无法确认资质所属工作人员:${(error as Error).message}`, + }; + } + if (options.mode() !== 'create') { + resolvedStaffType.value = normalizeStaffRole(owner.role_code); + } + const validationMessage = credentialOwnerValidationMessage( + ownerIdentity, + resolvedStaffType.value as StaffRoleCode, + owner, + ); + if (validationMessage) { + return { ok: false, status: '403', message: validationMessage }; + } + relations.options['/staff_account'] = [owner]; + return { ok: true }; + } + + return { resolvedStaffType, relations, reset, prepare }; +} diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index e13a416..e61a8c6 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -143,7 +143,15 @@