修复配送订单创建方动态联动

This commit is contained in:
czl231
2026-08-14 00:40:48 +08:00
parent eafb6fadf3
commit 9c3abf76d5
7 changed files with 177 additions and 20 deletions

View File

@@ -202,6 +202,8 @@
创建订单时必须提交唯一请求号、有效合同、创建方、用户地址、合同气瓶集合、联系人和联系方式。后端根据合同价格和默认配送费计算订单金额,不接受前端自行决定最终金额。
平台创建订单时,创建方类型支持用户、工作人员、配送站和气站。创建方候选必须根据已选类型分别加载用户账户、工作人员账户、配送站或气站;未选择类型时禁用创建方字段,切换类型时清空原创建方并重新加载候选。候选以用户名或机构名称等业务名称展示,不直接要求管理员识别唯一标识。
创建订单页面必须先选择配送合同,收货地址下拉仅展示该合同签约用户的地址。地址选项以完整地址为主文案并标记默认地址;存在默认地址时自动选中,切换合同时清空原地址。若合同用户没有可用地址,页面应明确引导先维护用户地址并禁止提交。
合同气瓶下拉仅展示所选合同中尚未解绑的气瓶,按气瓶类型和气瓶编码排序,并支持按气瓶编码或类型搜索。选项以气瓶类型名称展示;切换合同时清空原选择。若合同没有可用气瓶,页面应引导先为合同绑定气瓶并禁止提交。合同气瓶管理列表仍保留完整绑定和解绑历史。

View File

@@ -0,0 +1,49 @@
# 项目文档_配送订单创建方动态联动_v1.0
## 1. 项目概述
本次修复平台总后台创建配送订单时“创建方标识”始终显示“暂无数据”的问题。后端已经支持四种创建方,但前端字段只有关系控件类型,没有绑定数据资源,也没有根据创建方类型切换候选。
## 2. 业务规则
- 创建方类型支持用户、工作人员、配送站和气站。
- 未选择创建方类型时,创建方下拉框保持禁用并提示先选择类型。
- 用户对应 `/user_account`
- 工作人员对应 `/staff_account`
- 配送站对应 `/delivery_basic`
- 气站对应 `/gas_basic`
- 切换创建方类型时清空原创建方,防止跨类型提交旧标识。
- 候选显示用户名、人员显示名称或机构名称等现有业务字段。
- 后端继续校验创建方类型与唯一标识,不修改数据库。
## 3. 核心文件
- `frontend/platform_admin/src/api/resources.ts`:声明动态关系配置和四类资源映射。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:动态资源渲染、父类型禁用和选择事件。
- `frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts`:动态候选加载、搜索与类型切换清理。
- `frontend/platform_admin/scripts/check-gasorder-creator-linkage.mjs`:创建方动态联动专项检查。
## 4. 维护指南
- 新增创建方类型时必须同时更新后端 `gasorderCreatorModels` 和前端动态资源映射。
- 不得把不同模型的唯一标识混放在同一个静态候选列表中。
- 切换父类型必须先清空原值,再加载新类型候选。
- 动态关系能力是通用扩展,其他多类型主体字段可复用,避免页面级硬编码。
## 5. 变更记录
操作时间2026-08-14
操作类型:修复、扩展
影响模块:平台总后台、气体配送订单创建
操作前状态:创建方标识没有关系资源,选择创建方类型后仍显示“暂无数据”。
具体操作:增加父字段驱动的动态关系配置,接入候选加载、搜索、禁用和切换清理流程。
操作后状态:管理员选择创建方类型后可选择对应业务主体,切换类型会清空旧选择。
验证范围:前端专项检查、类型检查、生产构建和既有资源契约检查。
风险评估:后端接口和数据库不变;动态关系仅在显式配置字段启用,不影响既有固定关系字段。

View File

@@ -25,6 +25,7 @@
"gasorder-address:check": "node scripts/check-gasorder-address-linkage.mjs",
"gasorder-contract-candidates:check": "node scripts/check-gasorder-contract-candidates.mjs",
"gasorder-contract-products:check": "node scripts/check-gasorder-contract-product-display.mjs",
"gasorder-creator:check": "node scripts/check-gasorder-creator-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",

View File

