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) }}
+
+ {{ relationEmptyText(field) }}
+
当前归属只能选择库房、气站、配送点或用户中的一个;选择新归属时会自动清空其他归属。
+
+ {{ 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,
};
}