From 6bcde943888465f5940e7be1755569fbe195a21c Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Wed, 12 Aug 2026 00:37:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=9C=8D=E5=8A=A1=E5=85=B3=E7=B3=BB=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E7=BB=84=E7=BB=87=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一平台、气站和配送点后台的服务关系业务名称展示。 新增气站、配送点、服务人员三级联动及暂未分配语义。 后端补充组织归属、人员角色和启用状态的严格校验。 补充前后端测试、项目文档、操作日志及本机日志忽略规则。 --- .gitignore | 3 + .../internal/logic/platform/staff/staff.go | 63 ++++++++++++++ .../logic/platform/staff/staff_test.go | 22 +++++ .../internal/logic/platform/user/relation.go | 32 ++++++- .../logic/platform/user/relation_test.go | 27 ++++++ docs/操作日志_服务关系名称展示_20260811.md | 70 +++++++++++++++ docs/项目文档_服务关系名称展示_v1.0.md | 60 +++++++++++++ frontend/delivery_admin/src/api/resources.ts | 22 ++++- .../src/views/shared/CrudListPage.vue | 15 +++- .../src/views/shared/RelationNameText.vue | 39 +++++++++ frontend/gas_admin/src/api/resources.ts | 22 ++++- .../src/views/shared/CrudListPage.vue | 15 +++- .../src/views/shared/RelationNameText.vue | 39 +++++++++ .../check-staff-organization-linkage.mjs | 37 +++++++- .../scripts/check-staff-relation-policy.mjs | 1 + .../src/api/resource-staff-relation.ts | 1 + frontend/platform_admin/src/api/resources.ts | 51 ++++++++++- .../views/resource/ResourceDetailContent.vue | 4 +- .../src/views/resource/ResourceFieldForm.vue | 36 +++++++- .../src/views/resource/ResourceRecordPage.vue | 6 +- .../resource-relation-linkage-policy.ts | 36 ++++++++ .../resource/use-resource-relation-linkage.ts | 87 ++++++++++++++++++- .../src/views/shared/CrudListPage.vue | 5 ++ .../src/views/shared/RelationNameText.vue | 16 ++-- 24 files changed, 682 insertions(+), 27 deletions(-) create mode 100644 docs/操作日志_服务关系名称展示_20260811.md create mode 100644 docs/项目文档_服务关系名称展示_v1.0.md create mode 100644 frontend/delivery_admin/src/views/shared/RelationNameText.vue create mode 100644 frontend/gas_admin/src/views/shared/RelationNameText.vue diff --git a/.gitignore b/.gitignore index f499d9a..3abfcf1 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ scripts/fixtures/platform-demo-seed-result.json # 后端本机运行日志与上传文件不得进入版本库。 backend/api/logs/ backend/api/runtime/ + +# 前端本机调试日志不得进入版本库。 +frontend/*/logs/ diff --git a/backend/api/internal/logic/platform/staff/staff.go b/backend/api/internal/logic/platform/staff/staff.go index 1039fbf..afd19af 100644 --- a/backend/api/internal/logic/platform/staff/staff.go +++ b/backend/api/internal/logic/platform/staff/staff.go @@ -19,6 +19,13 @@ type staffListFilters struct { WorkStatus string } +type staffOrganizationFilters struct { + GasIdentity string + DeliveryIdentity string + GasUnassigned bool + DeliveryUnassigned bool +} + // parseStaffListFilters 将工作人员列表查询参数收敛为闭集条件,拒绝含糊或冲突值。 func parseStaffListFilters(roleCode, roleCodes, status, workStatus string) (staffListFilters, bool) { filters := staffListFilters{} @@ -61,6 +68,34 @@ func parseStaffListFilters(roleCode, roleCodes, status, workStatus string) (staf return filters, true } +// parseStaffOrganizationFilters 解析服务关系下拉使用的精确组织范围,并拒绝相互冲突的条件。 +func parseStaffOrganizationFilters(gasIdentity, deliveryIdentity, gasUnassigned, deliveryUnassigned string) (staffOrganizationFilters, bool) { + filters := staffOrganizationFilters{ + GasIdentity: strings.TrimSpace(gasIdentity), + DeliveryIdentity: strings.TrimSpace(deliveryIdentity), + } + if strings.Contains(filters.GasIdentity, ",") || strings.Contains(filters.DeliveryIdentity, ",") { + return staffOrganizationFilters{}, false + } + parseUnassigned := func(value string) (bool, bool) { + value = strings.TrimSpace(value) + if value == "" { + return false, true + } + return value == "1", value == "1" + } + var ok bool + filters.GasUnassigned, ok = parseUnassigned(gasUnassigned) + if !ok { + return staffOrganizationFilters{}, false + } + filters.DeliveryUnassigned, ok = parseUnassigned(deliveryUnassigned) + if !ok || filters.GasIdentity != "" && filters.GasUnassigned || filters.DeliveryIdentity != "" && filters.DeliveryUnassigned { + return staffOrganizationFilters{}, false + } + return filters, true +} + // ListStaff 查询服务人员分页列表,并应用调用方显式声明的角色与在岗状态条件。 func ListStaff(ctx *gin.Context) { filters, ok := parseStaffListFilters( @@ -73,6 +108,16 @@ func ListStaff(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + organizationFilters, ok := parseStaffOrganizationFilters( + ctx.Query("gas_basic_identities"), + ctx.Query("delivery_basic_identities"), + ctx.Query("gas_basic_unassigned"), + ctx.Query("delivery_basic_unassigned"), + ) + if !ok { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } page, size := common.PageSize(ctx) var list []models.StaffAccount var total int64 @@ -86,6 +131,24 @@ func ListStaff(ctx *gin.Context) { if filters.WorkStatus != "" { query = query.Where("work_status = ?", filters.WorkStatus) } + if organizationFilters.GasIdentity != "" { + query = query.Where( + "gas_basic_id IN (SELECT id FROM gas_basic WHERE identity = ? AND status <> ?)", + organizationFilters.GasIdentity, + common.StatusArchived, + ) + } else if organizationFilters.GasUnassigned { + query = query.Where("gas_basic_id = 0") + } + if organizationFilters.DeliveryIdentity != "" { + query = query.Where( + "delivery_basic_id IN (SELECT id FROM delivery_basic WHERE identity = ? AND status <> ?)", + organizationFilters.DeliveryIdentity, + common.StatusArchived, + ) + } else if organizationFilters.DeliveryUnassigned { + query = query.Where("delivery_basic_id = 0") + } query = common.ApplyKeywordFilter(ctx, query, &models.StaffAccount{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) diff --git a/backend/api/internal/logic/platform/staff/staff_test.go b/backend/api/internal/logic/platform/staff/staff_test.go index 2436f5a..9e382a9 100644 --- a/backend/api/internal/logic/platform/staff/staff_test.go +++ b/backend/api/internal/logic/platform/staff/staff_test.go @@ -53,3 +53,25 @@ func TestStaffListFiltersRejectAmbiguousOrUnknownValues(t *testing.T) { } } } + +// TestStaffOrganizationFilters 验证服务人员可以按已分配组织或未分配状态精确过滤。 +func TestStaffOrganizationFilters(t *testing.T) { + filters, ok := parseStaffOrganizationFilters("gas-a", "delivery-a", "", "") + if !ok || filters.GasIdentity != "gas-a" || filters.DeliveryIdentity != "delivery-a" { + t.Fatalf("组织标识过滤解析失败:%#v", filters) + } + filters, ok = parseStaffOrganizationFilters("", "", "1", "1") + if !ok || !filters.GasUnassigned || !filters.DeliveryUnassigned { + t.Fatalf("未分配过滤解析失败:%#v", filters) + } + for _, test := range [][4]string{ + {"gas-a", "", "1", ""}, + {"", "delivery-a", "", "1"}, + {"gas-a,gas-b", "", "", ""}, + {"", "", "true", ""}, + } { + if _, valid := parseStaffOrganizationFilters(test[0], test[1], test[2], test[3]); valid { + t.Fatalf("冲突或非法组织过滤被接受:%#v", test) + } + } +} diff --git a/backend/api/internal/logic/platform/user/relation.go b/backend/api/internal/logic/platform/user/relation.go index 0e771d4..9068149 100644 --- a/backend/api/internal/logic/platform/user/relation.go +++ b/backend/api/internal/logic/platform/user/relation.go @@ -226,10 +226,38 @@ func resolveServiceRelation(ctx *gin.Context, request serviceRelationRequest) (u infra.Response.Error(ctx, errcode.ErrInvalidArgument) return 0, 0, 0, 0, false } - if gasBasicID == 0 && deliveryBasicID == 0 && staffAccountID == 0 || - !common.ValidateOrganizationIDs(gasBasicID, deliveryBasicID, staffAccountID) { + if !validateServiceRelationOrganization(gasBasicID, deliveryBasicID, staffAccountID) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return 0, 0, 0, 0, false } return userAccountID, gasBasicID, deliveryBasicID, staffAccountID, true } + +// validateServiceRelationOrganization 读取组织记录并执行服务关系专用的严格归属校验。 +func validateServiceRelationOrganization(gasBasicID, deliveryBasicID, staffAccountID uint64) bool { + var delivery models.DeliveryBasic + if deliveryBasicID != 0 { + if err := common.ActiveRecords(impl.DBService).First(&delivery, deliveryBasicID).Error; err != nil { + return false + } + } + var staff models.StaffAccount + if staffAccountID != 0 { + if err := common.ActiveRecords(impl.DBService).First(&staff, staffAccountID).Error; err != nil { + return false + } + } + return serviceRelationOrganizationMatches(gasBasicID, deliveryBasicID, staffAccountID, delivery, staff) +} + +// serviceRelationOrganizationMatches 要求配送点、服务人员与当前选择完全一致;全空表示暂未分配。 +func serviceRelationOrganizationMatches(gasBasicID, deliveryBasicID, staffAccountID uint64, delivery models.DeliveryBasic, staff models.StaffAccount) bool { + if deliveryBasicID != 0 && delivery.GasBasicID != gasBasicID { + return false + } + if staffAccountID == 0 { + return true + } + validRole := staff.RoleCode == "installer" || staff.RoleCode == "delivery" || staff.RoleCode == "operations" + return validRole && staff.Status == common.StatusEnable && staff.GasBasicID == gasBasicID && staff.DeliveryBasicID == deliveryBasicID +} diff --git a/backend/api/internal/logic/platform/user/relation_test.go b/backend/api/internal/logic/platform/user/relation_test.go index fd0da40..d32bbb2 100644 --- a/backend/api/internal/logic/platform/user/relation_test.go +++ b/backend/api/internal/logic/platform/user/relation_test.go @@ -65,3 +65,30 @@ func TestUserAddressUpdateValuesUsesSupportedMap(t *testing.T) { t.Fatalf("update field count = %d, want 5", len(values)) } } + +// TestServiceRelationOrganizationMatches 验证暂未分配与严格组织一致性规则。 +func TestServiceRelationOrganizationMatches(t *testing.T) { + if !serviceRelationOrganizationMatches(0, 0, 0, models.DeliveryBasic{}, models.StaffAccount{}) { + t.Fatal("全空服务关系应允许保存为暂未分配") + } + delivery := models.DeliveryBasic{GasBasicID: 10} + staff := models.StaffAccount{ + Entity: models.Entity{Status: 1}, + RoleCode: "delivery", + GasBasicID: 10, + DeliveryBasicID: 20, + } + if !serviceRelationOrganizationMatches(10, 20, 30, delivery, staff) { + t.Fatal("完全一致的服务关系被拒绝") + } + if serviceRelationOrganizationMatches(11, 20, 30, delivery, staff) { + t.Fatal("配送点或服务人员气站不一致时不应通过") + } + if serviceRelationOrganizationMatches(10, 0, 30, models.DeliveryBasic{}, staff) { + t.Fatal("服务人员配送点与空选择不一致时不应通过") + } + staff.Status = 2 + if serviceRelationOrganizationMatches(10, 20, 30, delivery, staff) { + t.Fatal("停用服务人员不应写入服务关系") + } +} diff --git a/docs/操作日志_服务关系名称展示_20260811.md b/docs/操作日志_服务关系名称展示_20260811.md new file mode 100644 index 0000000..765a832 --- /dev/null +++ b/docs/操作日志_服务关系名称展示_20260811.md @@ -0,0 +1,70 @@ +# 服务关系名称展示操作日志 + +操作时间:2026-08-11 + +操作类型:扩展 + +影响模块:平台总后台、气站后台、配送点后台的服务关系列表 + +## 操作前状态 + +服务关系列表的用户、气站、配送点和工作人员字段仅显示唯一标识,普通用户难以直接识别关系归属;三个后台未统一采用既有的用户地址名称展示标准。 + +## 具体操作 + +1. 保持菜单、页面标题和面包屑中的“服务关系”名称不变。 +2. 四列表头统一改为“用户账户、所属气站、所属配送点、服务人员”。 +3. 列表主内容改为关联业务名称,悬停显示完整唯一标识,并提供复制按钮。 +4. 名称缺失或关系加载失败时降级显示完整唯一标识。 +5. 将相同规则同步到平台总后台、气站后台和配送点后台。 + +## 操作后状态 + +管理人员可以直接按业务名称识别服务归属,同时仍可查看和复制底层唯一标识。详情页和编辑流程保持原行为。 + +## 验证结果 + +- 三个后台的 `pnpm.cmd type:check` 均通过。 +- 三个后台的 `pnpm.cmd build` 均通过。 +- 未修改后端接口和数据库结构。 + +## 风险评估 + +影响范围限于列表渲染和字段文案。唯一标识保留为降级内容,可缓解关联数据暂未加载或名称缺失造成的识别问题。 + +--- + +操作时间:2026-08-12 + +操作类型:扩展 + +影响模块:平台总后台服务关系记录页、服务人员筛选接口、服务关系写入校验 + +## 操作前状态 + +详情和编辑页仍显示“唯一标识”文案;气站、配送点、服务人员可以独立选择;后端不允许三项全部为空,且组织一致性规则比页面确认的规则宽松。 + +## 具体操作 + +1. 新建、详情、编辑页面统一使用“用户账户、所属气站、所属配送点、服务人员”。 +2. 用户账户编辑时改为只读业务名称,并保留唯一标识复制能力。 +3. 空组织统一显示“暂未分配”,允许三项全部为空。 +4. 配送点按气站筛选并自动回填气站;服务人员按气站和配送点精确筛选。 +5. 组织变化时清空不匹配的下级选项并提示。 +6. 后端保存前严格校验配送点、服务人员归属、角色和启用状态。 +7. 保持接口地址、请求字段、数据库结构及后台权限边界不变。 + +## 操作后状态 + +平台总后台的列表、详情、新建和编辑流程已统一采用业务名称。历史异常关系允许查看,但必须修正后才能保存;“暂未分配”关系可以正常创建和更新。 + +## 验证结果 + +- 平台、气站、配送点后台 TypeScript 类型检查通过。 +- 平台资源页、工作人员策略和组织联动静态契约检查通过。 +- 后端工作人员过滤与用户服务关系定向测试通过。 +- 三套 Web 后台生产构建通过。 + +## 风险评估 + +严格校验可能阻止历史异常记录原样保存,这是确认后的预期行为。工作人员列表新增过滤参数均为可选参数,旧调用保持原行为。 diff --git a/docs/项目文档_服务关系名称展示_v1.0.md b/docs/项目文档_服务关系名称展示_v1.0.md new file mode 100644 index 0000000..87451e8 --- /dev/null +++ b/docs/项目文档_服务关系名称展示_v1.0.md @@ -0,0 +1,60 @@ +# 服务关系管理项目文档 v1.1 + +## 1. 项目概述 + +- 项目名称:服务关系名称展示统一。 +- 主要功能:服务关系在列表、详情、新建和编辑页面统一展示业务名称,并对气站、配送点、服务人员执行前后端组织一致性校验。 +- 实施范围:平台总后台、气站后台、配送点后台。 +- 技术栈:Vue 3、TypeScript、Arco Design。 + +## 2. 核心实现 + +三个后台的 `resources.ts` 已统一服务关系字段名称和空值语义。平台总后台是当前唯一暴露服务关系管理菜单和写接口的后台,因此新建、详情、编辑和联动交互在平台总后台实现;气站、配送点后台仅同步共享资源定义,不扩大原有权限范围。 + +平台总后台共享列表会补载当前页实际引用的关系记录,以业务名称渲染并保留唯一标识的悬停查看与复制能力。详情页展示业务名称和可复制标识;编辑页的用户账户以只读名称显示。 + +列表名称“服务关系”保持不变,四列表头统一为: + +- 用户账户 +- 所属气站 +- 所属配送点 +- 服务人员 + +新建和编辑页面的组织规则如下: + +- 用户账户必选,编辑时不可变更。 +- 气站、配送点、服务人员允许同时为空,表示“暂未分配”。 +- 选择配送点后自动回填所属气站;切换气站会清理不匹配的配送点。 +- 服务人员按当前气站、配送点、启用状态和业务角色精确过滤。 +- 历史异常关系允许查看,但再次保存前必须修正。 + +接口地址、请求字段和数据库结构保持不变。工作人员列表接口新增可选组织过滤参数,旧调用不传参数时行为不变。 + +## 3. 目录结构 + +```text +frontend/ +├── platform_admin/ # 平台总后台 +│ └── src/views/shared/ # 通用列表与名称展示组件 +├── gas_admin/ # 气站后台 +│ └── src/views/shared/ # 通用列表与名称展示组件 +└── delivery_admin/ # 配送点后台 + └── src/views/shared/ # 通用列表与名称展示组件 +backend/api/internal/logic/platform/ +├── staff/ # 服务人员组织范围过滤 +└── user/ # 服务关系写入一致性校验 +``` + +## 4. 维护指南 + +新增类似关联名称列时,应通过字段级配置启用,不得按资源名称硬编码。新增组织关联必须同时提供前端联动和后端最终校验,不得只依赖下拉框过滤。 + +## 5. 验证记录 + +- 平台总后台:类型检查、资源页契约、组织联动契约和生产构建通过。 +- 气站后台、配送点后台:类型检查及生产构建通过。 +- 后端:平台工作人员与用户服务关系定向测试通过。 + +## 6. 风险评估 + +风险集中在历史异常数据:详情仍可查看,编辑保存会被严格校验阻止。未新增菜单、角色或写接口,现有权限边界不变;接口字段和数据库结构保持兼容。 diff --git a/frontend/delivery_admin/src/api/resources.ts b/frontend/delivery_admin/src/api/resources.ts index af7f685..ee6213e 100644 --- a/frontend/delivery_admin/src/api/resources.ts +++ b/frontend/delivery_admin/src/api/resources.ts @@ -21,9 +21,15 @@ export type ResourceFieldType = export type ResourceField = { key: string; label: string; + listLabel?: string; type?: ResourceFieldType; required?: boolean; relation?: string; + listRelationNameOnly?: boolean; + emptyText?: string; + placeholder?: string; + showIdentityCopy?: boolean; + readonlyRelationText?: boolean; options?: Array<{ label: string; value: string | number }>; defaultValue?: string | number | boolean; readonly?: boolean; @@ -271,8 +277,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 }); } /** 创建固定管理员角色字段,确保页面仅展示后端支持的角色。 */ @@ -332,7 +343,12 @@ const platformResources: ResourceUiDefinition[] = [ define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', 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, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), + relation('gas_basic_identity', '/gas_basic', false, { label: '所属气站', listLabel: '所属气站', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择所属气站', showIdentityCopy: true }), + relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择所属配送点', showIdentityCopy: true }), + relation('staff_account_identity', '/staff_account', false, { label: '服务人员', listLabel: '服务人员', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择服务人员', showIdentityCopy: true }), + ]), define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]), define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address'), f('manager'), f('phone')]), diff --git a/frontend/delivery_admin/src/views/shared/CrudListPage.vue b/frontend/delivery_admin/src/views/shared/CrudListPage.vue index 78e4347..cd957ce 100644 --- a/frontend/delivery_admin/src/views/shared/CrudListPage.vue +++ b/frontend/delivery_admin/src/views/shared/CrudListPage.vue @@ -34,9 +34,10 @@ - + @@ -269,6 +270,7 @@ import type { } from '@/api/resources'; import { useUserStore } from '@/store'; import IdentityText from '@/components/IdentityText.vue'; +import RelationNameText from './RelationNameText.vue'; type Row = Record; const props = defineProps<{ definition: ResourceUiDefinition }>(); @@ -1153,6 +1155,15 @@ function identityFieldValue(field: ResourceField, row: Row) { return String(row[field.key] ?? row[`${field.key}_masked`] ?? ''); } +/** 获取关联记录的业务名称;关系尚未加载时降级显示完整唯一标识。 */ +function relationListName(field: ResourceField, row: Row) { + const identity = identityFieldValue(field, row); + const match = (relationOptions[field.relation ?? ''] ?? []).find( + (option) => String(option.identity) === identity, + ); + return match ? optionLabel(match) : identity; +} + function columnWidth(field: ResourceField) { if (field.key === 'identity') return 280; if (field.type === 'datetime' || field.type === 'date') return 180; diff --git a/frontend/delivery_admin/src/views/shared/RelationNameText.vue b/frontend/delivery_admin/src/views/shared/RelationNameText.vue new file mode 100644 index 0000000..084c55b --- /dev/null +++ b/frontend/delivery_admin/src/views/shared/RelationNameText.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/frontend/gas_admin/src/api/resources.ts b/frontend/gas_admin/src/api/resources.ts index d36c5bf..54e3f6e 100644 --- a/frontend/gas_admin/src/api/resources.ts +++ b/frontend/gas_admin/src/api/resources.ts @@ -21,9 +21,15 @@ export type ResourceFieldType = export type ResourceField = { key: string; label: string; + listLabel?: string; type?: ResourceFieldType; required?: boolean; relation?: string; + listRelationNameOnly?: boolean; + emptyText?: string; + placeholder?: string; + showIdentityCopy?: boolean; + readonlyRelationText?: boolean; options?: Array<{ label: string; value: string | number }>; defaultValue?: string | number | boolean; readonly?: boolean; @@ -271,8 +277,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 }); } /** 创建固定管理员角色字段,确保页面仅展示后端支持的角色。 */ @@ -332,7 +343,12 @@ const platformResources: ResourceUiDefinition[] = [ define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', 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, { label: '用户账户', listLabel: '用户账户', listRelationNameOnly: true, showIdentityCopy: true, readonlyRelationText: true }), + relation('gas_basic_identity', '/gas_basic', false, { label: '所属气站', listLabel: '所属气站', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择所属气站', showIdentityCopy: true }), + relation('delivery_basic_identity', '/delivery_basic', false, { label: '所属配送点', listLabel: '所属配送点', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择所属配送点', showIdentityCopy: true }), + relation('staff_account_identity', '/staff_account', false, { label: '服务人员', listLabel: '服务人员', listRelationNameOnly: true, emptyText: '暂未分配', placeholder: '请选择服务人员', showIdentityCopy: true }), + ]), define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]), define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address'), f('manager'), f('phone')]), diff --git a/frontend/gas_admin/src/views/shared/CrudListPage.vue b/frontend/gas_admin/src/views/shared/CrudListPage.vue index 78e4347..cd957ce 100644 --- a/frontend/gas_admin/src/views/shared/CrudListPage.vue +++ b/frontend/gas_admin/src/views/shared/CrudListPage.vue @@ -34,9 +34,10 @@ - + @@ -269,6 +270,7 @@ import type { } from '@/api/resources'; import { useUserStore } from '@/store'; import IdentityText from '@/components/IdentityText.vue'; +import RelationNameText from './RelationNameText.vue'; type Row = Record; const props = defineProps<{ definition: ResourceUiDefinition }>(); @@ -1153,6 +1155,15 @@ function identityFieldValue(field: ResourceField, row: Row) { return String(row[field.key] ?? row[`${field.key}_masked`] ?? ''); } +/** 获取关联记录的业务名称;关系尚未加载时降级显示完整唯一标识。 */ +function relationListName(field: ResourceField, row: Row) { + const identity = identityFieldValue(field, row); + const match = (relationOptions[field.relation ?? ''] ?? []).find( + (option) => String(option.identity) === identity, + ); + return match ? optionLabel(match) : identity; +} + function columnWidth(field: ResourceField) { if (field.key === 'identity') return 280; if (field.type === 'datetime' || field.type === 'date') return 180; diff --git a/frontend/gas_admin/src/views/shared/RelationNameText.vue b/frontend/gas_admin/src/views/shared/RelationNameText.vue new file mode 100644 index 0000000..084c55b --- /dev/null +++ b/frontend/gas_admin/src/views/shared/RelationNameText.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs b/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs index 7b0b9e9..a69be98 100644 --- a/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs +++ b/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs @@ -19,6 +19,8 @@ const { linkedRelationValidationMessage, optionParentIdentity, shouldClearLinkedOption, + staffOrganizationFilters, + staffOrganizationValidationMessage, } = await import(moduleURL); const gasA = 'gas-a'; @@ -34,6 +36,36 @@ assert.equal( gasA, '普通配送点必须能回填所属气站', ); + +assert.deepEqual(staffOrganizationFilters(gasA, 'delivery-a'), { + gas_basic_identities: gasA, + delivery_basic_identities: 'delivery-a', +}); +assert.deepEqual(staffOrganizationFilters('', ''), { + gas_basic_unassigned: '1', + delivery_basic_unassigned: '1', +}); +const staffA = { + identity: 'staff-a', + gas_basic_identity: gasA, + delivery_basic_identity: 'delivery-a', +}; +assert.equal( + staffOrganizationValidationMessage(gasA, 'delivery-a', 'staff-a', staffA), + '', +); +assert.match( + staffOrganizationValidationMessage(gasB, 'delivery-a', 'staff-a', staffA), + /不属于当前气站/, +); +assert.match( + staffOrganizationValidationMessage(gasA, '', 'staff-a', staffA), + /不属于当前配送点/, +); +assert.match( + staffOrganizationValidationMessage(gasA, 'delivery-a', 'staff-a', undefined), + /无法确认/, +); assert.equal( optionParentIdentity(platformDelivery, 'gas_basic_identity'), '', @@ -128,10 +160,11 @@ const resourcesSource = await readFile( ); assert.equal( (resourcesSource.match(/relationLinkage:/g) ?? []).length, - 1, - '联动配置只能显式启用于工作人员配送点字段', + 2, + '联动配置必须显式启用于工作人员和用户服务关系的配送点字段', ); assert.match(resourcesSource, /filterKey: 'gas_basic_identities'/); +assert.match(resourcesSource, /organizationScoped: true/); assert.match(resourcesSource, /label: '所属气站'/); assert.match(resourcesSource, /label: '所属配送点'/); diff --git a/frontend/platform_admin/scripts/check-staff-relation-policy.mjs b/frontend/platform_admin/scripts/check-staff-relation-policy.mjs index cb24c1b..260a8ca 100644 --- a/frontend/platform_admin/scripts/check-staff-relation-policy.mjs +++ b/frontend/platform_admin/scripts/check-staff-relation-policy.mjs @@ -96,6 +96,7 @@ const resourcesSource = await readFile( new URL('../src/api/resources.ts', import.meta.url), 'utf8', ); +assert.match(resourcesSource, /organizationScoped: true/); assert.equal( (resourcesSource.match(/staffRelation:/g) ?? []).length, 3, diff --git a/frontend/platform_admin/src/api/resource-staff-relation.ts b/frontend/platform_admin/src/api/resource-staff-relation.ts index 1da7a8a..0e1695b 100644 --- a/frontend/platform_admin/src/api/resource-staff-relation.ts +++ b/frontend/platform_admin/src/api/resource-staff-relation.ts @@ -13,6 +13,7 @@ export type StaffRelationPolicy = { workStatus?: 'on_duty' | 'off_duty'; lockPrefilled?: boolean; showIdentityCopy?: boolean; + organizationScoped?: boolean; }; export type StaffRelationContext = { diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 730f545..b147692 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -44,6 +44,8 @@ export type ResourceField = { displayPrecision?: number; emptyText?: string; placeholder?: string; + showIdentityCopy?: boolean; + readonlyRelationText?: boolean; options?: Array<{ label: string; value: string | number }>; defaultValue?: string | number | boolean; readonlyOnCreate?: boolean; @@ -368,7 +370,54 @@ export const resources: ResourceUiDefinition[] = [ 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, { listLabel: '用户账户', listRelationNameOnly: true }), f('address', { required: true, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), 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', false, { staffRelation: { roles: ['installer', 'delivery', 'operations'], enabledOnly: true } })]), + define('user_service_relation', '用户服务关系', 'writable', [ + relation('user_account_identity', '/user_account', true, { + label: '用户账户', + listLabel: '用户账户', + listRelationNameOnly: true, + displayRelationLabel: true, + showIdentityCopy: true, + readonlyRelationText: true, + }), + relation('gas_basic_identity', '/gas_basic', false, { + label: '所属气站', + listLabel: '所属气站', + listRelationNameOnly: true, + displayRelationLabel: true, + emptyText: '暂未分配', + placeholder: '请选择所属气站', + showIdentityCopy: true, + }), + relation('delivery_basic_identity', '/delivery_basic', false, { + label: '所属配送点', + listLabel: '所属配送点', + listRelationNameOnly: true, + displayRelationLabel: true, + emptyText: '暂未分配', + placeholder: '请选择所属配送点', + showIdentityCopy: true, + relationLinkage: { + parentKey: 'gas_basic_identity', + optionParentKey: 'gas_basic_identity', + filterKey: 'gas_basic_identities', + backfillParent: true, + }, + }), + relation('staff_account_identity', '/staff_account', false, { + label: '服务人员', + listLabel: '服务人员', + listRelationNameOnly: true, + displayRelationLabel: true, + emptyText: '暂未分配', + placeholder: '请选择服务人员', + showIdentityCopy: true, + staffRelation: { + roles: ['installer', 'delivery', 'operations'], + enabledOnly: true, + organizationScoped: 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 })]), diff --git a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue index fd4edfa..81d81d5 100644 --- a/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue +++ b/frontend/platform_admin/src/views/resource/ResourceDetailContent.vue @@ -121,7 +121,9 @@ const entries = computed(() => { value: display, objectValue, identityValue: - field?.staffRelation?.showIdentityCopy && value ? String(value) : '', + (field?.showIdentityCopy || field?.staffRelation?.showIdentityCopy) && value + ? String(value) + : '', wide: objectValue || /(address|terms|content|body|remark|reason|params|args)$/.test(key), diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue index 8fa5b69..4e10c07 100644 --- a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -68,7 +68,18 @@
+
+ {{ readonlyRelationLabel(field) }} + +
人员标识 @@ -146,6 +157,16 @@ function isWide(field: ResourceField) { ); } +/** 返回只读关系字段的业务名称,未加载到历史记录时保留唯一标识以避免误导。 */ +function readonlyRelationLabel(field: ResourceField) { + const identity = String(model.value[field.key] ?? ''); + if (!identity) return field.emptyText ?? '暂未分配'; + const option = (props.relationOptions[field.relation ?? ''] ?? []).find( + (item) => String(item.identity ?? '') === identity, + ); + return option ? relationOptionLabel(field, option) : identity; +} + /** 为历史未知编码补充只读选项,避免下拉框退回显示原始值。 */ function selectOptions(field: ResourceField) { const options = [...(field.options ?? [])]; @@ -195,6 +216,19 @@ function selectOptions(field: ResourceField) { color: var(--color-text-3); font-size: 12px; } +.readonly-relation { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + min-height: 32px; + padding: 5px 12px; + background: var(--color-fill-1); + border-radius: var(--border-radius-small); +} +.readonly-relation-name { + color: var(--color-text-1); +} @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 d2bb759..edac0d4 100644 --- a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue @@ -305,7 +305,11 @@ const visibleActions = computed(() => ), ), ); -const modeLabel = computed(() => recordPageModeLabel(mode.value)); +const modeLabel = computed(() => + definition.value.name === 'user_service_relation' + ? `${recordPageModeLabel(mode.value)}${definition.value.title}` + : recordPageModeLabel(mode.value), +); const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value)); function snapshot() { diff --git a/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts b/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts index 904e091..a2844a2 100644 --- a/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts +++ b/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts @@ -60,6 +60,42 @@ export function linkedRelationFilters( return parentIdentity ? { [filterKey]: parentIdentity } : undefined; } +/** 为服务人员关系生成精确组织范围;空组织使用显式未分配条件,避免返回其他组织人员。 */ +export function staffOrganizationFilters( + gasIdentity: string, + deliveryIdentity: string, +) { + return { + ...(gasIdentity + ? { gas_basic_identities: gasIdentity } + : { gas_basic_unassigned: '1' }), + ...(deliveryIdentity + ? { delivery_basic_identities: deliveryIdentity } + : { delivery_basic_unassigned: '1' }), + }; +} + +/** 校验服务人员记录与当前气站、配送点完全一致,空字符串代表可以提交。 */ +export function staffOrganizationValidationMessage( + gasIdentity: string, + deliveryIdentity: string, + staffIdentity: string, + option: ResourceRow | undefined, +) { + if (!staffIdentity) return ''; + if (!option) return '无法确认所选服务人员的组织归属,请重新选择'; + if (optionParentIdentity(option, 'gas_basic_identity') !== gasIdentity) { + return '所选服务人员不属于当前气站,请重新选择'; + } + if ( + optionParentIdentity(option, 'delivery_basic_identity') !== + deliveryIdentity + ) { + return '所选服务人员不属于当前配送点,请重新选择'; + } + return ''; +} + /** 为同一关联资源生成递增版本号,用于识别并丢弃过期响应。 */ export function createRelationRequestVersionGuard() { const versions = new Map(); 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 e26b047..e91646d 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 @@ -10,6 +10,8 @@ import { optionParentIdentity, relationIdentity, shouldClearLinkedOption, + staffOrganizationFilters, + staffOrganizationValidationMessage, } from './resource-relation-linkage-policy'; import type { ResourceRelations } from './use-resource-relations'; @@ -23,6 +25,7 @@ export function useResourceRelationLinkage( relations: ResourceRelations, ) { let active: ActiveLinkage | undefined; + let organizationStaffField: ResourceField | undefined; let initialized = false; function findOption(resource: string, identity: string) { @@ -33,6 +36,9 @@ export function useResourceRelationLinkage( function configure(fields: ResourceField[]) { const childField = fields.find((field) => field.relationLinkage); + organizationStaffField = fields.find( + (field) => field.staffRelation?.organizationScoped, + ); if (!childField?.relationLinkage) { active = undefined; return; @@ -45,6 +51,49 @@ export function useResourceRelationLinkage( }; } + function staffRequest() { + const staffIdentity = relationIdentity( + organizationStaffField ? form[organizationStaffField.key] : '', + ); + return { + filters: staffOrganizationFilters( + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), + ), + preserveIdentities: staffIdentity ? [staffIdentity] : [], + }; + } + + async function reloadStaffOptions() { + const field = organizationStaffField; + if (!field?.relation) return; + relations.cancelSearch(field.relation); + await relations.loadField(field, '', staffRequest()); + } + + /** 组织变化后清理不再匹配的服务人员,并刷新精确范围选项。 */ + async function synchronizeStaff() { + const field = organizationStaffField; + if (!field?.relation) return; + const staffIdentity = relationIdentity(form[field.key]); + if (staffIdentity) { + const option = + findOption(field.relation, staffIdentity) ?? + (await relations.ensure(field.relation, staffIdentity)); + const warning = staffOrganizationValidationMessage( + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), + staffIdentity, + option, + ); + if (warning && relationIdentity(form[field.key]) === staffIdentity) { + form[field.key] = ''; + Message.info(`${warning},已清空原服务人员`); + } + } + await reloadStaffOptions(); + } + function childRequest() { if (!active?.childField.relationLinkage) return {}; const linkage = active.childField.relationLinkage; @@ -69,14 +118,21 @@ export function useResourceRelationLinkage( initialized = false; configure(fields); if (!active?.childField.relationLinkage || !active.childField.relation) { - await relations.preload(fields); + await relations.preload( + fields.filter((field) => field.key !== organizationStaffField?.key), + ); await relations.ensureValues(fields, form); + await reloadStaffOptions(); initialized = true; return; } await relations.preload( - fields.filter((field) => field.key !== active?.childField.key), + fields.filter( + (field) => + field.key !== active?.childField.key && + field.key !== organizationStaffField?.key, + ), ); const linkage = active.childField.relationLinkage; const parentIdentity = relationIdentity(form[linkage.parentKey]); @@ -87,7 +143,14 @@ export function useResourceRelationLinkage( if (childIdentity) { await relations.ensure(active.childField.relation, childIdentity); } + const staffIdentity = relationIdentity( + organizationStaffField ? form[organizationStaffField.key] : '', + ); + if (staffIdentity && organizationStaffField?.relation) { + await relations.ensure(organizationStaffField.relation, staffIdentity); + } await reloadChildOptions(); + await reloadStaffOptions(); initialized = true; const warning = validationMessage(); @@ -158,8 +221,12 @@ export function useResourceRelationLinkage( const identity = relationIdentity(value); if (field.key === active.childField.relationLinkage.parentKey) { await handleParentChange(identity); + await synchronizeStaff(); } else if (field.key === active.childField.key) { await handleChildChange(identity); + await synchronizeStaff(); + } else if (field.key === organizationStaffField?.key) { + await synchronizeStaff(); } } @@ -170,6 +237,10 @@ export function useResourceRelationLinkage( relations.searchField(field, keyword, childRequest()); return; } + if (field.key === organizationStaffField?.key) { + relations.searchField(field, keyword, staffRequest()); + return; + } relations.searchField(field, keyword); } @@ -181,12 +252,22 @@ export function useResourceRelationLinkage( const parentIdentity = relationIdentity(form[linkage.parentKey]); const childIdentity = relationIdentity(form[active.childField.key]); const option = findOption(active.childField.relation, childIdentity); - return linkedRelationValidationMessage( + const linkedMessage = linkedRelationValidationMessage( parentIdentity, childIdentity, option, linkage.optionParentKey, ); + if (linkedMessage) return linkedMessage; + const staffField = organizationStaffField; + if (!staffField?.relation) return ''; + const staffIdentity = relationIdentity(form[staffField.key]); + return staffOrganizationValidationMessage( + parentIdentity, + childIdentity, + staffIdentity, + findOption(staffField.relation, staffIdentity), + ); } return { preload, change, search, validationMessage }; diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 04ce0b7..1fac29e 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -60,6 +60,7 @@ v-else-if="field.listRelationNameOnly && identityFieldValue(field, record)" :name="relationListName(field, record, relations.options)" :identity="identityFieldValue(field, record)" + :identity-label="field.label" /> relations.ensureValues(displayFields.value, row)), + ); await extras.loadExtras(); } catch (error) { Message.error((error as Error).message); diff --git a/frontend/platform_admin/src/views/shared/RelationNameText.vue b/frontend/platform_admin/src/views/shared/RelationNameText.vue index f073179..9605e9b 100644 --- a/frontend/platform_admin/src/views/shared/RelationNameText.vue +++ b/frontend/platform_admin/src/views/shared/RelationNameText.vue @@ -1,14 +1,14 @@