@@ -0,0 +1,28 @@
/**
* 功能:静态检查配送订单创建方类型与业务主体候选的动态联动。
* 版本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 form = readFileSync(new URL('../src/views/resource/ResourceFieldForm.vue', import.meta.url), 'utf8');
const linkage = readFileSync(new URL('../src/views/resource/use-resource-relation-linkage.ts', import.meta.url), 'utf8');
assert.match(resources, /parentKey: 'creator_type'/, '创建方必须由创建方类型驱动');
for (const mapping of [
"user: '/user_account'",
"staff: '/staff_account'",
"delivery: '/delivery_basic'",
"gas: '/gas_basic'",
]) {
assert.ok(resources.includes(mapping), `创建方动态资源缺少映射:${mapping}`);
}
assert.match(resources, /label: '创建方'/, '创建方字段必须使用业务名称');
assert.match(resources, /创建方类型已变更,请重新选择创建方/, '切换类型时缺少清空提示');
assert.match(form, /field\.dynamicRelation && !relationResource\(field\)/, '未选择类型时必须禁用创建方');
assert.match(form, /v-else-if="field\.type === 'select'"[\s\S]*?emit\('change-relation'/, '普通选择框未触发动态联动');
assert.match(linkage, /reloadDynamicOptions/, '动态关系候选未接入加载流程');
assert.match(linkage, /form\[childField\.key\] = ''/, '切换类型时未清空旧创建方');
console.log('配送订单创建方检查通过:四类创建方按类型动态加载,切换类型会清空旧值。');

View File

@@ -39,6 +39,14 @@ export type ResourceRelationLinkage = {
parentChangeMessage?: string;
};
/** 根据父字段值选择关系资源,用于创建方等多类型业务主体。 */
export type ResourceDynamicRelation = {
parentKey: string;
parentLabel: string;
resources: Record<string, string>;
parentChangeMessage?: string;
};
export type ResourceField = {
key: string;
label: string;
@@ -60,6 +68,8 @@ export type ResourceField = {
readonlyOnCreate?: boolean;
unknownValueLabel?: string;
relationLinkage?: ResourceRelationLinkage;
/** 父字段值决定实际关系资源,未选择父字段时不加载候选。 */
dynamicRelation?: ResourceDynamicRelation;
/** 关联选项优先使用的业务展示字段。 */
relationOptionLabelKey?: string;
/** 该字段为真时,在关联选项后追加“默认地址”。 */
@@ -523,7 +533,7 @@ 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, { label: '配送合同', placeholder: '请选择当前可履约的生效合同', relationFilters: { candidate: 'order' }, relationStrictFilter: true, relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择', relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同' }), 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, {
define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true, { label: '配送合同', placeholder: '请选择当前可履约的生效合同', relationFilters: { candidate: 'order' }, relationStrictFilter: true, relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择', relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { label: '创建方', required: true, placeholder: '请选择创建方', relationEmptyText: '当前类型暂无可用的创建方数据', dynamicRelation: { parentKey: 'creator_type', parentLabel: '创建方类型', resources: { user: '/user_account', staff: '/staff_account', delivery: '/delivery_basic', gas: '/gas_basic' }, parentChangeMessage: '创建方类型已变更,请重新选择创建方' } }), relation('user_address_identity', '/user_address', true, {
label: '收货地址', placeholder: '请先选择配送合同,再选择该合同用户的收货地址',
relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default',
relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址',
@@ -619,3 +629,14 @@ export function getResource(resourcePath: string): ResourceUiDefinition {
if (!definition) throw new Error(`未知资源:${resourcePath}`);
return definition;
}
/** 返回动态关系字段当前应使用的资源;父值为空或未知时返回空字符串。 */
export function dynamicRelationResource(
field: ResourceField,
values: Record<string, unknown>,
) {
const dynamic = field.dynamicRelation;
if (!dynamic) return field.relation ?? '';
const parentValue = String(values[dynamic.parentKey] ?? '').trim();
return dynamic.resources[parentValue] ?? '';
}

View File

@@ -1,6 +1,6 @@
<!--
功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.1.0
版本v1.2.0
-->
<template>
<div class="field-grid">
@@ -71,6 +71,7 @@
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
allow-clear
@change="(value: unknown) => emit('change-relation', field, value)"
>
<a-option v-for="option in selectOptions(field)" :key="option.value" :value="option.value">
{{ option.label }}
@@ -93,14 +94,14 @@
:disabled="disabledSet.has(field.key) || relationRequiresParent(field)"
:multiple="field.type === 'identity-list'"
:placeholder="relationPlaceholder(field)"
:loading="relationLoading[field.relation ?? '']"
:loading="relationLoading[relationResource(field)]"
allow-clear
allow-search
@change="(value: unknown) => emit('change-relation', field, value)"
@search="(value: string) => emit('search-relation', field, value)"
>
<a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []"
v-for="option in relationOptions[relationResource(field)] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
>
@@ -131,7 +132,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { relationOptionLabel } from '@/api/resource-display';
import type { ResourceField } from '@/api/resources';
import { dynamicRelationResource, type ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { PlatformRole } from '@/api/platform';
import IdentityText from '@/components/IdentityText.vue';
@@ -188,24 +189,33 @@ 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(
const option = (props.relationOptions[relationResource(field)] ?? []).find(
(item) => String(item.identity ?? '') === identity,
);
return option ? relationOptionLabel(field, option) : identity;
}
/** 解析固定或由父字段动态决定的关系资源。 */
function relationResource(field: ResourceField) {
return dynamicRelationResource(field, model.value);
}
/** 父级尚未选择时禁用联动下拉,避免用户误以为可以跨合同选择。 */
function relationRequiresParent(field: ResourceField) {
const linkage = field.relationLinkage;
return Boolean(
linkage?.requiresParent &&
!String(model.value[linkage.parentKey] ?? '').trim(),
(linkage?.requiresParent &&
!String(model.value[linkage.parentKey] ?? '').trim()) ||
(field.dynamicRelation && !relationResource(field)),
);
}
/** 父级未选择时直接在控件内提示操作顺序,选择后恢复字段搜索提示。 */
function relationPlaceholder(field: ResourceField) {
if (!relationRequiresParent(field)) return field.placeholder;
if (field.dynamicRelation) {
return `请先选择${field.dynamicRelation.parentLabel}`;
}
return field.relationLinkage?.parentKey === 'gasorder_contract_identity'
? '请先选择配送合同'
: '请先选择上级数据';
@@ -214,6 +224,9 @@ function relationPlaceholder(field: ResourceField) {
/** 根据父级和候选状态给出明确的关联选择引导。 */
function relationEmptyText(field: ResourceField) {
if (relationRequiresParent(field)) {
if (field.dynamicRelation) {
return `请先选择${field.dynamicRelation.parentLabel}`;
}
const parentName =
field.relationLinkage?.parentKey === 'gasorder_contract_identity'
? '配送合同'

View File

@@ -1,10 +1,10 @@
/**
* 功能:协调资源表单中显式启用的多组父子关联下拉、搜索和保存前校验。
* 版本v1.1.0
* 版本v1.2.0
*/
import { Message } from '@arco-design/web-vue';
import { ref } from 'vue';
import type { ResourceField } from '@/api/resources';
import { dynamicRelationResource, type ResourceField } from '@/api/resources';
import {
linkedRelationFilters,
linkedRelationValidationMessage,
@@ -52,6 +52,25 @@ export function useResourceRelationLinkage(
);
}
/** 为动态关系字段补入当前父值对应的实际资源。 */
function resolvedField(field: ResourceField) {
const resource = dynamicRelationResource(field, form);
return resource && resource !== field.relation
? { ...field, relation: resource }
: field;
}
/** 加载由父字段类型决定的关系候选。 */
async function reloadDynamicOptions(field: ResourceField) {
const resolved = resolvedField(field);
if (!resolved.relation) return;
const identity = relationIdentity(form[field.key]);
relations.cancelSearch(resolved.relation);
await relations.loadField(resolved, '', {
preserveIdentities: identity ? [identity] : [],
});
}
function staffRequest() {
const staffIdentity = relationIdentity(
organizationStaffField ? form[organizationStaffField.key] : '',
@@ -147,14 +166,22 @@ export function useResourceRelationLinkage(
fields.filter(
(field) =>
!linkedChildKeys.has(field.key) &&
field.key !== organizationStaffField?.key,
field.key !== organizationStaffField?.key &&
!field.dynamicRelation,
),
);
await relations.ensureValues(
fields.filter((field) => !field.relationStrictFilter),
fields.filter(
(field) => !field.relationStrictFilter && !field.dynamicRelation,
),
form,
);
await Promise.all(active.map((item) => reloadChildOptions(item)));
await Promise.all(
fields
.filter((field) => field.dynamicRelation)
.map((field) => reloadDynamicOptions(field)),
);
await reloadStaffOptions();
clearInvalidStrictSelections();
initialized = true;
@@ -263,6 +290,20 @@ export function useResourceRelationLinkage(
async function change(field: ResourceField, value: unknown) {
if (!initialized) return;
const identity = relationIdentity(value);
const dynamicChildren = configuredFields.value.filter(
(item) => item.dynamicRelation?.parentKey === field.key,
);
for (const childField of dynamicChildren) {
const previousIdentity = relationIdentity(form[childField.key]);
form[childField.key] = '';
await reloadDynamicOptions(childField);
if (previousIdentity) {
Message.info(
childField.dynamicRelation?.parentChangeMessage ??
'关联类型已变更,请重新选择关联数据',
);
}
}
const parentLinkages = active.filter(
(item) => item.childField.relationLinkage?.parentKey === field.key,
);
@@ -282,22 +323,23 @@ export function useResourceRelationLinkage(
/** 搜索联动子项时始终保留对应父级过滤和当前选项。 */
function search(field: ResourceField, keyword: string) {
if (!field.relation) return;
const resolved = resolvedField(field);
if (!resolved.relation) return;
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);
relations.clear(resolved.relation);
return;
}
relations.searchField(field, keyword, childRequest(linkageItem));
relations.searchField(resolved, keyword, childRequest(linkageItem));
return;
}
if (field.key === organizationStaffField?.key) {
relations.searchField(field, keyword, staffRequest());
relations.searchField(resolved, keyword, staffRequest());
return;
}
relations.searchField(field, keyword);
relations.searchField(resolved, keyword);
}
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
@@ -331,11 +373,12 @@ export function useResourceRelationLinkage(
function unavailableMessage() {
for (const field of configuredFields.value) {
const linkage = field.relationLinkage;
const resource = dynamicRelationResource(field, form);
if (
!field.required ||
!field.relation ||
!resource ||
!field.relationEmptyText ||
relations.loading[field.relation]
relations.loading[resource]
) {
continue;
}
@@ -345,7 +388,7 @@ export function useResourceRelationLinkage(
) {
continue;
}
if ((relations.options[field.relation] ?? []).length === 0) {
if ((relations.options[resource] ?? []).length === 0) {
return field.relationEmptyText;
}
}