修复工作人员资质关联与角色过滤
按安装、配送、运维菜单显式过滤工作人员,锁定资质所有者并使用姓名和角色回显。 扩展工作人员状态与多角色查询,补充权限校验、回归测试、操作日志和项目文档。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71
docs/操作日志_工作人员资质关联修复_20260811.md
Normal file
71
docs/操作日志_工作人员资质关联修复_20260811.md
Normal file
@@ -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 行,未继续重构既有保存和状态流程以控制改动范围。
|
||||
121
docs/项目文档_工作人员资质关联_v1.0.md
Normal file
121
docs/项目文档_工作人员资质关联_v1.0.md
Normal file
@@ -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 条,更多记录通过关键字搜索获取。
|
||||
- 多角色查询不返回“调用者有权访问的部分集合”;任何目标角色权限不足都会拒绝整次请求。
|
||||
- 本次只覆盖平台总后台;气站和配送点后台继续使用各自组织范围内的工作人员接口。
|
||||
- 不新增人员姓名历史快照;人员已归档后,当前平台权限模型会阻止继续新建资质。
|
||||
@@ -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 .",
|
||||
|
||||
122
frontend/platform_admin/scripts/check-staff-relation-policy.mjs
Normal file
122
frontend/platform_admin/scripts/check-staff-relation-policy.mjs
Normal file
@@ -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('工作人员关联与资质上下文检查通过');
|
||||
@@ -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<string, ResourceRow[]>,
|
||||
fieldOptions: Record<string, Array<{ label: string; value: string | number }>> = {},
|
||||
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)})`;
|
||||
|
||||
136
frontend/platform_admin/src/api/resource-staff-relation.ts
Normal file
136
frontend/platform_admin/src/api/resource-staff-relation.ts
Normal file
@@ -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<StaffRoleCode, string> = {
|
||||
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<string, string> =
|
||||
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 '';
|
||||
}
|
||||
@@ -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> = {}): 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> = {},
|
||||
): 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] } },
|
||||
|
||||
@@ -72,7 +72,7 @@ const ownershipKeys = [
|
||||
|
||||
/** 业务动作字段不启用记录页联动,只沿用普通关联搜索。 */
|
||||
function searchRelation(field: ResourceField, keyword: string) {
|
||||
relations.search(field.relation, keyword);
|
||||
relations.searchField(field, keyword);
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<span class="detail-label">{{ entry.label }}</span>
|
||||
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
|
||||
<IdentityText v-else-if="entry.key === 'identity'" :value="String(entry.value)" />
|
||||
<div v-else-if="entry.identityValue" class="detail-relation-value">
|
||||
<span class="detail-value">{{ entry.value }}</span>
|
||||
<IdentityText :value="entry.identityValue" />
|
||||
</div>
|
||||
<span v-else class="detail-value">{{ entry.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,6 +75,7 @@ type DetailEntry = {
|
||||
value: string;
|
||||
objectValue: boolean;
|
||||
wide: boolean;
|
||||
identityValue: string;
|
||||
};
|
||||
|
||||
const entries = computed<DetailEntry[]>(() => {
|
||||
@@ -115,6 +120,8 @@ const entries = computed<DetailEntry[]>(() => {
|
||||
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;
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<div v-else-if="field.type === 'identity' || field.type === 'identity-list'" class="relation-control">
|
||||
<a-select
|
||||
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
|
||||
v-model="model[field.key]"
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:multiple="field.type === 'identity-list'"
|
||||
@@ -84,9 +84,17 @@
|
||||
:key="String(option.identity)"
|
||||
:value="String(option.identity)"
|
||||
>
|
||||
{{ optionLabel(option) }}
|
||||
{{ relationOptionLabel(field, option) }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<div
|
||||
v-if="disabledSet.has(field.key) && field.staffRelation?.showIdentityCopy && model[field.key]"
|
||||
class="relation-identity"
|
||||
>
|
||||
<span>人员标识</span>
|
||||
<IdentityText :value="String(model[field.key])" />
|
||||
</div>
|
||||
</div>
|
||||
<a-input
|
||||
v-else
|
||||
v-model="model[field.key]"
|
||||
@@ -99,10 +107,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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;
|
||||
|
||||
@@ -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<PlatformRole[]>([]);
|
||||
const fieldOptions = reactive<
|
||||
Record<string, Array<{ label: string; value: string | number }>>
|
||||
>({});
|
||||
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<DetailAction>();
|
||||
@@ -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,7 +261,11 @@ 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
|
||||
@@ -320,6 +339,7 @@ async function initialize() {
|
||||
}
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
staffOwnerGuard.reset();
|
||||
try {
|
||||
if (mode.value !== 'create')
|
||||
detail.value = await resourceApi.detail<ResourceRow>(
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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<void> };
|
||||
fieldOptions: Record<
|
||||
string,
|
||||
Array<{ label: string; value: string | number }>
|
||||
>;
|
||||
roleOptions: Ref<PlatformRole[]>;
|
||||
};
|
||||
|
||||
/** 加载当前页面实际使用的关系和动态平台角色。 */
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
|
||||
|
||||
@@ -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<Record<string, ResourceRow[]>>({});
|
||||
const loading = reactive<Record<string, boolean>>({});
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -54,7 +60,6 @@ export function useResourceRelations() {
|
||||
...(request.filters ?? {}),
|
||||
...(keyword ? { keyword } : {}),
|
||||
};
|
||||
if (resource === '/staff_account') filters.role_code = 'delivery';
|
||||
const rows = (
|
||||
await resourceApi.list<ResourceRow>(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<string, ResourceField>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<StaffRoleCode | ''>(
|
||||
normalizeStaffRole(options.routeStaffType()),
|
||||
);
|
||||
const relations = useResourceRelations(() => ({
|
||||
staffType: resolvedStaffType.value,
|
||||
}));
|
||||
|
||||
/** 每次页面重新初始化时重置路由角色,避免复用上一次记录的上下文。 */
|
||||
function reset() {
|
||||
resolvedStaffType.value = normalizeStaffRole(options.routeStaffType());
|
||||
}
|
||||
|
||||
async function prepare(): Promise<GuardResult> {
|
||||
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<ResourceRow>(
|
||||
'/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 };
|
||||
}
|
||||
@@ -143,7 +143,15 @@
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconLeft, IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRef, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
toRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
import {
|
||||
@@ -180,7 +188,9 @@ const list = ref<ResourceRow[]>([]);
|
||||
const filters = reactive({
|
||||
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
|
||||
});
|
||||
const relations = useResourceRelations();
|
||||
const relations = useResourceRelations(() => ({
|
||||
staffType: String(route.query.staff_type ?? route.meta.staffType ?? ''),
|
||||
}));
|
||||
const fieldOptions = reactive<
|
||||
Record<string, Array<{ label: string; value: string | number }>>
|
||||
>({});
|
||||
@@ -285,6 +295,9 @@ function recordQuery() {
|
||||
...(managedRelationKey.value
|
||||
? { relation_key: managedRelationKey.value }
|
||||
: {}),
|
||||
...(typeof route.query.staff_type === 'string' && route.query.staff_type
|
||||
? { staff_type: route.query.staff_type }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -336,6 +349,7 @@ function viewCredentials(row: ResourceRow) {
|
||||
owner_identity: String(row.identity ?? ''),
|
||||
owner_name: String(row.name ?? row.username ?? ''),
|
||||
relation_key: 'staff_account_identity',
|
||||
staff_type: staffType.value,
|
||||
return_to: route.fullPath,
|
||||
},
|
||||
});
|
||||
@@ -404,6 +418,7 @@ async function syncQuery() {
|
||||
'owner_identity',
|
||||
'owner_name',
|
||||
'relation_key',
|
||||
'staff_type',
|
||||
'return_to',
|
||||
]) {
|
||||
const value = route.query[key];
|
||||
@@ -456,9 +471,7 @@ function displayListField(field: ResourceField, row: ResourceRow) {
|
||||
/** 平台角色由后端维护,列表加载全部角色用于编码转中文名称。 */
|
||||
async function loadFieldOptions() {
|
||||
if (
|
||||
!props.definition.fields.some(
|
||||
(field) => field.key === 'platform_role_code',
|
||||
)
|
||||
!props.definition.fields.some((field) => field.key === 'platform_role_code')
|
||||
) {
|
||||
delete fieldOptions.platform_role_code;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user