From 029724a46c06a3868e21c6b752d30114024c373d Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Thu, 13 Aug 2026 22:50:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=85=8D=E9=80=81=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=94=B6=E8=B4=A7=E5=9C=B0=E5=9D=80=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/logic/platform/user/relation.go | 13 + .../logic/platform/user/relation_test.go | 34 +++ docs/05-平台总后台需求.md | 2 + docs/项目文档_配送订单收货地址联动_v1.0.md | 66 +++++ frontend/platform_admin/package.json | 1 + .../check-gasorder-address-linkage.mjs | 22 ++ .../src/api/resource-display.ts | 13 +- frontend/platform_admin/src/api/resources.ts | 23 +- .../src/views/resource/ResourceFieldForm.vue | 26 +- .../src/views/resource/ResourceRecordPage.vue | 13 +- .../resource/use-resource-relation-linkage.ts | 260 ++++++++++-------- .../views/resource/use-resource-relations.ts | 10 + 12 files changed, 367 insertions(+), 116 deletions(-) create mode 100644 docs/项目文档_配送订单收货地址联动_v1.0.md create mode 100644 frontend/platform_admin/scripts/check-gasorder-address-linkage.mjs diff --git a/backend/api/internal/logic/platform/user/relation.go b/backend/api/internal/logic/platform/user/relation.go index 9068149..268d4f8 100644 --- a/backend/api/internal/logic/platform/user/relation.go +++ b/backend/api/internal/logic/platform/user/relation.go @@ -4,6 +4,7 @@ package user import ( "errors" + "strings" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -28,6 +29,7 @@ func ListUserAddress(ctx *gin.Context) { var list []models.UserAddress var total int64 query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.UserAddress{})), &models.UserAddress{}) + query = filterUserAddressByContract(query, ctx.Query("gasorder_contract_identity")) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -45,6 +47,17 @@ func ListUserAddress(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"total": total, "list": restoreUserAddressLocations(protected, list)}) } +// filterUserAddressByContract 仅返回指定配送合同签约用户的地址,避免后台下单时跨用户选择。 +func filterUserAddressByContract(query *gorm.DB, contractIdentity string) *gorm.DB { + contractIdentity = strings.TrimSpace(contractIdentity) + if contractIdentity == "" { + return query + } + contractUser := common.ActiveRecords(impl.DBService.Model(&models.GasorderContract{})). + Select("user_account_id").Where("identity = ?", contractIdentity) + return query.Where("user_account_id = (?)", contractUser) +} + // GetUserAddress 查询用户地址详情,并返回详情与编辑页需要的地址和坐标。 func GetUserAddress(ctx *gin.Context) { var address models.UserAddress diff --git a/backend/api/internal/logic/platform/user/relation_test.go b/backend/api/internal/logic/platform/user/relation_test.go index d32bbb2..524776c 100644 --- a/backend/api/internal/logic/platform/user/relation_test.go +++ b/backend/api/internal/logic/platform/user/relation_test.go @@ -3,11 +3,45 @@ package user import ( + "strings" "testing" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) +// TestFilterUserAddressByContract 验证下单地址候选通过合同签约用户进行服务端精确过滤。 +func TestFilterUserAddressByContract(t *testing.T) { + sqlDatabase, _, err := sqlmock.New() + if err != nil { + t.Fatalf("创建模拟数据库失败:%v", err) + } + defer sqlDatabase.Close() + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatalf("创建 GORM 数据库失败:%v", err) + } + previous := impl.DBService + impl.DBService = database + defer func() { impl.DBService = previous }() + + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + var addresses []models.UserAddress + return filterUserAddressByContract( + tx.Model(&models.UserAddress{}), + " contract-1 ", + ).Find(&addresses) + }) + if !strings.Contains(statement, `"gasorder_contract"`) || + !strings.Contains(statement, `identity = 'contract-1'`) || + !strings.Contains(statement, `user_account_id = (`) { + t.Fatalf("合同用户地址过滤 SQL 不完整:%s", statement) + } +} + // TestRestoreUserAddressLocations 验证用户地址列表按原顺序恢复地址与坐标。 func TestRestoreUserAddressLocations(t *testing.T) { response := []any{ diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index 520592d..8868c41 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -200,6 +200,8 @@ 创建订单时必须提交唯一请求号、有效合同、创建方、用户地址、合同气瓶集合、联系人和联系方式。后端根据合同价格和默认配送费计算订单金额,不接受前端自行决定最终金额。 +创建订单页面必须先选择配送合同,收货地址下拉仅展示该合同签约用户的地址。地址选项以完整地址为主文案并标记默认地址;存在默认地址时自动选中,切换合同时清空原地址。若合同用户没有可用地址,页面应明确引导先维护用户地址并禁止提交。 + 订单状态机如下: ```text diff --git a/docs/项目文档_配送订单收货地址联动_v1.0.md b/docs/项目文档_配送订单收货地址联动_v1.0.md new file mode 100644 index 0000000..7a4ce05 --- /dev/null +++ b/docs/项目文档_配送订单收货地址联动_v1.0.md @@ -0,0 +1,66 @@ +# 项目文档_配送订单收货地址联动_v1.0 + +## 1. 项目概述 + +本次变更优化平台总后台创建配送订单时的收货地址选择。原页面将用户地址退化显示为 UUID,且候选范围未按配送合同用户收敛,管理员难以识别并可能误选其他用户地址。 + +## 2. 核心规则 + +- 必须先选择配送合同,再选择收货地址。 +- 收货地址仅来源于配送合同签约用户。 +- 下拉选项显示完整地址,默认地址追加“默认地址”标记。 +- 候选中存在默认地址时自动选中,管理员仍可更换。 +- 切换配送合同时同时清空原收货地址和原合同气瓶,并重新加载候选。 +- 合同用户没有可用地址时,页面明确提示先维护用户地址并禁止保存。 +- 服务端创建订单时继续校验地址确实属于合同用户,前端联动不替代服务端鉴权。 + +## 3. 目录与文件职责 + +```text +frontend/platform_admin/ +├── src/api/resources.ts # 配送订单字段与联动配置 +├── src/api/resource-display.ts # 地址选项和默认标记展示 +├── src/views/resource/ResourceFieldForm.vue # 联动下拉禁用及空数据引导 +├── src/views/resource/ResourceRecordPage.vue # 空候选时阻止保存 +└── src/views/resource/use-resource-relation-linkage.ts # 多组父子联动协调 +backend/api/internal/logic/platform/user/ +├── relation.go # 按配送合同过滤用户地址 +└── relation_test.go # 合同地址过滤回归测试 +``` + +## 4. 变更记录 + +操作时间:2026-08-13 + +操作类型:扩展 + +影响模块:平台总后台配送订单、平台用户地址查询 + +操作前状态:地址下拉显示 UUID,并加载未按合同用户过滤的地址候选。 + +具体操作:增加合同地址精确过滤;扩展标准资源表单为多子项联动;增加地址业务文案、默认地址自动选择、合同切换清空及空数据阻断。 + +操作后状态:管理员可识别并且只能选择当前合同用户的地址;无地址时不能提交无效订单。 + +数据库变更:无。 + +## 5. 兼容性与风险 + +- `/user_address` 不携带 `gasorder_contract_identity` 时保持原有地址管理列表行为。 +- 新筛选参数仅收窄订单创建页候选范围,不改变地址数据和订单写入协议。 +- 多联动扩展保留原配送点、工作人员和合同气瓶联动规则。 +- 地址属于敏感数据,完整地址仍只通过已鉴权的平台用户地址接口返回,不扩展到其他终端。 + +## 6. 验证方式 + +```bash +cd backend/api +go test ./internal/logic/platform/user + +cd frontend/platform_admin +npm run gasorder-address:check +npm run type:check +npm run lint +npm run contract:check +npm run build +``` diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 9422608..2d4a05b 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -22,6 +22,7 @@ "staff-organization:check": "node scripts/check-staff-organization-linkage.mjs", "staff-relations:check": "node scripts/check-staff-relation-policy.mjs", "user-address-display:check": "node scripts/check-user-address-relation-display.mjs", + "gasorder-address:check": "node scripts/check-gasorder-address-linkage.mjs", "product-ownership:check": "node scripts/check-product-ownership-action.mjs", "product-ownership-display:check": "node scripts/check-product-ownership-display.mjs", "product-lifecycle-display:check": "node scripts/check-product-lifecycle-display.mjs", diff --git a/frontend/platform_admin/scripts/check-gasorder-address-linkage.mjs b/frontend/platform_admin/scripts/check-gasorder-address-linkage.mjs new file mode 100644 index 0000000..5b40c7c --- /dev/null +++ b/frontend/platform_admin/scripts/check-gasorder-address-linkage.mjs @@ -0,0 +1,22 @@ +/** + * 功能:静态检查配送订单收货地址的可读展示、合同过滤与默认地址联动。 + * 版本:v1.0.0 + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const resources = readFileSync(new URL('../src/api/resources.ts', import.meta.url), 'utf8'); +const display = readFileSync(new URL('../src/api/resource-display.ts', import.meta.url), 'utf8'); +const linkage = readFileSync(new URL('../src/views/resource/use-resource-relation-linkage.ts', import.meta.url), 'utf8'); + +assert.match(resources, /label: '收货地址'/, '订单地址字段必须使用业务名称'); +assert.match(resources, /relationOptionLabelKey: 'address'/, '订单地址选项必须显示完整地址'); +assert.match(resources, /relationOptionDefaultKey: 'is_default'/, '订单地址选项必须标记默认地址'); +assert.match(resources, /relationAutoSelectKey: 'is_default'/, '订单地址必须自动选择默认地址'); +assert.match(resources, /filterKey: 'gasorder_contract_identity'/, '订单地址必须按配送合同过滤'); +assert.match(resources, /该合同用户暂无收货地址,请先维护用户地址/, '缺少无地址时的业务引导'); +assert.match(display, /默认地址/, '关联选项未实现默认地址标记'); +assert.match(linkage, /active: ActiveLinkage\[\]/, '表单联动必须支持同一父级驱动多个子项'); +assert.match(linkage, /unavailableMessage/, '必填候选为空时必须阻止无效提交'); + +console.log('配送订单地址检查通过:可读地址、默认标记、合同过滤、自动选择与空数据引导均已覆盖。'); diff --git a/frontend/platform_admin/src/api/resource-display.ts b/frontend/platform_admin/src/api/resource-display.ts index f175fca..4a6c9fb 100644 --- a/frontend/platform_admin/src/api/resource-display.ts +++ b/frontend/platform_admin/src/api/resource-display.ts @@ -150,7 +150,18 @@ export function optionLabel(option: ResourceRow) { /** 工作人员关系额外展示角色,其他关系保持原有可读名称。 */ export function relationOptionLabel(field: ResourceField, option: ResourceRow) { - const label = optionLabel(option); + const configuredLabel = field.relationOptionLabelKey + ? option[field.relationOptionLabelKey] + : undefined; + const label = configuredLabel == null || configuredLabel === '' + ? optionLabel(option) + : String(configuredLabel); + if ( + field.relationOptionDefaultKey && + option[field.relationOptionDefaultKey] + ) { + return `${label}(默认地址)`; + } return field.relation === '/staff_account' ? `${label}(${staffRoleLabel(option.role_code)})` : label; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index ec3561b..ba17692 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -33,6 +33,10 @@ export type ResourceRelationLinkage = { backfillParent?: boolean; /** 仅使用父字段筛选候选项,不校验候选项中的父标识。 */ filterOnly?: boolean; + /** 父字段为空时不加载候选项,并禁用当前关联选择器。 */ + requiresParent?: boolean; + /** 父字段变化并清空当前值时展示的中文提示。 */ + parentChangeMessage?: string; }; export type ResourceField = { @@ -56,6 +60,14 @@ export type ResourceField = { readonlyOnCreate?: boolean; unknownValueLabel?: string; relationLinkage?: ResourceRelationLinkage; + /** 关联选项优先使用的业务展示字段。 */ + relationOptionLabelKey?: string; + /** 该字段为真时,在关联选项后追加“默认地址”。 */ + relationOptionDefaultKey?: string; + /** 候选加载完成后自动选中该布尔字段为真的选项。 */ + relationAutoSelectKey?: string; + /** 关联候选为空时展示的业务引导。 */ + relationEmptyText?: string; staffRelation?: import('./resource-staff-relation').StaffRelationPolicy; }; @@ -502,7 +514,16 @@ export const resources: ResourceUiDefinition[] = [ { name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason }, ]), 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: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), 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', [ + define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true, { label: '配送合同', placeholder: '请选择配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { required: true }), relation('user_address_identity', '/user_address', true, { + label: '收货地址', placeholder: '请先选择配送合同,再选择该合同用户的收货地址', + relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default', + relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址', + relationLinkage: { + parentKey: 'gasorder_contract_identity', optionParentKey: '', + filterKey: 'gasorder_contract_identity', filterOnly: true, requiresParent: true, + parentChangeMessage: '配送合同已变更,请重新选择该合同用户的收货地址', + }, + }), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product', placeholder: '请先选择配送合同,再选择该合同可用的气瓶', relationLinkage: { parentKey: 'gasorder_contract_identity', optionParentKey: '', filterKey: 'contract_identity', filterOnly: true, requiresParent: true, parentChangeMessage: '配送合同已变更,请重新选择该合同可用的气瓶' } }), 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, { 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] } }, diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue index 7fd4dcc..ce9b958 100644 --- a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -90,7 +90,7 @@ {{ relationOptionLabel(field, option) }} +
当前归属只能选择库房、气站、配送点或用户中的一个;选择新归属时会自动清空其他归属。 + + {{ relationUnavailableMessage }} +
- 保存 + 保存 取消
@@ -258,6 +266,9 @@ const staffOwnerGuard = useStaffCredentialOwnerGuard({ const resolvedStaffType = staffOwnerGuard.resolvedStaffType; const relations = staffOwnerGuard.relations; const relationLinkage = useResourceRelationLinkage(form, relations); +const relationUnavailableMessage = computed(() => + relationLinkage.unavailableMessage(), +); const productOwnershipKeys = [ 'warehouse_identity', 'gas_basic_identity', 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 27b2ec7..7c6ad2c 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 @@ -1,6 +1,6 @@ /** - * 功能:协调资源表单中显式启用的父子关联下拉、搜索和保存前校验。 - * 版本:v1.0.0 + * 功能:协调资源表单中显式启用的多组父子关联下拉、搜索和保存前校验。 + * 版本:v1.1.0 */ import { Message } from '@arco-design/web-vue'; import type { ResourceField } from '@/api/resources'; @@ -24,7 +24,7 @@ export function useResourceRelationLinkage( form: Record, relations: ResourceRelations, ) { - let active: ActiveLinkage | undefined; + let active: ActiveLinkage[] = []; let organizationStaffField: ResourceField | undefined; let initialized = false; @@ -34,21 +34,19 @@ export function useResourceRelationLinkage( ); } + /** 收集页面内全部显式联动,允许一个父字段同时驱动多个业务子项。 */ function configure(fields: ResourceField[]) { - const childField = fields.find((field) => field.relationLinkage); + active = fields + .filter((field) => field.relationLinkage) + .map((childField) => ({ + childField, + parentField: fields.find( + (field) => field.key === childField.relationLinkage?.parentKey, + ), + })); organizationStaffField = fields.find( (field) => field.staffRelation?.organizationScoped, ); - if (!childField?.relationLinkage) { - active = undefined; - return; - } - active = { - childField, - parentField: fields.find( - (field) => field.key === childField.relationLinkage?.parentKey, - ), - }; } function staffRequest() { @@ -94,62 +92,63 @@ export function useResourceRelationLinkage( await reloadStaffOptions(); } - function childRequest() { - if (!active?.childField.relationLinkage) return {}; - const linkage = active.childField.relationLinkage; + function childRequest(linkageItem: ActiveLinkage) { + const linkage = linkageItem.childField.relationLinkage; + if (!linkage) return {}; const parentIdentity = relationIdentity(form[linkage.parentKey]); - const childIdentity = relationIdentity(form[active.childField.key]); + const rawChildValue = form[linkageItem.childField.key]; + const childIdentities = (Array.isArray(rawChildValue) + ? rawChildValue + : [rawChildValue] + ) + .map(relationIdentity) + .filter(Boolean); return { filters: linkedRelationFilters(parentIdentity, linkage.filterKey), - preserveIdentities: childIdentity ? [childIdentity] : [], + preserveIdentities: childIdentities, }; } - async function reloadChildOptions() { - const childField = active?.childField; - const resource = childField?.relation; - if (!resource) return; + /** 加载一组子选项,并在配置要求时自动选中默认业务记录。 */ + async function reloadChildOptions(linkageItem: ActiveLinkage) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + const resource = childField.relation; + if (!linkage || !resource) return; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + if (linkage.requiresParent && !parentIdentity) { + relations.clear(resource); + return; + } relations.cancelSearch(resource); - await relations.loadField(childField, '', childRequest()); + await relations.loadField(childField, '', childRequest(linkageItem)); + if ( + !relationIdentity(form[childField.key]) && + childField.relationAutoSelectKey + ) { + const defaultOption = (relations.options[resource] ?? []).find( + (option) => Boolean(option[childField.relationAutoSelectKey as string]), + ); + if (defaultOption) { + form[childField.key] = relationIdentity(defaultOption.identity); + } + } } - /** 预加载普通关联项,并单独按父级条件加载启用联动的子选项。 */ + /** 预加载普通关联项,并分别按父级条件加载所有启用联动的子选项。 */ async function preload(fields: ResourceField[]) { initialized = false; configure(fields); - if (!active?.childField.relationLinkage || !active.childField.relation) { - await relations.preload( - fields.filter((field) => field.key !== organizationStaffField?.key), - ); - await relations.ensureValues(fields, form); - await reloadStaffOptions(); - initialized = true; - return; - } - + const linkedChildKeys = new Set(active.map((item) => item.childField.key)); await relations.preload( fields.filter( (field) => - field.key !== active?.childField.key && + !linkedChildKeys.has(field.key) && field.key !== organizationStaffField?.key, ), ); - const linkage = active.childField.relationLinkage; - const parentIdentity = relationIdentity(form[linkage.parentKey]); - const childIdentity = relationIdentity(form[active.childField.key]); - if (parentIdentity && active.parentField?.relation) { - await relations.ensure(active.parentField.relation, parentIdentity); - } - 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 relations.ensureValues(fields, form); + await Promise.all(active.map((item) => reloadChildOptions(item))); await reloadStaffOptions(); initialized = true; @@ -157,58 +156,61 @@ export function useResourceRelationLinkage( if (warning) Message.warning(`当前组织归属需要确认:${warning}`); } - async function handleParentChange(parentIdentity: string) { - if (!active?.childField.relationLinkage || !active.childField.relation) - return; - const linkage = active.childField.relationLinkage; - const childIdentity = relationIdentity(form[active.childField.key]); + async function handleParentChange( + linkageItem: ActiveLinkage, + parentIdentity: string, + ) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if (!linkage || !childField.relation) return; + const childIdentity = relationIdentity(form[childField.key]); + if (linkage.filterOnly) relations.clear(childField.relation); if (linkage.filterOnly && childIdentity) { - form[active.childField.key] = ''; - Message.info('合同已变更,请重新选择该合同可绑定的气瓶'); + form[childField.key] = childField.type === 'identity-list' ? [] : ''; + Message.info( + linkage.parentChangeMessage ?? '上级数据已变更,请重新选择关联数据', + ); } if (childIdentity && !linkage.filterOnly) { const option = - findOption(active.childField.relation, childIdentity) ?? - (await relations.ensure(active.childField.relation, childIdentity)); + findOption(childField.relation, childIdentity) ?? + (await relations.ensure(childField.relation, childIdentity)); if ( relationIdentity(form[linkage.parentKey]) !== parentIdentity || - relationIdentity(form[active.childField.key]) !== childIdentity + relationIdentity(form[childField.key]) !== childIdentity ) { return; } if ( shouldClearLinkedOption(parentIdentity, option, linkage.optionParentKey) ) { - form[active.childField.key] = ''; - Message.info('所属气站已变更,请重新选择配送点'); + form[childField.key] = ''; + Message.info( + linkage.parentChangeMessage ?? '所属气站已变更,请重新选择配送点', + ); } } - await reloadChildOptions(); + await reloadChildOptions(linkageItem); } - async function handleChildChange(childIdentity: string) { - if (!active?.childField.relationLinkage || !active.childField.relation) + async function handleChildChange( + linkageItem: ActiveLinkage, + childIdentity: string, + ) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if (!linkage || !childField.relation || !childIdentity || linkage.filterOnly) return; - const linkage = active.childField.relationLinkage; - if (!childIdentity) return; - if (linkage.filterOnly) return; const option = - findOption(active.childField.relation, childIdentity) ?? - (await relations.ensure(active.childField.relation, childIdentity)); - if ( - !option || - relationIdentity(form[active.childField.key]) !== childIdentity - ) { - return; - } + findOption(childField.relation, childIdentity) ?? + (await relations.ensure(childField.relation, childIdentity)); + if (!option || relationIdentity(form[childField.key]) !== childIdentity) return; if (linkage.backfillParent) { const nextParent = optionParentIdentity(option, linkage.optionParentKey); const currentParent = relationIdentity(form[linkage.parentKey]); - if (nextParent && active.parentField?.relation) { - await relations.ensure(active.parentField.relation, nextParent); - if (relationIdentity(form[active.childField.key]) !== childIdentity) { - return; - } + if (nextParent && linkageItem.parentField?.relation) { + await relations.ensure(linkageItem.parentField.relation, nextParent); + if (relationIdentity(form[childField.key]) !== childIdentity) return; } if (currentParent !== nextParent) { form[linkage.parentKey] = nextParent; @@ -217,29 +219,41 @@ export function useResourceRelationLinkage( } } } - await reloadChildOptions(); + await reloadChildOptions(linkageItem); } /** 响应用户主动修改关联字段;初始化回显不会触发自动清理。 */ async function change(field: ResourceField, value: unknown) { - if (!initialized || !active?.childField.relationLinkage) return; + if (!initialized) return; 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) { + const parentLinkages = active.filter( + (item) => item.childField.relationLinkage?.parentKey === field.key, + ); + await Promise.all( + parentLinkages.map((item) => handleParentChange(item, identity)), + ); + const childLinkage = active.find((item) => item.childField.key === field.key); + if (childLinkage) await handleChildChange(childLinkage, identity); + if ( + field.key === 'gas_basic_identity' || + field.key === 'delivery_basic_identity' || + field.key === organizationStaffField?.key + ) { await synchronizeStaff(); } } - /** 搜索联动子项时始终保留父级过滤和当前选项。 */ + /** 搜索联动子项时始终保留对应父级过滤和当前选项。 */ function search(field: ResourceField, keyword: string) { if (!field.relation) return; - if (field.key === active?.childField.key) { - relations.searchField(field, keyword, childRequest()); + const linkageItem = active.find((item) => item.childField.key === field.key); + if (linkageItem) { + const linkage = linkageItem.childField.relationLinkage; + if (linkage?.requiresParent && !relationIdentity(form[linkage.parentKey])) { + relations.clear(field.relation); + return; + } + relations.searchField(field, keyword, childRequest(linkageItem)); return; } if (field.key === organizationStaffField?.key) { @@ -251,30 +265,52 @@ export function useResourceRelationLinkage( /** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */ function validationMessage() { - if (!active?.childField.relationLinkage || !active.childField.relation) - return ''; - const linkage = active.childField.relationLinkage; - if (linkage.filterOnly) return ''; - const parentIdentity = relationIdentity(form[linkage.parentKey]); - const childIdentity = relationIdentity(form[active.childField.key]); - const option = findOption(active.childField.relation, childIdentity); - const linkedMessage = linkedRelationValidationMessage( - parentIdentity, - childIdentity, - option, - linkage.optionParentKey, - ); - if (linkedMessage) return linkedMessage; + for (const linkageItem of active) { + const childField = linkageItem.childField; + const linkage = childField.relationLinkage; + if (!linkage || !childField.relation || linkage.filterOnly) continue; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const childIdentity = relationIdentity(form[childField.key]); + const linkedMessage = linkedRelationValidationMessage( + parentIdentity, + childIdentity, + findOption(childField.relation, childIdentity), + linkage.optionParentKey, + ); + if (linkedMessage) return linkedMessage; + } const staffField = organizationStaffField; if (!staffField?.relation) return ''; const staffIdentity = relationIdentity(form[staffField.key]); return staffOrganizationValidationMessage( - parentIdentity, - childIdentity, + relationIdentity(form.gas_basic_identity), + relationIdentity(form.delivery_basic_identity), staffIdentity, findOption(staffField.relation, staffIdentity), ); } - return { preload, change, search, validationMessage }; + /** 必填联动候选为空时返回业务引导,用于阻止提交无效表单。 */ + function unavailableMessage() { + for (const linkageItem of active) { + const field = linkageItem.childField; + const linkage = field.relationLinkage; + if ( + !field.required || + !field.relation || + !field.relationEmptyText || + !linkage?.requiresParent || + !relationIdentity(form[linkage.parentKey]) || + relations.loading[field.relation] + ) { + continue; + } + if ((relations.options[field.relation] ?? []).length === 0) { + return field.relationEmptyText; + } + } + return ''; + } + + return { preload, change, search, validationMessage, unavailableMessage }; } diff --git a/frontend/platform_admin/src/views/resource/use-resource-relations.ts b/frontend/platform_admin/src/views/resource/use-resource-relations.ts index 2dc59f3..21864ae 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relations.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relations.ts @@ -183,6 +183,15 @@ export function useResourceRelations( timers.delete(resource); } + /** 清空依赖父级但当前尚无有效父级的候选项。 */ + function clear(resource: string | undefined) { + if (!resource) return; + cancelSearch(resource); + requestGuard.begin(resource); + options[resource] = []; + loading[resource] = false; + } + onBeforeUnmount(() => { for (const resource of timers.keys()) cancelSearch(resource); }); @@ -198,6 +207,7 @@ export function useResourceRelations( search, searchField, cancelSearch, + clear, }; }