修复工作人员资质关联与角色过滤

按安装、配送、运维菜单显式过滤工作人员,锁定资质所有者并使用姓名和角色回显。

扩展工作人员状态与多角色查询,补充权限校验、回归测试、操作日志和项目文档。
This commit is contained in:
czl231
2026-08-11 19:15:53 +08:00
parent 4b1f045806
commit 74c5e34275
20 changed files with 976 additions and 100 deletions

View File

@@ -17,6 +17,7 @@
"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",
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
"audit:platform": "node scripts/check-backend-contract.mjs",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",

View File

@@ -0,0 +1,122 @@
/**
* 功能:验证工作人员关联字段过滤、资质来源校验和路由上下文传递契约。
* 版本v1.0.0
*/
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { transformWithOxc } from 'vite';
const policyURL = new URL(
'../src/api/resource-staff-relation.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 {
credentialOwnerValidationMessage,
resolveCredentialStaffRole,
staffRelationFilters,
staffRoleFromReturnPath,
} = await import(moduleURL);
assert.deepEqual(
staffRelationFilters(
{
relation: '/staff_account',
staffRelation: {
roles: ['delivery'],
enabledOnly: true,
workStatus: 'on_duty',
},
},
{},
),
{ role_code: 'delivery', status: '1', work_status: 'on_duty' },
'订单分配必须只查询启用且在岗的配送人员',
);
assert.deepEqual(
staffRelationFilters(
{
relation: '/staff_account',
staffRelation: {
roles: ['installer', 'delivery', 'operations'],
enabledOnly: true,
},
},
{},
),
{ role_codes: 'installer,delivery,operations', status: '1' },
'用户服务关系必须查询全部角色中的启用人员',
);
assert.deepEqual(
staffRelationFilters(
{
relation: '/staff_account',
staffRelation: { roles: 'context' },
},
{ staffType: 'installer' },
),
{ role_code: 'installer' },
'人员资质必须继承当前工作人员菜单角色',
);
assert.throws(
() => staffRelationFilters({ relation: '/staff_account' }, {}),
/缺少显式过滤策略/,
'未声明策略的工作人员关系必须立即失败',
);
assert.equal(
staffRoleFromReturnPath(
'/staff/credential?return_to=%2Fstaff%2Foperations%3Fpage%3D2',
),
'operations',
'返回路径必须能回退解析工作人员角色',
);
assert.equal(
resolveCredentialStaffRole(
'installer',
'/staff/credential?staff_type=delivery',
).error,
'工作人员角色与来源菜单不一致',
);
assert.equal(
credentialOwnerValidationMessage('staff-a', 'installer', {
identity: 'staff-a',
role_code: 'delivery',
status: 1,
}),
'工作人员角色已变化,请从当前角色菜单重新进入',
);
const resourcesSource = await readFile(
new URL('../src/api/resources.ts', import.meta.url),
'utf8',
);
assert.equal(
(resourcesSource.match(/staffRelation:/g) ?? []).length,
3,
'三个工作人员关联场景都必须显式声明策略',
);
const relationsSource = await readFile(
new URL('../src/views/resource/use-resource-relations.ts', import.meta.url),
'utf8',
);
assert.doesNotMatch(
relationsSource,
/resource === ['"]\/staff_account['"].*delivery/,
'关系加载器不得恢复全局配送人员特例',
);
const listSource = await readFile(
new URL('../src/views/shared/CrudListPage.vue', import.meta.url),
'utf8',
);
assert.match(listSource, /staff_type: staffType\.value/);
assert.match(listSource, /'staff_type'/);
console.log('工作人员关联与资质上下文检查通过');

View File

@@ -3,6 +3,7 @@
* 版本v1.1.0
*/
import dayjs from 'dayjs';
import { staffRoleLabel } from './resource-staff-relation';
import type { ResourceField, ResourceUiDefinition } from './resources';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
@@ -110,6 +111,14 @@ export function optionLabel(option: ResourceRow) {
);
}
/** 工作人员关系额外展示角色,其他关系保持原有可读名称。 */
export function relationOptionLabel(field: ResourceField, option: ResourceRow) {
const label = optionLabel(option);
return field.relation === '/staff_account'
? `${label}${staffRoleLabel(option.role_code)}`
: label;
}
function relationLabel(
field: ResourceField,
identity: string,
@@ -119,6 +128,9 @@ function relationLabel(
(option) => String(option.identity) === identity,
);
if (!match) return identity;
if (field.relation === '/staff_account') {
return relationOptionLabel(field, match);
}
return field.displayRelationLabel
? optionLabel(match)
: `${optionLabel(match)} · ${identity}`;
@@ -161,7 +173,10 @@ export function displayResourceField(
field: ResourceField,
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
fieldOptions: Record<string, Array<{ label: string; value: string | number }>> = {},
fieldOptions: Record<
string,
Array<{ label: string; value: string | number }>
> = {},
) {
const value = row[field.key] ?? row[`${field.key}_masked`];
if (value == null || value === '') return field.emptyText ?? '-';
@@ -169,9 +184,7 @@ export function displayResourceField(
Object.prototype.hasOwnProperty.call(fieldOptions, field.key) ||
Boolean(field.options);
const options = fieldOptions[field.key] ?? field.options;
const option = options?.find(
(item) => String(item.value) === String(value),
);
const option = options?.find((item) => String(item.value) === String(value));
if (option) return option.label;
if (hasOptionSource && field.unknownValueLabel) {
return `${field.unknownValueLabel}${String(value)}`;

View File

@@ -0,0 +1,136 @@
/**
* 功能:定义工作人员关联字段的显式过滤、角色展示与资质来源校验策略。
* 版本v1.0.0
*/
export const staffRoleCodes = ['installer', 'delivery', 'operations'] as const;
export type StaffRoleCode = (typeof staffRoleCodes)[number];
export type StaffRelationPolicy = {
roles: readonly StaffRoleCode[] | 'context';
enabledOnly?: boolean;
workStatus?: 'on_duty' | 'off_duty';
lockPrefilled?: boolean;
showIdentityCopy?: boolean;
};
export type StaffRelationContext = {
staffType?: string;
};
type StaffRelationField = {
relation?: string;
staffRelation?: StaffRelationPolicy;
};
type StaffOwner = {
identity?: unknown;
role_code?: unknown;
status?: unknown;
};
const roleLabels: Record<StaffRoleCode, string> = {
installer: '安装人员',
delivery: '配送人员',
operations: '运维人员',
};
/** 将外部字符串收敛为工作人员闭集角色。 */
export function normalizeStaffRole(value: unknown): StaffRoleCode | '' {
const role = String(value ?? '').trim();
return staffRoleCodes.includes(role as StaffRoleCode)
? (role as StaffRoleCode)
: '';
}
/** 返回工作人员角色的中文名称。 */
export function staffRoleLabel(value: unknown) {
const role = normalizeStaffRole(value);
return role ? roleLabels[role] : '未知角色';
}
/**
* 为工作人员关联字段构造服务端过滤条件;缺少显式策略时立即失败,
* 防止业务字段再次继承隐含的配送人员默认值。
*/
export function staffRelationFilters(
field: StaffRelationField,
context: StaffRelationContext = {},
) {
if (field.relation !== '/staff_account') return {};
if (!field.staffRelation) {
throw new Error('工作人员关联字段缺少显式过滤策略');
}
const roles =
field.staffRelation.roles === 'context'
? [normalizeStaffRole(context.staffType)].filter(
(role): role is StaffRoleCode => Boolean(role),
)
: [...field.staffRelation.roles];
if (!roles.length) throw new Error('工作人员关联字段缺少有效角色上下文');
const filters: Record<string, string> =
roles.length === 1
? { role_code: roles[0] }
: { role_codes: roles.join(',') };
if (field.staffRelation.enabledOnly) filters.status = '1';
if (field.staffRelation.workStatus) {
filters.work_status = field.staffRelation.workStatus;
}
return filters;
}
/** 从站内返回地址中推断工作人员菜单角色,仅用作显式参数缺失时的回退。 */
export function staffRoleFromReturnPath(returnTo: string) {
let current = returnTo;
for (let depth = 0; depth < 3 && current; depth += 1) {
const url = new URL(current, 'http://codex.local');
const queryRole = normalizeStaffRole(url.searchParams.get('staff_type'));
if (queryRole) return queryRole;
const pathRole = [
['/staff/installers', 'installer'],
['/staff/delivery', 'delivery'],
['/staff/operations', 'operations'],
].find(([path]) => url.pathname.startsWith(path))?.[1];
if (pathRole) return pathRole as StaffRoleCode;
current = url.searchParams.get('return_to') ?? '';
}
return '';
}
/** 解析资质页面角色来源,并拒绝显式参数与返回路径互相冲突。 */
export function resolveCredentialStaffRole(
explicitRole: unknown,
returnTo: string,
) {
const explicit = normalizeStaffRole(explicitRole);
const inferred = staffRoleFromReturnPath(returnTo);
if (explicit && inferred && explicit !== inferred) {
return { role: '' as const, error: '工作人员角色与来源菜单不一致' };
}
const role = explicit || inferred;
return role
? { role, error: '' }
: {
role: '' as const,
error: '缺少工作人员角色来源,请从工作人员页面进入',
};
}
/** 校验资质所有者与经服务端查询得到的真实人员记录是否一致。 */
export function credentialOwnerValidationMessage(
ownerIdentity: string,
expectedRole: StaffRoleCode,
owner: StaffOwner | undefined,
) {
if (!ownerIdentity) return '缺少工作人员唯一标识,请从工作人员页面进入';
if (!owner) return '工作人员不存在、已归档或无权访问';
if (String(owner.identity ?? '') !== ownerIdentity) {
return '工作人员唯一标识与查询结果不一致';
}
if (normalizeStaffRole(owner.role_code) !== expectedRole) {
return '工作人员角色已变化,请从当前角色菜单重新进入';
}
if (Number(owner.status) === 3) return '已归档工作人员不能新增资质';
return '';
}

View File

@@ -41,6 +41,7 @@ export type ResourceField = {
readonlyOnCreate?: boolean;
unknownValueLabel?: string;
relationLinkage?: ResourceRelationLinkage;
staffRelation?: import('./resource-staff-relation').StaffRelationPolicy;
};
export type DetailAction = {
@@ -289,8 +290,13 @@ function f(key: string, options: Partial<ResourceField> = {}): ResourceField {
return { key, label, type, ...options };
}
function relation(key: string, resource: string, required = false): ResourceField {
return f(key, { type: 'identity', relation: resource, required });
function relation(
key: string,
resource: string,
required = false,
options: Partial<ResourceField> = {},
): ResourceField {
return f(key, { ...options, type: 'identity', relation: resource, required });
}
/** 创建固定管理员角色字段,页面展示中文名称,接口仍使用稳定编码。 */
@@ -349,10 +355,10 @@ export const resources: ResourceUiDefinition[] = [
{ ...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' }] }), 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('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), f('address', { required: true }), f('longitude'), f('latitude'), 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')]),
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('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 })]),
@@ -374,7 +380,7 @@ export const resources: ResourceUiDefinition[] = [
]),
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: [{ label: '用户', value: 'user' }, { label: '工作人员', value: 'staff' }, { label: '配送站', value: 'delivery' }, { label: '气站', value: 'gas' }] }), 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', [
{ name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
{ 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] } },
{ name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason, visibleFor: { field: 'order_status', values: [20] } },

View File

@@ -72,7 +72,7 @@ const ownershipKeys = [
/** 业务动作字段不启用记录页联动,只沿用普通关联搜索。 */
function searchRelation(field: ResourceField, keyword: string) {
relations.search(field.relation, keyword);
relations.searchField(field, keyword);
}
watch(

View File

@@ -10,6 +10,10 @@
<span class="detail-label">{{ entry.label }}</span>
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
<IdentityText v-else-if="entry.key === 'identity'" :value="String(entry.value)" />
<div v-else-if="entry.identityValue" class="detail-relation-value">
<span class="detail-value">{{ entry.value }}</span>
<IdentityText :value="entry.identityValue" />
</div>
<span v-else class="detail-value">{{ entry.value }}</span>
</div>
</div>
@@ -71,6 +75,7 @@ type DetailEntry = {
value: string;
objectValue: boolean;
wide: boolean;
identityValue: string;
};
const entries = computed<DetailEntry[]>(() => {
@@ -115,6 +120,8 @@ const entries = computed<DetailEntry[]>(() => {
label: resourceFieldLabel(props.definition, actualKey),
value: display,
objectValue,
identityValue:
field?.staffRelation?.showIdentityCopy && value ? String(value) : '',
wide:
objectValue ||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
@@ -189,6 +196,10 @@ const collections = computed(() =>
white-space: pre-wrap;
word-break: break-word;
}
.detail-relation-value {
display: grid;
gap: 4px;
}
.json-value {
max-height: 360px;
margin: 0;

View File

@@ -67,26 +67,34 @@
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:multiple="field.type === 'identity-list'"
:placeholder="field.placeholder"
:loading="relationLoading[field.relation ?? '']"
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 ?? ''] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
<div v-else-if="field.type === 'identity' || field.type === 'identity-list'" class="relation-control">
<a-select
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:multiple="field.type === 'identity-list'"
:placeholder="field.placeholder"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
@change="(value: unknown) => emit('change-relation', field, value)"
@search="(value: string) => emit('search-relation', field, value)"
>
{{ optionLabel(option) }}
</a-option>
</a-select>
<a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []"
:key="String(option.identity)"
:value="String(option.identity)"
>
{{ relationOptionLabel(field, option) }}
</a-option>
</a-select>
<div
v-if="disabledSet.has(field.key) && field.staffRelation?.showIdentityCopy && model[field.key]"
class="relation-identity"
>
<span>人员标识</span>
<IdentityText :value="String(model[field.key])" />
</div>
</div>
<a-input
v-else
v-model="model[field.key]"
@@ -99,10 +107,11 @@
<script setup lang="ts">
import { computed } from 'vue';
import { optionLabel } from '@/api/resource-display';
import { relationOptionLabel } from '@/api/resource-display';
import 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';
const props = withDefaults(
defineProps<{
@@ -175,6 +184,17 @@ function selectOptions(field: ResourceField) {
:deep(.arco-picker) {
width: 100%;
}
.relation-control {
display: grid;
gap: 6px;
}
.relation-identity {
display: flex;
gap: 8px;
align-items: center;
color: var(--color-text-3);
font-size: 12px;
}
@media (max-width: 900px) {
.field-grid {
grid-template-columns: 1fr;

View File

@@ -131,7 +131,7 @@ import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ApiError } from '@/api/http';
import { platformApi, type PlatformRole } from '@/api/platform';
import type { PlatformRole } from '@/api/platform';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload } from '@/api/resource-form';
import {
@@ -169,9 +169,10 @@ import ResourceDetailContent from './ResourceDetailContent.vue';
import ResourceFieldForm from './ResourceFieldForm.vue';
import ResourceWalletSummary from './ResourceWalletSummary.vue';
import { createResourceRecordNavigation } from './resource-record-navigation';
import { loadResourceRecordRelations } from './load-resource-record-relations';
import { useResourceAvatar } from './use-resource-avatar';
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
import { useResourceRelations } from './use-resource-relations';
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
import { useUnsavedRecord } from './use-unsaved-record';
const route = useRoute();
@@ -199,7 +200,22 @@ const roleOptions = ref<PlatformRole[]>([]);
const fieldOptions = reactive<
Record<string, Array<{ label: string; value: string | number }>>
>({});
const relations = useResourceRelations();
const staffOwnerGuard = useStaffCredentialOwnerGuard({
definitionName: () => definition.value.name,
mode: () => mode.value,
record: () => record.value,
ownerIdentity: () =>
String(
mode.value === 'create'
? (route.query.owner_identity ?? '')
: (record.value.staff_account_identity ?? ''),
),
explicitStaffType: () => route.query.staff_type,
routeStaffType: () => route.query.staff_type ?? route.meta.staffType,
returnPath: () => returnPath.value,
});
const resolvedStaffType = staffOwnerGuard.resolvedStaffType;
const relations = staffOwnerGuard.relations;
const relationLinkage = useResourceRelationLinkage(form, relations);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
@@ -212,8 +228,7 @@ const clearAvatar = avatar.clear;
const accountSummary = computed(() => usesAccountSummary(definition.value));
const accountSummaryVisible = computed(
() =>
accountSummary.value &&
(mode.value !== 'create' || hasAvatarField.value),
accountSummary.value && (mode.value !== 'create' || hasAvatarField.value),
);
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
@@ -246,12 +261,16 @@ const editableKeySet = computed(
const readonlyKeys = computed(() =>
mode.value === 'create'
? formFields.value
.filter((field) => field.readonlyOnCreate)
.filter(
(field) =>
field.readonlyOnCreate ||
(field.staffRelation?.lockPrefilled && Boolean(form[field.key])),
)
.map((field) => field.key)
: mode.value === 'edit'
? formFields.value
.filter((field) => !editableKeySet.value.has(field.key))
.map((field) => field.key)
.filter((field) => !editableKeySet.value.has(field.key))
.map((field) => field.key)
: [],
);
const requiredKeys = computed(() =>
@@ -320,6 +339,7 @@ async function initialize() {
}
loading.value = true;
errorMessage.value = '';
staffOwnerGuard.reset();
try {
if (mode.value !== 'create')
detail.value = await resourceApi.detail<ResourceRow>(
@@ -336,10 +356,24 @@ async function initialize() {
mode.value === 'create' ? String(route.query.relation_key ?? '') : '',
ownerIdentity:
mode.value === 'create' ? String(route.query.owner_identity ?? '') : '',
staffType:
mode.value === 'create' ? String(route.meta.staffType ?? '') : '',
staffType: mode.value === 'create' ? resolvedStaffType.value : '',
});
const ownerGuardResult = await staffOwnerGuard.prepare();
if (!ownerGuardResult.ok) {
errorStatus.value = ownerGuardResult.status;
errorMessage.value = ownerGuardResult.message;
return;
}
await loadResourceRecordRelations({
detailMode: mode.value === 'detail',
definition: definition.value,
formFields: formFields.value,
record: record.value,
relations,
relationLinkage,
fieldOptions,
roleOptions,
});
await loadRelationsAndRoles();
// 钱包和头像属于附加信息,读取失败时不能阻断主详情或基础表单。
await Promise.allSettled([loadWallet(), loadAvatar()]);
unsaved.markInitialized();
@@ -359,31 +393,6 @@ async function initialize() {
}
}
async function loadRelationsAndRoles() {
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')
) {
try {
const roles = (await platformApi.listRole()).list;
fieldOptions.platform_role_code = roles.map((role) => ({
label: role.name,
value: role.role_code,
}));
roleOptions.value = roles.filter(
(role) => !role.is_system && role.status === 1,
);
} catch (error) {
fieldOptions.platform_role_code = [];
Message.warning(`平台角色加载失败:${(error as Error).message}`);
}
}
}
async function loadWallet() {
if (
!definition.value.walletOwnerType ||

View File

@@ -0,0 +1,57 @@
/**
* 功能:统一加载资源记录页的关系选项和平台角色选项。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import type { Ref } from 'vue';
import { platformApi, type PlatformRole } from '@/api/platform';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceRelations } from './use-resource-relations';
type LoaderOptions = {
detailMode: boolean;
definition: ResourceUiDefinition;
formFields: ResourceField[];
record: ResourceRow;
relations: ResourceRelations;
relationLinkage: { preload: (fields: ResourceField[]) => Promise<void> };
fieldOptions: Record<
string,
Array<{ label: string; value: string | number }>
>;
roleOptions: Ref<PlatformRole[]>;
};
/** 加载当前页面实际使用的关系和动态平台角色。 */
export async function loadResourceRecordRelations(options: LoaderOptions) {
if (options.detailMode) {
await options.relations.preload(options.definition.fields);
await options.relations.ensureValues(
options.definition.fields,
options.record,
);
} else {
await options.relationLinkage.preload(options.formFields);
}
if (
!options.definition.fields.some(
(field) => field.key === 'platform_role_code',
)
) {
return;
}
try {
const roles = (await platformApi.listRole()).list;
options.fieldOptions.platform_role_code = roles.map((role) => ({
label: role.name,
value: role.role_code,
}));
options.roleOptions.value = roles.filter(
(role) => !role.is_system && role.status === 1,
);
} catch (error) {
options.fieldOptions.platform_role_code = [];
Message.warning(`平台角色加载失败:${(error as Error).message}`);
}
}

View File

@@ -57,10 +57,11 @@ export function useResourceRelationLinkage(
}
async function reloadChildOptions() {
const resource = active?.childField.relation;
const childField = active?.childField;
const resource = childField?.relation;
if (!resource) return;
relations.cancelSearch(resource);
await relations.load(resource, '', childRequest());
await relations.loadField(childField, '', childRequest());
}
/** 预加载普通关联项,并单独按父级条件加载启用联动的子选项。 */
@@ -69,6 +70,7 @@ export function useResourceRelationLinkage(
configure(fields);
if (!active?.childField.relationLinkage || !active.childField.relation) {
await relations.preload(fields);
await relations.ensureValues(fields, form);
initialized = true;
return;
}
@@ -108,11 +110,7 @@ export function useResourceRelationLinkage(
return;
}
if (
shouldClearLinkedOption(
parentIdentity,
option,
linkage.optionParentKey,
)
shouldClearLinkedOption(parentIdentity, option, linkage.optionParentKey)
) {
form[active.childField.key] = '';
Message.info('所属气站已变更,请重新选择配送点');
@@ -169,10 +167,10 @@ export function useResourceRelationLinkage(
function search(field: ResourceField, keyword: string) {
if (!field.relation) return;
if (field.key === active?.childField.key) {
relations.search(field.relation, keyword, childRequest());
relations.searchField(field, keyword, childRequest());
return;
}
relations.search(field.relation, keyword);
relations.searchField(field, keyword);
}
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */

View File

@@ -5,6 +5,10 @@
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, reactive } from 'vue';
import { resourceApi } from '@/api/resource';
import {
staffRelationFilters,
type StaffRelationContext,
} from '@/api/resource-staff-relation';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
@@ -14,7 +18,9 @@ export type ResourceRelationLoadOptions = {
preserveIdentities?: string[];
};
export function useResourceRelations() {
export function useResourceRelations(
context: () => StaffRelationContext = () => ({}),
) {
const options = reactive<Record<string, ResourceRow[]>>({});
const loading = reactive<Record<string, boolean>>({});
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -54,7 +60,6 @@ export function useResourceRelations() {
...(request.filters ?? {}),
...(keyword ? { keyword } : {}),
};
if (resource === '/staff_account') filters.role_code = 'delivery';
const rows = (
await resourceApi.list<ResourceRow>(resource, 1, 100, filters)
).list;
@@ -74,6 +79,22 @@ export function useResourceRelations() {
}
}
/** 按字段的显式关系策略加载选项,工作人员字段不得依赖全局默认值。 */
function loadField(
field: ResourceField,
keyword = '',
request: ResourceRelationLoadOptions = {},
) {
if (!field.relation) return Promise.resolve();
return load(field.relation, keyword, {
...request,
filters: {
...staffRelationFilters(field, context()),
...(request.filters ?? {}),
},
});
}
/** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */
async function ensure(resource: string, identity: string) {
if (!identity) return undefined;
@@ -97,12 +118,29 @@ export function useResourceRelations() {
/** 预加载当前页面实际使用的全部关系字段。 */
async function preload(fields: ResourceField[]) {
const resources = new Set(
fields
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
const fieldsByResource = new Map<string, ResourceField>();
for (const field of fields) {
if (field.relation && !fieldsByResource.has(field.relation)) {
fieldsByResource.set(field.relation, field);
}
}
await Promise.all(
[...fieldsByResource.values()].map((field) => loadField(field)),
);
await Promise.all([...resources].map((resource) => load(resource)));
}
/** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */
async function ensureValues(fields: ResourceField[], values: ResourceRow) {
const requests = fields.flatMap((field) => {
if (!field.relation) return [];
const value = values[field.key];
const identities = Array.isArray(value) ? value : [value];
return identities
.map((identity) => String(identity ?? ''))
.filter(Boolean)
.map((identity) => ensure(field.relation as string, identity));
});
await Promise.all(requests);
}
/** 对远程关系选项进行防抖搜索。 */
@@ -122,6 +160,22 @@ export function useResourceRelations() {
);
}
/** 按字段的显式关系策略执行防抖搜索。 */
function searchField(
field: ResourceField,
keyword: string,
request: ResourceRelationLoadOptions = {},
) {
if (!field.relation) return;
search(field.relation, keyword, {
...request,
filters: {
...staffRelationFilters(field, context()),
...(request.filters ?? {}),
},
});
}
/** 取消关联资源尚未触发的防抖搜索。 */
function cancelSearch(resource: string) {
const timer = timers.get(resource);
@@ -137,9 +191,12 @@ export function useResourceRelations() {
options,
loading,
load,
loadField,
ensure,
ensureValues,
preload,
search,
searchField,
cancelSearch,
};
}

View File

@@ -0,0 +1,96 @@
/**
* 功能:校验工作人员资质页面的所有者、来源角色和访问状态。
* 版本v1.0.0
*/
import { ref } from 'vue';
import type { LocationQueryValue } from 'vue-router';
import { ApiError } from '@/api/http';
import { resourceApi } from '@/api/resource';
import {
credentialOwnerValidationMessage,
normalizeStaffRole,
resolveCredentialStaffRole,
type StaffRoleCode,
} from '@/api/resource-staff-relation';
import type { RecordPageMode, ResourceRow } from '@/api/resource-page-rules';
import { useResourceRelations } from './use-resource-relations';
type GuardOptions = {
definitionName: () => string;
mode: () => RecordPageMode;
record: () => ResourceRow;
ownerIdentity: () => string;
explicitStaffType: () => LocationQueryValue | LocationQueryValue[];
routeStaffType: () => unknown;
returnPath: () => string;
};
type GuardResult =
| { ok: true }
| { ok: false; status: '403' | '404'; message: string };
/** 创建资质所有者守卫,并向关系加载器提供已验证的角色上下文。 */
export function useStaffCredentialOwnerGuard(options: GuardOptions) {
const resolvedStaffType = ref<StaffRoleCode | ''>(
normalizeStaffRole(options.routeStaffType()),
);
const relations = useResourceRelations(() => ({
staffType: resolvedStaffType.value,
}));
/** 每次页面重新初始化时重置路由角色,避免复用上一次记录的上下文。 */
function reset() {
resolvedStaffType.value = normalizeStaffRole(options.routeStaffType());
}
async function prepare(): Promise<GuardResult> {
if (options.definitionName() !== 'staff_credential') return { ok: true };
const ownerIdentity = options.ownerIdentity();
if (options.mode() === 'create') {
const resolved = resolveCredentialStaffRole(
options.explicitStaffType(),
options.returnPath(),
);
if (resolved.error) {
return { ok: false, status: '403', message: resolved.error };
}
resolvedStaffType.value = resolved.role;
}
if (!ownerIdentity) {
return {
ok: false,
status: '403',
message: '缺少工作人员唯一标识,请从工作人员页面进入',
};
}
let owner: ResourceRow;
try {
owner = await resourceApi.detail<ResourceRow>(
'/staff_account',
ownerIdentity,
);
} catch (error) {
return {
ok: false,
status:
error instanceof ApiError && error.status === 404 ? '404' : '403',
message: `无法确认资质所属工作人员:${(error as Error).message}`,
};
}
if (options.mode() !== 'create') {
resolvedStaffType.value = normalizeStaffRole(owner.role_code);
}
const validationMessage = credentialOwnerValidationMessage(
ownerIdentity,
resolvedStaffType.value as StaffRoleCode,
owner,
);
if (validationMessage) {
return { ok: false, status: '403', message: validationMessage };
}
relations.options['/staff_account'] = [owner];
return { ok: true };
}
return { resolvedStaffType, relations, reset, prepare };
}

View File

@@ -143,7 +143,15 @@
<script setup lang="ts">
import { Message, Modal } from '@arco-design/web-vue';
import { IconLeft, IconPlus, IconRefresh } from '@arco-design/web-vue/es/icon';
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRef, watch } from 'vue';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
toRef,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import IdentityText from '@/components/IdentityText.vue';
import {
@@ -180,7 +188,9 @@ const list = ref<ResourceRow[]>([]);
const filters = reactive({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
});
const relations = useResourceRelations();
const relations = useResourceRelations(() => ({
staffType: String(route.query.staff_type ?? route.meta.staffType ?? ''),
}));
const fieldOptions = reactive<
Record<string, Array<{ label: string; value: string | number }>>
>({});
@@ -285,6 +295,9 @@ function recordQuery() {
...(managedRelationKey.value
? { relation_key: managedRelationKey.value }
: {}),
...(typeof route.query.staff_type === 'string' && route.query.staff_type
? { staff_type: route.query.staff_type }
: {}),
};
}
@@ -336,6 +349,7 @@ function viewCredentials(row: ResourceRow) {
owner_identity: String(row.identity ?? ''),
owner_name: String(row.name ?? row.username ?? ''),
relation_key: 'staff_account_identity',
staff_type: staffType.value,
return_to: route.fullPath,
},
});
@@ -404,6 +418,7 @@ async function syncQuery() {
'owner_identity',
'owner_name',
'relation_key',
'staff_type',
'return_to',
]) {
const value = route.query[key];
@@ -456,9 +471,7 @@ function displayListField(field: ResourceField, row: ResourceRow) {
/** 平台角色由后端维护,列表加载全部角色用于编码转中文名称。 */
async function loadFieldOptions() {
if (
!props.definition.fields.some(
(field) => field.key === 'platform_role_code',
)
!props.definition.fields.some((field) => field.key === 'platform_role_code')
) {
delete fieldOptions.platform_role_code;
return;