feat: 增加工作人员组织联动
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"resource-pages:check": "node scripts/check-resource-pages.mjs",
|
||||
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||
"staff-organization:check": "node scripts/check-staff-organization-linkage.mjs",
|
||||
"audit:platform": "node scripts/check-backend-contract.mjs",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 功能:验证工作人员气站与配送点联动策略及页面隔离配置。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transformWithOxc } from 'vite';
|
||||
|
||||
const policyURL = new URL(
|
||||
'../src/views/resource/resource-relation-linkage-policy.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
const source = await readFile(policyURL, 'utf8');
|
||||
const transformed = await transformWithOxc(source, policyURL.pathname);
|
||||
const moduleURL = `data:text/javascript;base64,${Buffer.from(transformed.code).toString('base64')}`;
|
||||
const {
|
||||
createRelationRequestVersionGuard,
|
||||
linkedRelationFilters,
|
||||
linkedRelationValidationMessage,
|
||||
optionParentIdentity,
|
||||
shouldClearLinkedOption,
|
||||
} = await import(moduleURL);
|
||||
|
||||
const gasA = 'gas-a';
|
||||
const gasB = 'gas-b';
|
||||
const deliveryA = { identity: 'delivery-a', gas_basic_identity: gasA };
|
||||
const platformDelivery = {
|
||||
identity: 'delivery-platform',
|
||||
gas_basic_identity: '',
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
optionParentIdentity(deliveryA, 'gas_basic_identity'),
|
||||
gasA,
|
||||
'普通配送点必须能回填所属气站',
|
||||
);
|
||||
assert.equal(
|
||||
optionParentIdentity(platformDelivery, 'gas_basic_identity'),
|
||||
'',
|
||||
'平台主管配送点必须保持气站为空',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldClearLinkedOption(gasA, deliveryA, 'gas_basic_identity'),
|
||||
false,
|
||||
'同一气站的配送点必须保留',
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearLinkedOption(gasB, deliveryA, 'gas_basic_identity'),
|
||||
true,
|
||||
'切换到其他气站必须清空原配送点',
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearLinkedOption('', deliveryA, 'gas_basic_identity'),
|
||||
true,
|
||||
'清空气站时必须清空普通配送点',
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearLinkedOption('', platformDelivery, 'gas_basic_identity'),
|
||||
false,
|
||||
'清空气站时允许保留平台主管配送点',
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearLinkedOption(gasA, platformDelivery, 'gas_basic_identity'),
|
||||
true,
|
||||
'选择气站时平台主管配送点不应作为其直属配送点保留',
|
||||
);
|
||||
assert.equal(
|
||||
shouldClearLinkedOption(gasA, undefined, 'gas_basic_identity'),
|
||||
false,
|
||||
'无法加载配送点记录时不得误清当前值',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
linkedRelationValidationMessage(
|
||||
gasB,
|
||||
'delivery-a',
|
||||
deliveryA,
|
||||
'gas_basic_identity',
|
||||
),
|
||||
/不属于所属气站/,
|
||||
);
|
||||
assert.match(
|
||||
linkedRelationValidationMessage(
|
||||
gasA,
|
||||
'delivery-platform',
|
||||
platformDelivery,
|
||||
'gas_basic_identity',
|
||||
),
|
||||
/平台主管配送点/,
|
||||
);
|
||||
assert.match(
|
||||
linkedRelationValidationMessage(
|
||||
'',
|
||||
'delivery-a',
|
||||
deliveryA,
|
||||
'gas_basic_identity',
|
||||
),
|
||||
/确认所属气站/,
|
||||
);
|
||||
assert.equal(
|
||||
linkedRelationValidationMessage(
|
||||
gasA,
|
||||
'delivery-a',
|
||||
deliveryA,
|
||||
'gas_basic_identity',
|
||||
),
|
||||
'',
|
||||
);
|
||||
assert.deepEqual(linkedRelationFilters(gasA, 'gas_basic_identities'), {
|
||||
gas_basic_identities: gasA,
|
||||
});
|
||||
assert.equal(
|
||||
linkedRelationFilters('', 'gas_basic_identities'),
|
||||
undefined,
|
||||
'未选气站时允许查询全部配送点,包括平台主管配送点',
|
||||
);
|
||||
|
||||
const requestGuard = createRelationRequestVersionGuard();
|
||||
const firstRequest = requestGuard.begin('/delivery_basic');
|
||||
const secondRequest = requestGuard.begin('/delivery_basic');
|
||||
assert.equal(requestGuard.isLatest('/delivery_basic', firstRequest), false);
|
||||
assert.equal(requestGuard.isLatest('/delivery_basic', secondRequest), true);
|
||||
|
||||
const resourcesSource = await readFile(
|
||||
new URL('../src/api/resources.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(resourcesSource.match(/relationLinkage:/g) ?? []).length,
|
||||
1,
|
||||
'联动配置只能显式启用于工作人员配送点字段',
|
||||
);
|
||||
assert.match(resourcesSource, /filterKey: 'gas_basic_identities'/);
|
||||
assert.match(resourcesSource, /label: '所属气站'/);
|
||||
assert.match(resourcesSource, /label: '所属配送点'/);
|
||||
|
||||
console.log('工作人员气站与配送点联动检查通过');
|
||||
@@ -18,6 +18,14 @@ export type ResourceFieldType =
|
||||
| 'textarea'
|
||||
| 'select';
|
||||
|
||||
/** 关联下拉的父子联动配置;未配置的资源继续保持独立选择。 */
|
||||
export type ResourceRelationLinkage = {
|
||||
parentKey: string;
|
||||
optionParentKey: string;
|
||||
filterKey: string;
|
||||
backfillParent?: boolean;
|
||||
};
|
||||
|
||||
export type ResourceField = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -32,6 +40,7 @@ export type ResourceField = {
|
||||
defaultValue?: string | number | boolean;
|
||||
readonlyOnCreate?: boolean;
|
||||
unknownValueLabel?: string;
|
||||
relationLinkage?: ResourceRelationLinkage;
|
||||
};
|
||||
|
||||
export type DetailAction = {
|
||||
@@ -339,7 +348,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('气站管理员'), relation('gas_basic_identity', '/gas_basic', true)]),
|
||||
{ ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('gas_basic_identity', { label: '气站', listLabel: '气站名称', type: 'identity', relation: '/gas_basic', displayRelationLabel: true, emptyText: '平台直属', placeholder: '请选择气站,留空表示平台直属' }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' },
|
||||
define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员'), relation('delivery_basic_identity', '/delivery_basic', true)]),
|
||||
{ ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' },
|
||||
{ ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' },
|
||||
define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),
|
||||
{ ...define('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')]),
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:required-keys="requiredKeys"
|
||||
:relation-options="relations.options"
|
||||
:relation-loading="relations.loading"
|
||||
@search-relation="relations.search"
|
||||
@search-relation="searchRelation"
|
||||
/>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -37,7 +37,7 @@ import { computed, reactive, ref, watch } from 'vue';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { platformApi } from '@/api/platform';
|
||||
import type { DetailAction } from '@/api/resources';
|
||||
import type { DetailAction, ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
import { useResourceRelations } from './use-resource-relations';
|
||||
@@ -70,6 +70,11 @@ const ownershipKeys = [
|
||||
'user_account_identity',
|
||||
];
|
||||
|
||||
/** 业务动作字段不启用记录页联动,只沿用普通关联搜索。 */
|
||||
function searchRelation(field: ResourceField, keyword: string) {
|
||||
relations.search(field.relation, keyword);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.action?.name] as const,
|
||||
async ([visible]) => {
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
:loading="relationLoading[field.relation ?? '']"
|
||||
allow-clear
|
||||
allow-search
|
||||
@search="(value: string) => emit('search-relation', field.relation, value)"
|
||||
@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 ?? ''] ?? []"
|
||||
@@ -122,7 +123,8 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'search-relation': [resource: string | undefined, keyword: string];
|
||||
'change-relation': [field: ResourceField, value: unknown];
|
||||
'search-relation': [field: ResourceField, keyword: string];
|
||||
}>();
|
||||
const model = defineModel<Record<string, any>>({ required: true });
|
||||
const disabledSet = computed(() => new Set(props.disabledKeys));
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
:relation-options="relations.options"
|
||||
:relation-loading="relations.loading"
|
||||
:role-options="roleOptions"
|
||||
@search-relation="relations.search"
|
||||
@change-relation="relationLinkage.change"
|
||||
@search-relation="relationLinkage.search"
|
||||
/>
|
||||
</a-form>
|
||||
<div class="form-actions">
|
||||
@@ -169,6 +170,7 @@ import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
import ResourceWalletSummary from './ResourceWalletSummary.vue';
|
||||
import { createResourceRecordNavigation } from './resource-record-navigation';
|
||||
import { useResourceAvatar } from './use-resource-avatar';
|
||||
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
|
||||
import { useResourceRelations } from './use-resource-relations';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
|
||||
@@ -198,6 +200,7 @@ const fieldOptions = reactive<
|
||||
Record<string, Array<{ label: string; value: string | number }>>
|
||||
>({});
|
||||
const relations = useResourceRelations();
|
||||
const relationLinkage = useResourceRelationLinkage(form, relations);
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const avatar = useResourceAvatar();
|
||||
@@ -357,9 +360,11 @@ async function initialize() {
|
||||
}
|
||||
|
||||
async function loadRelationsAndRoles() {
|
||||
await relations.preload(
|
||||
mode.value === 'detail' ? definition.value.fields : formFields.value,
|
||||
);
|
||||
if (mode.value === 'detail') {
|
||||
await relations.preload(definition.value.fields);
|
||||
} else {
|
||||
await relationLinkage.preload(formFields.value);
|
||||
}
|
||||
if (
|
||||
definition.value.fields.some((field) => field.key === 'platform_role_code')
|
||||
) {
|
||||
@@ -410,6 +415,11 @@ async function save() {
|
||||
Message.warning(validationError);
|
||||
return;
|
||||
}
|
||||
const linkageError = relationLinkage.validationMessage();
|
||||
if (linkageError) {
|
||||
Message.warning(linkageError);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildResourcePayload(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 功能:提供父子关联下拉的纯业务判断,避免页面层重复推断组织归属。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
|
||||
/** 将表单或关联记录中的标识规范化为空字符串或稳定字符串。 */
|
||||
export function relationIdentity(value: unknown) {
|
||||
return value == null ? '' : String(value).trim();
|
||||
}
|
||||
|
||||
/** 读取子选项记录声明的父级标识。 */
|
||||
export function optionParentIdentity(
|
||||
option: ResourceRow | undefined,
|
||||
optionParentKey: string,
|
||||
) {
|
||||
return option ? relationIdentity(option[optionParentKey]) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在已取得子选项记录且父子关系明确不兼容时返回 true。
|
||||
* 父级为空时只允许平台主管子项;父级有值时只允许其直属子项。
|
||||
*/
|
||||
export function shouldClearLinkedOption(
|
||||
parentIdentity: string,
|
||||
option: ResourceRow | undefined,
|
||||
optionParentKey: string,
|
||||
) {
|
||||
if (!option) return false;
|
||||
const optionParent = optionParentIdentity(option, optionParentKey);
|
||||
return parentIdentity ? optionParent !== parentIdentity : Boolean(optionParent);
|
||||
}
|
||||
|
||||
/** 返回父子组合的保存前提示;无法取得子选项时交由后端最终校验。 */
|
||||
export function linkedRelationValidationMessage(
|
||||
parentIdentity: string,
|
||||
childIdentity: string,
|
||||
option: ResourceRow | undefined,
|
||||
optionParentKey: string,
|
||||
) {
|
||||
if (!childIdentity || !option) return '';
|
||||
const optionParent = optionParentIdentity(option, optionParentKey);
|
||||
if (parentIdentity && !optionParent) {
|
||||
return '平台主管配送点不能与所属气站同时选择';
|
||||
}
|
||||
if (parentIdentity && optionParent !== parentIdentity) {
|
||||
return '所选配送点不属于所属气站,请重新选择';
|
||||
}
|
||||
if (!parentIdentity && optionParent) {
|
||||
return '所选配送点已有所属气站,请确认所属气站';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 根据当前父级生成服务端筛选条件;父级为空时保留全量可选范围。 */
|
||||
export function linkedRelationFilters(
|
||||
parentIdentity: string,
|
||||
filterKey: string,
|
||||
) {
|
||||
return parentIdentity ? { [filterKey]: parentIdentity } : undefined;
|
||||
}
|
||||
|
||||
/** 为同一关联资源生成递增版本号,用于识别并丢弃过期响应。 */
|
||||
export function createRelationRequestVersionGuard() {
|
||||
const versions = new Map<string, number>();
|
||||
return {
|
||||
begin(resource: string) {
|
||||
const version = (versions.get(resource) ?? 0) + 1;
|
||||
versions.set(resource, version);
|
||||
return version;
|
||||
},
|
||||
isLatest(resource: string, version: number) {
|
||||
return versions.get(resource) === version;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 功能:协调资源表单中显式启用的父子关联下拉、搜索和保存前校验。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import {
|
||||
linkedRelationFilters,
|
||||
linkedRelationValidationMessage,
|
||||
optionParentIdentity,
|
||||
relationIdentity,
|
||||
shouldClearLinkedOption,
|
||||
} from './resource-relation-linkage-policy';
|
||||
import type { ResourceRelations } from './use-resource-relations';
|
||||
|
||||
type ActiveLinkage = {
|
||||
childField: ResourceField;
|
||||
parentField?: ResourceField;
|
||||
};
|
||||
|
||||
export function useResourceRelationLinkage(
|
||||
form: Record<string, any>,
|
||||
relations: ResourceRelations,
|
||||
) {
|
||||
let active: ActiveLinkage | undefined;
|
||||
let initialized = false;
|
||||
|
||||
function findOption(resource: string, identity: string) {
|
||||
return (relations.options[resource] ?? []).find(
|
||||
(row) => relationIdentity(row.identity) === identity,
|
||||
);
|
||||
}
|
||||
|
||||
function configure(fields: ResourceField[]) {
|
||||
const childField = fields.find((field) => field.relationLinkage);
|
||||
if (!childField?.relationLinkage) {
|
||||
active = undefined;
|
||||
return;
|
||||
}
|
||||
active = {
|
||||
childField,
|
||||
parentField: fields.find(
|
||||
(field) => field.key === childField.relationLinkage?.parentKey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function childRequest() {
|
||||
if (!active?.childField.relationLinkage) return {};
|
||||
const linkage = active.childField.relationLinkage;
|
||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||
const childIdentity = relationIdentity(form[active.childField.key]);
|
||||
return {
|
||||
filters: linkedRelationFilters(parentIdentity, linkage.filterKey),
|
||||
preserveIdentities: childIdentity ? [childIdentity] : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadChildOptions() {
|
||||
const resource = active?.childField.relation;
|
||||
if (!resource) return;
|
||||
relations.cancelSearch(resource);
|
||||
await relations.load(resource, '', childRequest());
|
||||
}
|
||||
|
||||
/** 预加载普通关联项,并单独按父级条件加载启用联动的子选项。 */
|
||||
async function preload(fields: ResourceField[]) {
|
||||
initialized = false;
|
||||
configure(fields);
|
||||
if (!active?.childField.relationLinkage || !active.childField.relation) {
|
||||
await relations.preload(fields);
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await relations.preload(
|
||||
fields.filter((field) => field.key !== active?.childField.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);
|
||||
}
|
||||
await reloadChildOptions();
|
||||
initialized = true;
|
||||
|
||||
const warning = validationMessage();
|
||||
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]);
|
||||
if (childIdentity) {
|
||||
const option =
|
||||
findOption(active.childField.relation, childIdentity) ??
|
||||
(await relations.ensure(active.childField.relation, childIdentity));
|
||||
if (
|
||||
relationIdentity(form[linkage.parentKey]) !== parentIdentity ||
|
||||
relationIdentity(form[active.childField.key]) !== childIdentity
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
shouldClearLinkedOption(
|
||||
parentIdentity,
|
||||
option,
|
||||
linkage.optionParentKey,
|
||||
)
|
||||
) {
|
||||
form[active.childField.key] = '';
|
||||
Message.info('所属气站已变更,请重新选择配送点');
|
||||
}
|
||||
}
|
||||
await reloadChildOptions();
|
||||
}
|
||||
|
||||
async function handleChildChange(childIdentity: string) {
|
||||
if (!active?.childField.relationLinkage || !active.childField.relation)
|
||||
return;
|
||||
const linkage = active.childField.relationLinkage;
|
||||
if (!childIdentity) return;
|
||||
const option =
|
||||
findOption(active.childField.relation, childIdentity) ??
|
||||
(await relations.ensure(active.childField.relation, childIdentity));
|
||||
if (
|
||||
!option ||
|
||||
relationIdentity(form[active.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 (currentParent !== nextParent) {
|
||||
form[linkage.parentKey] = nextParent;
|
||||
if (!nextParent && currentParent) {
|
||||
Message.info('平台主管配送点不隶属于气站,已清空所属气站');
|
||||
}
|
||||
}
|
||||
}
|
||||
await reloadChildOptions();
|
||||
}
|
||||
|
||||
/** 响应用户主动修改关联字段;初始化回显不会触发自动清理。 */
|
||||
async function change(field: ResourceField, value: unknown) {
|
||||
if (!initialized || !active?.childField.relationLinkage) return;
|
||||
const identity = relationIdentity(value);
|
||||
if (field.key === active.childField.relationLinkage.parentKey) {
|
||||
await handleParentChange(identity);
|
||||
} else if (field.key === active.childField.key) {
|
||||
await handleChildChange(identity);
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索联动子项时始终保留父级过滤和当前选项。 */
|
||||
function search(field: ResourceField, keyword: string) {
|
||||
if (!field.relation) return;
|
||||
if (field.key === active?.childField.key) {
|
||||
relations.search(field.relation, keyword, childRequest());
|
||||
return;
|
||||
}
|
||||
relations.search(field.relation, keyword);
|
||||
}
|
||||
|
||||
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
|
||||
function validationMessage() {
|
||||
if (!active?.childField.relationLinkage || !active.childField.relation)
|
||||
return '';
|
||||
const linkage = active.childField.relationLinkage;
|
||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||
const childIdentity = relationIdentity(form[active.childField.key]);
|
||||
const option = findOption(active.childField.relation, childIdentity);
|
||||
return linkedRelationValidationMessage(
|
||||
parentIdentity,
|
||||
childIdentity,
|
||||
option,
|
||||
linkage.optionParentKey,
|
||||
);
|
||||
}
|
||||
|
||||
return { preload, change, search, validationMessage };
|
||||
}
|
||||
@@ -3,29 +3,95 @@
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { reactive } from 'vue';
|
||||
import { onBeforeUnmount, reactive } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
|
||||
|
||||
export type ResourceRelationLoadOptions = {
|
||||
filters?: Record<string, string>;
|
||||
preserveIdentities?: string[];
|
||||
};
|
||||
|
||||
export function useResourceRelations() {
|
||||
const options = reactive<Record<string, ResourceRow[]>>({});
|
||||
const loading = reactive<Record<string, boolean>>({});
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const requestGuard = createRelationRequestVersionGuard();
|
||||
|
||||
/** 加载一类关联资源,工作人员关系默认只选择配送人员。 */
|
||||
async function load(resource: string, keyword = '') {
|
||||
/** 合并需要保留的当前选项,搜索和筛选时不让下拉框退回显示原始标识。 */
|
||||
function mergePreserved(
|
||||
resource: string,
|
||||
rows: ResourceRow[],
|
||||
identities: string[] = [],
|
||||
) {
|
||||
const merged = [...rows];
|
||||
const seen = new Set(rows.map((row) => String(row.identity ?? '')));
|
||||
for (const identity of identities.filter(Boolean)) {
|
||||
if (seen.has(identity)) continue;
|
||||
const current = (options[resource] ?? []).find(
|
||||
(row) => String(row.identity ?? '') === identity,
|
||||
);
|
||||
if (current) {
|
||||
merged.push(current);
|
||||
seen.add(identity);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 加载一类关联资源,并丢弃已经过期的搜索或筛选响应。 */
|
||||
async function load(
|
||||
resource: string,
|
||||
keyword = '',
|
||||
request: ResourceRelationLoadOptions = {},
|
||||
) {
|
||||
const version = requestGuard.begin(resource);
|
||||
loading[resource] = true;
|
||||
try {
|
||||
const filters: Record<string, string> = keyword ? { keyword } : {};
|
||||
const filters: Record<string, string> = {
|
||||
...(request.filters ?? {}),
|
||||
...(keyword ? { keyword } : {}),
|
||||
};
|
||||
if (resource === '/staff_account') filters.role_code = 'delivery';
|
||||
options[resource] = (
|
||||
const rows = (
|
||||
await resourceApi.list<ResourceRow>(resource, 1, 100, filters)
|
||||
).list;
|
||||
if (!requestGuard.isLatest(resource, version)) return;
|
||||
options[resource] = mergePreserved(
|
||||
resource,
|
||||
rows,
|
||||
request.preserveIdentities,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!requestGuard.isLatest(resource, version)) return;
|
||||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
loading[resource] = false;
|
||||
if (requestGuard.isLatest(resource, version)) {
|
||||
loading[resource] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */
|
||||
async function ensure(resource: string, identity: string) {
|
||||
if (!identity) return undefined;
|
||||
const current = (options[resource] ?? []).find(
|
||||
(row) => String(row.identity ?? '') === identity,
|
||||
);
|
||||
if (current) return current;
|
||||
try {
|
||||
const row = await resourceApi.detail<ResourceRow>(resource, identity);
|
||||
const resolved = (options[resource] ?? []).find(
|
||||
(item) => String(item.identity ?? '') === identity,
|
||||
);
|
||||
if (resolved) return resolved;
|
||||
options[resource] = [...(options[resource] ?? []), row];
|
||||
return row;
|
||||
} catch (error) {
|
||||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +106,42 @@ export function useResourceRelations() {
|
||||
}
|
||||
|
||||
/** 对远程关系选项进行防抖搜索。 */
|
||||
function search(resource: string | undefined, keyword: string) {
|
||||
function search(
|
||||
resource: string | undefined,
|
||||
keyword: string,
|
||||
request: ResourceRelationLoadOptions = {},
|
||||
) {
|
||||
if (!resource) return;
|
||||
const timer = timers.get(resource);
|
||||
if (timer) clearTimeout(timer);
|
||||
cancelSearch(resource);
|
||||
timers.set(
|
||||
resource,
|
||||
setTimeout(() => load(resource, keyword.trim()), 250),
|
||||
setTimeout(() => {
|
||||
timers.delete(resource);
|
||||
void load(resource, keyword.trim(), request);
|
||||
}, 250),
|
||||
);
|
||||
}
|
||||
|
||||
return { options, loading, load, preload, search };
|
||||
/** 取消关联资源尚未触发的防抖搜索。 */
|
||||
function cancelSearch(resource: string) {
|
||||
const timer = timers.get(resource);
|
||||
if (timer) clearTimeout(timer);
|
||||
timers.delete(resource);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const resource of timers.keys()) cancelSearch(resource);
|
||||
});
|
||||
|
||||
return {
|
||||
options,
|
||||
loading,
|
||||
load,
|
||||
ensure,
|
||||
preload,
|
||||
search,
|
||||
cancelSearch,
|
||||
};
|
||||
}
|
||||
|
||||
export type ResourceRelations = ReturnType<typeof useResourceRelations>;
|
||||
|
||||
Reference in New Issue
Block a user