功能:完善用户服务关系展示与组织联动
统一平台、气站和配送点后台的服务关系业务名称展示。 新增气站、配送点、服务人员三级联动及暂未分配语义。 后端补充组织归属、人员角色和启用状态的严格校验。 补充前后端测试、项目文档、操作日志及本机日志忽略规则。
This commit is contained in:
@@ -19,6 +19,8 @@ const {
|
||||
linkedRelationValidationMessage,
|
||||
optionParentIdentity,
|
||||
shouldClearLinkedOption,
|
||||
staffOrganizationFilters,
|
||||
staffOrganizationValidationMessage,
|
||||
} = await import(moduleURL);
|
||||
|
||||
const gasA = 'gas-a';
|
||||
@@ -34,6 +36,36 @@ assert.equal(
|
||||
gasA,
|
||||
'普通配送点必须能回填所属气站',
|
||||
);
|
||||
|
||||
assert.deepEqual(staffOrganizationFilters(gasA, 'delivery-a'), {
|
||||
gas_basic_identities: gasA,
|
||||
delivery_basic_identities: 'delivery-a',
|
||||
});
|
||||
assert.deepEqual(staffOrganizationFilters('', ''), {
|
||||
gas_basic_unassigned: '1',
|
||||
delivery_basic_unassigned: '1',
|
||||
});
|
||||
const staffA = {
|
||||
identity: 'staff-a',
|
||||
gas_basic_identity: gasA,
|
||||
delivery_basic_identity: 'delivery-a',
|
||||
};
|
||||
assert.equal(
|
||||
staffOrganizationValidationMessage(gasA, 'delivery-a', 'staff-a', staffA),
|
||||
'',
|
||||
);
|
||||
assert.match(
|
||||
staffOrganizationValidationMessage(gasB, 'delivery-a', 'staff-a', staffA),
|
||||
/不属于当前气站/,
|
||||
);
|
||||
assert.match(
|
||||
staffOrganizationValidationMessage(gasA, '', 'staff-a', staffA),
|
||||
/不属于当前配送点/,
|
||||
);
|
||||
assert.match(
|
||||
staffOrganizationValidationMessage(gasA, 'delivery-a', 'staff-a', undefined),
|
||||
/无法确认/,
|
||||
);
|
||||
assert.equal(
|
||||
optionParentIdentity(platformDelivery, 'gas_basic_identity'),
|
||||
'',
|
||||
@@ -128,10 +160,11 @@ const resourcesSource = await readFile(
|
||||
);
|
||||
assert.equal(
|
||||
(resourcesSource.match(/relationLinkage:/g) ?? []).length,
|
||||
1,
|
||||
'联动配置只能显式启用于工作人员配送点字段',
|
||||
2,
|
||||
'联动配置必须显式启用于工作人员和用户服务关系的配送点字段',
|
||||
);
|
||||
assert.match(resourcesSource, /filterKey: 'gas_basic_identities'/);
|
||||
assert.match(resourcesSource, /organizationScoped: true/);
|
||||
assert.match(resourcesSource, /label: '所属气站'/);
|
||||
assert.match(resourcesSource, /label: '所属配送点'/);
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ const resourcesSource = await readFile(
|
||||
new URL('../src/api/resources.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(resourcesSource, /organizationScoped: true/);
|
||||
assert.equal(
|
||||
(resourcesSource.match(/staffRelation:/g) ?? []).length,
|
||||
3,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type StaffRelationPolicy = {
|
||||
workStatus?: 'on_duty' | 'off_duty';
|
||||
lockPrefilled?: boolean;
|
||||
showIdentityCopy?: boolean;
|
||||
organizationScoped?: boolean;
|
||||
};
|
||||
|
||||
export type StaffRelationContext = {
|
||||
|
||||
@@ -44,6 +44,8 @@ export type ResourceField = {
|
||||
displayPrecision?: number;
|
||||
emptyText?: string;
|
||||
placeholder?: string;
|
||||
showIdentityCopy?: boolean;
|
||||
readonlyRelationText?: boolean;
|
||||
options?: Array<{ label: string; value: string | number }>;
|
||||
defaultValue?: string | number | boolean;
|
||||
readonlyOnCreate?: boolean;
|
||||
@@ -368,7 +370,54 @@ export const resources: ResourceUiDefinition[] = [
|
||||
define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: true } }), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),
|
||||
{ ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' },
|
||||
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true, { listLabel: '用户账户', listRelationNameOnly: true }), f('address', { required: true, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('is_default')]),
|
||||
define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account', false, { staffRelation: { roles: ['installer', 'delivery', 'operations'], enabledOnly: true } })]),
|
||||
define('user_service_relation', '用户服务关系', 'writable', [
|
||||
relation('user_account_identity', '/user_account', true, {
|
||||
label: '用户账户',
|
||||
listLabel: '用户账户',
|
||||
listRelationNameOnly: true,
|
||||
displayRelationLabel: true,
|
||||
showIdentityCopy: true,
|
||||
readonlyRelationText: true,
|
||||
}),
|
||||
relation('gas_basic_identity', '/gas_basic', false, {
|
||||
label: '所属气站',
|
||||
listLabel: '所属气站',
|
||||
listRelationNameOnly: true,
|
||||
displayRelationLabel: true,
|
||||
emptyText: '暂未分配',
|
||||
placeholder: '请选择所属气站',
|
||||
showIdentityCopy: true,
|
||||
}),
|
||||
relation('delivery_basic_identity', '/delivery_basic', false, {
|
||||
label: '所属配送点',
|
||||
listLabel: '所属配送点',
|
||||
listRelationNameOnly: true,
|
||||
displayRelationLabel: true,
|
||||
emptyText: '暂未分配',
|
||||
placeholder: '请选择所属配送点',
|
||||
showIdentityCopy: true,
|
||||
relationLinkage: {
|
||||
parentKey: 'gas_basic_identity',
|
||||
optionParentKey: 'gas_basic_identity',
|
||||
filterKey: 'gas_basic_identities',
|
||||
backfillParent: true,
|
||||
},
|
||||
}),
|
||||
relation('staff_account_identity', '/staff_account', false, {
|
||||
label: '服务人员',
|
||||
listLabel: '服务人员',
|
||||
listRelationNameOnly: true,
|
||||
displayRelationLabel: true,
|
||||
emptyText: '暂未分配',
|
||||
placeholder: '请选择服务人员',
|
||||
showIdentityCopy: true,
|
||||
staffRelation: {
|
||||
roles: ['installer', 'delivery', 'operations'],
|
||||
enabledOnly: true,
|
||||
organizationScoped: true,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
|
||||
define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address'), f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), f('remark')]),
|
||||
define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]),
|
||||
|
||||
@@ -121,7 +121,9 @@ const entries = computed<DetailEntry[]>(() => {
|
||||
value: display,
|
||||
objectValue,
|
||||
identityValue:
|
||||
field?.staffRelation?.showIdentityCopy && value ? String(value) : '',
|
||||
(field?.showIdentityCopy || field?.staffRelation?.showIdentityCopy) && value
|
||||
? String(value)
|
||||
: '',
|
||||
wide:
|
||||
objectValue ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
|
||||
|
||||
@@ -68,7 +68,18 @@
|
||||
</a-option>
|
||||
</a-select>
|
||||
<div v-else-if="field.type === 'identity' || field.type === 'identity-list'" class="relation-control">
|
||||
<div
|
||||
v-if="disabledSet.has(field.key) && field.readonlyRelationText"
|
||||
class="readonly-relation"
|
||||
>
|
||||
<span class="readonly-relation-name">{{ readonlyRelationLabel(field) }}</span>
|
||||
<IdentityText
|
||||
v-if="(field.showIdentityCopy || field.staffRelation?.showIdentityCopy) && model[field.key]"
|
||||
:value="String(model[field.key])"
|
||||
/>
|
||||
</div>
|
||||
<a-select
|
||||
v-else
|
||||
v-model="model[field.key]"
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:multiple="field.type === 'identity-list'"
|
||||
@@ -88,7 +99,7 @@
|
||||
</a-option>
|
||||
</a-select>
|
||||
<div
|
||||
v-if="disabledSet.has(field.key) && field.staffRelation?.showIdentityCopy && model[field.key]"
|
||||
v-if="disabledSet.has(field.key) && (field.showIdentityCopy || field.staffRelation?.showIdentityCopy) && !field.readonlyRelationText && model[field.key]"
|
||||
class="relation-identity"
|
||||
>
|
||||
<span>人员标识</span>
|
||||
@@ -146,6 +157,16 @@ function isWide(field: ResourceField) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 返回只读关系字段的业务名称,未加载到历史记录时保留唯一标识以避免误导。 */
|
||||
function readonlyRelationLabel(field: ResourceField) {
|
||||
const identity = String(model.value[field.key] ?? '');
|
||||
if (!identity) return field.emptyText ?? '暂未分配';
|
||||
const option = (props.relationOptions[field.relation ?? ''] ?? []).find(
|
||||
(item) => String(item.identity ?? '') === identity,
|
||||
);
|
||||
return option ? relationOptionLabel(field, option) : identity;
|
||||
}
|
||||
|
||||
/** 为历史未知编码补充只读选项,避免下拉框退回显示原始值。 */
|
||||
function selectOptions(field: ResourceField) {
|
||||
const options = [...(field.options ?? [])];
|
||||
@@ -195,6 +216,19 @@ function selectOptions(field: ResourceField) {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.readonly-relation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 5px 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: var(--border-radius-small);
|
||||
}
|
||||
.readonly-relation-name {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -305,7 +305,11 @@ const visibleActions = computed(() =>
|
||||
),
|
||||
),
|
||||
);
|
||||
const modeLabel = computed(() => recordPageModeLabel(mode.value));
|
||||
const modeLabel = computed(() =>
|
||||
definition.value.name === 'user_service_relation'
|
||||
? `${recordPageModeLabel(mode.value)}${definition.value.title}`
|
||||
: recordPageModeLabel(mode.value),
|
||||
);
|
||||
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
|
||||
|
||||
function snapshot() {
|
||||
|
||||
@@ -60,6 +60,42 @@ export function linkedRelationFilters(
|
||||
return parentIdentity ? { [filterKey]: parentIdentity } : undefined;
|
||||
}
|
||||
|
||||
/** 为服务人员关系生成精确组织范围;空组织使用显式未分配条件,避免返回其他组织人员。 */
|
||||
export function staffOrganizationFilters(
|
||||
gasIdentity: string,
|
||||
deliveryIdentity: string,
|
||||
) {
|
||||
return {
|
||||
...(gasIdentity
|
||||
? { gas_basic_identities: gasIdentity }
|
||||
: { gas_basic_unassigned: '1' }),
|
||||
...(deliveryIdentity
|
||||
? { delivery_basic_identities: deliveryIdentity }
|
||||
: { delivery_basic_unassigned: '1' }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 校验服务人员记录与当前气站、配送点完全一致,空字符串代表可以提交。 */
|
||||
export function staffOrganizationValidationMessage(
|
||||
gasIdentity: string,
|
||||
deliveryIdentity: string,
|
||||
staffIdentity: string,
|
||||
option: ResourceRow | undefined,
|
||||
) {
|
||||
if (!staffIdentity) return '';
|
||||
if (!option) return '无法确认所选服务人员的组织归属,请重新选择';
|
||||
if (optionParentIdentity(option, 'gas_basic_identity') !== gasIdentity) {
|
||||
return '所选服务人员不属于当前气站,请重新选择';
|
||||
}
|
||||
if (
|
||||
optionParentIdentity(option, 'delivery_basic_identity') !==
|
||||
deliveryIdentity
|
||||
) {
|
||||
return '所选服务人员不属于当前配送点,请重新选择';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 为同一关联资源生成递增版本号,用于识别并丢弃过期响应。 */
|
||||
export function createRelationRequestVersionGuard() {
|
||||
const versions = new Map<string, number>();
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
optionParentIdentity,
|
||||
relationIdentity,
|
||||
shouldClearLinkedOption,
|
||||
staffOrganizationFilters,
|
||||
staffOrganizationValidationMessage,
|
||||
} from './resource-relation-linkage-policy';
|
||||
import type { ResourceRelations } from './use-resource-relations';
|
||||
|
||||
@@ -23,6 +25,7 @@ export function useResourceRelationLinkage(
|
||||
relations: ResourceRelations,
|
||||
) {
|
||||
let active: ActiveLinkage | undefined;
|
||||
let organizationStaffField: ResourceField | undefined;
|
||||
let initialized = false;
|
||||
|
||||
function findOption(resource: string, identity: string) {
|
||||
@@ -33,6 +36,9 @@ export function useResourceRelationLinkage(
|
||||
|
||||
function configure(fields: ResourceField[]) {
|
||||
const childField = fields.find((field) => field.relationLinkage);
|
||||
organizationStaffField = fields.find(
|
||||
(field) => field.staffRelation?.organizationScoped,
|
||||
);
|
||||
if (!childField?.relationLinkage) {
|
||||
active = undefined;
|
||||
return;
|
||||
@@ -45,6 +51,49 @@ export function useResourceRelationLinkage(
|
||||
};
|
||||
}
|
||||
|
||||
function staffRequest() {
|
||||
const staffIdentity = relationIdentity(
|
||||
organizationStaffField ? form[organizationStaffField.key] : '',
|
||||
);
|
||||
return {
|
||||
filters: staffOrganizationFilters(
|
||||
relationIdentity(form.gas_basic_identity),
|
||||
relationIdentity(form.delivery_basic_identity),
|
||||
),
|
||||
preserveIdentities: staffIdentity ? [staffIdentity] : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadStaffOptions() {
|
||||
const field = organizationStaffField;
|
||||
if (!field?.relation) return;
|
||||
relations.cancelSearch(field.relation);
|
||||
await relations.loadField(field, '', staffRequest());
|
||||
}
|
||||
|
||||
/** 组织变化后清理不再匹配的服务人员,并刷新精确范围选项。 */
|
||||
async function synchronizeStaff() {
|
||||
const field = organizationStaffField;
|
||||
if (!field?.relation) return;
|
||||
const staffIdentity = relationIdentity(form[field.key]);
|
||||
if (staffIdentity) {
|
||||
const option =
|
||||
findOption(field.relation, staffIdentity) ??
|
||||
(await relations.ensure(field.relation, staffIdentity));
|
||||
const warning = staffOrganizationValidationMessage(
|
||||
relationIdentity(form.gas_basic_identity),
|
||||
relationIdentity(form.delivery_basic_identity),
|
||||
staffIdentity,
|
||||
option,
|
||||
);
|
||||
if (warning && relationIdentity(form[field.key]) === staffIdentity) {
|
||||
form[field.key] = '';
|
||||
Message.info(`${warning},已清空原服务人员`);
|
||||
}
|
||||
}
|
||||
await reloadStaffOptions();
|
||||
}
|
||||
|
||||
function childRequest() {
|
||||
if (!active?.childField.relationLinkage) return {};
|
||||
const linkage = active.childField.relationLinkage;
|
||||
@@ -69,14 +118,21 @@ export function useResourceRelationLinkage(
|
||||
initialized = false;
|
||||
configure(fields);
|
||||
if (!active?.childField.relationLinkage || !active.childField.relation) {
|
||||
await relations.preload(fields);
|
||||
await relations.preload(
|
||||
fields.filter((field) => field.key !== organizationStaffField?.key),
|
||||
);
|
||||
await relations.ensureValues(fields, form);
|
||||
await reloadStaffOptions();
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await relations.preload(
|
||||
fields.filter((field) => field.key !== active?.childField.key),
|
||||
fields.filter(
|
||||
(field) =>
|
||||
field.key !== active?.childField.key &&
|
||||
field.key !== organizationStaffField?.key,
|
||||
),
|
||||
);
|
||||
const linkage = active.childField.relationLinkage;
|
||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||
@@ -87,7 +143,14 @@ export function useResourceRelationLinkage(
|
||||
if (childIdentity) {
|
||||
await relations.ensure(active.childField.relation, childIdentity);
|
||||
}
|
||||
const staffIdentity = relationIdentity(
|
||||
organizationStaffField ? form[organizationStaffField.key] : '',
|
||||
);
|
||||
if (staffIdentity && organizationStaffField?.relation) {
|
||||
await relations.ensure(organizationStaffField.relation, staffIdentity);
|
||||
}
|
||||
await reloadChildOptions();
|
||||
await reloadStaffOptions();
|
||||
initialized = true;
|
||||
|
||||
const warning = validationMessage();
|
||||
@@ -158,8 +221,12 @@ export function useResourceRelationLinkage(
|
||||
const identity = relationIdentity(value);
|
||||
if (field.key === active.childField.relationLinkage.parentKey) {
|
||||
await handleParentChange(identity);
|
||||
await synchronizeStaff();
|
||||
} else if (field.key === active.childField.key) {
|
||||
await handleChildChange(identity);
|
||||
await synchronizeStaff();
|
||||
} else if (field.key === organizationStaffField?.key) {
|
||||
await synchronizeStaff();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +237,10 @@ export function useResourceRelationLinkage(
|
||||
relations.searchField(field, keyword, childRequest());
|
||||
return;
|
||||
}
|
||||
if (field.key === organizationStaffField?.key) {
|
||||
relations.searchField(field, keyword, staffRequest());
|
||||
return;
|
||||
}
|
||||
relations.searchField(field, keyword);
|
||||
}
|
||||
|
||||
@@ -181,12 +252,22 @@ export function useResourceRelationLinkage(
|
||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||
const childIdentity = relationIdentity(form[active.childField.key]);
|
||||
const option = findOption(active.childField.relation, childIdentity);
|
||||
return linkedRelationValidationMessage(
|
||||
const linkedMessage = linkedRelationValidationMessage(
|
||||
parentIdentity,
|
||||
childIdentity,
|
||||
option,
|
||||
linkage.optionParentKey,
|
||||
);
|
||||
if (linkedMessage) return linkedMessage;
|
||||
const staffField = organizationStaffField;
|
||||
if (!staffField?.relation) return '';
|
||||
const staffIdentity = relationIdentity(form[staffField.key]);
|
||||
return staffOrganizationValidationMessage(
|
||||
parentIdentity,
|
||||
childIdentity,
|
||||
staffIdentity,
|
||||
findOption(staffField.relation, staffIdentity),
|
||||
);
|
||||
}
|
||||
|
||||
return { preload, change, search, validationMessage };
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
v-else-if="field.listRelationNameOnly && identityFieldValue(field, record)"
|
||||
:name="relationListName(field, record, relations.options)"
|
||||
:identity="identityFieldValue(field, record)"
|
||||
:identity-label="field.label"
|
||||
/>
|
||||
<IdentityText
|
||||
v-else-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
|
||||
@@ -284,6 +285,10 @@ async function load() {
|
||||
);
|
||||
list.value = result.list;
|
||||
total.value = result.total;
|
||||
// 补载当前页实际引用的关系,避免关联记录超过首批选项时退回显示裸标识。
|
||||
await Promise.all(
|
||||
result.list.map((row) => relations.ensureValues(displayFields.value, row)),
|
||||
);
|
||||
await extras.loadExtras();
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!-- 功能描述:列表以关系名称为主展示,并提供完整唯一标识的悬停查看与复制。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<div class="relation-name-text">
|
||||
<a-tooltip :content="`用户唯一标识:${identity}`">
|
||||
<a-tooltip :content="`${identityLabel}:${identity}`">
|
||||
<span class="relation-name">{{ displayName }}</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="复制用户唯一标识">
|
||||
<a-tooltip :content="`复制${identityLabel}`">
|
||||
<button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
:aria-label="`复制用户唯一标识 ${identity}`"
|
||||
:aria-label="`复制${identityLabel} ${identity}`"
|
||||
@click.stop="copyIdentity"
|
||||
>
|
||||
<icon-copy />
|
||||
@@ -22,14 +22,18 @@ import { Message } from '@arco-design/web-vue';
|
||||
import { IconCopy } from '@arco-design/web-vue/es/icon';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{ name: string; identity: string }>();
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
identity: string;
|
||||
identityLabel?: string;
|
||||
}>();
|
||||
const displayName = computed(() => props.name || props.identity);
|
||||
|
||||
/** 复制完整用户唯一标识,并给出明确操作反馈。 */
|
||||
/** 复制当前关联记录的完整唯一标识,并给出明确操作反馈。 */
|
||||
async function copyIdentity() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.identity);
|
||||
Message.success('用户唯一标识已复制');
|
||||
Message.success(`${props.identityLabel ?? '唯一标识'}已复制`);
|
||||
} catch {
|
||||
Message.error('复制失败,请从悬浮提示中复制');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user