完善配送端资源搜索与资质导航

This commit is contained in:
czl231
2026-08-22 23:24:12 +08:00
parent 5d4bd61672
commit 4334719051
22 changed files with 432 additions and 58 deletions

View File

@@ -24,6 +24,7 @@ const creatable = new Set([
const editable = new Set([
'staff_account', 'staff_credential', 'user_account', 'user_address', 'gasorder_contract',
]);
const resourcesByName = new Map(contract.resources.map((resource) => [resource.name, resource]));
/** 在条件不成立时中止检查,并给出可直接定位的原因。 */
function assert(condition, message) {
@@ -72,5 +73,20 @@ assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像
const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts');
assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐');
assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确');
assert(
resourcesByName.get('staff_credential').searchFields.map((field) => field.key).join(',') ===
'credential_type,credential_no',
'人员资质搜索契约必须包含资质类型和资质编号',
);
for (const name of ['delivery_profile', 'user_address', 'wallet_bank']) {
assert(!(resourcesByName.get(name).searchFields?.length), `${name} 不应显示无效搜索`);
}
assert(listPage.includes('v-if="searchEnabled"'), '列表搜索区域未按后端契约控制显示');
assert(listPage.includes('label="模糊搜索"'), '列表搜索标签未明确说明模糊匹配');
assert(!listPage.includes('label="关键字"'), '列表仍使用含义不清的关键字标签');
assert(!listPage.includes('placeholder="关键字段模糊搜索"'), '列表仍使用无意义的通用搜索提示');
assert(listPage.includes('ensureCredentialContext'), '人员资质列表缺少服务端人员上下文校验');
assert(listPage.includes("field.key === 'staff_account_identity'"), '人员范围资质列表未隐藏重复人员列');
assert(recordPage.includes('已锁定,不可更换'), '人员资质独立页未锁定所属配送人员');
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);

View File

@@ -25,11 +25,12 @@ export function recordRouteLocation(
mode: RecordNavigationMode,
identity: string,
returnTo: string,
context: Record<string, string> = {},
): RouteLocationRaw {
return {
name: `${listRouteName}-${mode}`,
...(mode === 'create' ? {} : { params: { identity } }),
query: returnTo ? { return_to: returnTo } : {},
query: { ...context, ...(returnTo ? { return_to: returnTo } : {}) },
};
}

View File

@@ -0,0 +1,29 @@
/**
* 功能描述:读取配送端生成的资源搜索契约,统一搜索字段与枚举别名。
* 版本v1.0.0。
*/
import deliveryContract from '@/contracts/delivery-resources.json';
export type ResourceSearchValue = { value: string; label: string };
export type ResourceSearchField = {
key: string;
kind: 'text' | 'enum';
values?: ResourceSearchValue[];
};
type ContractResource = { name: string; searchFields?: ResourceSearchField[] };
const fieldsByResource = Object.fromEntries(
(deliveryContract.resources as ContractResource[]).map((resource) => [
resource.name,
resource.searchFields ?? [],
]),
) as Record<string, ResourceSearchField[]>;
/** 返回后端确认可用的搜索字段副本。 */
export function resourceSearchFields(name: string): ResourceSearchField[] {
return (fieldsByResource[name] ?? []).map((field) => ({
...field,
values: field.values?.map((value) => ({ ...value })),
}));
}

View File

@@ -2,6 +2,8 @@
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
* 版本v1.1.0。
*/
import { resourceSearchFields, type ResourceSearchField } from './resource-search-contract';
export type ResourceMode =
| 'writable'
| 'readonly'
@@ -59,6 +61,7 @@ export type ResourceUiDefinition = {
mode: ResourceMode;
pageKind: ResourcePageKind;
fields: ResourceField[];
searchFields: ResourceSearchField[];
detailActions?: DetailAction[];
canCreate: boolean;
canEdit: boolean;
@@ -338,6 +341,7 @@ function define(
mode,
pageKind,
fields,
searchFields: resourceSearchFields(name),
...defaults,
...capabilities,
...(detailActions ? { detailActions } : {}),

File diff suppressed because one or more lines are too long

View File

@@ -66,3 +66,14 @@
.section-card :deep(.arco-card-header),
.section-card :deep(.arco-card-body) { padding-right: 16px; padding-left: 16px; }
}
.credential-owner-card :deep(.arco-card-body) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.credential-owner-card strong {
display: block;
margin-bottom: 8px;
}

View File

@@ -29,6 +29,18 @@
</a-result>
<template v-else>
<a-card
v-if="isCredentialResource && credentialOwner"
title="所属配送人员"
:bordered="false"
class="section-card credential-owner-card"
>
<div>
<strong>{{ credentialOwner.name || credentialOwner.username || '未命名人员' }}</strong>
<IdentityText :value="String(credentialOwner.identity)" />
</div>
<a-tag color="blue">已锁定不可更换</a-tag>
</a-card>
<ResourceAccountSummary
v-if="hasAvatarField"
:mode="mode"
@@ -111,6 +123,7 @@ import {
import { primaryRecord, recordEditReason } from '@/api/resource-display';
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceDetailContent from './ResourceDetailContent.vue';
@@ -131,6 +144,7 @@ const loading = ref(false);
const saving = ref(false);
const errorMessage = ref('');
const detail = ref<ResourceRow>({});
const credentialOwner = ref<ResourceRow>();
const record = computed(() => primaryRecord(detail.value));
const form = reactive<Record<string, any>>({});
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
@@ -142,6 +156,7 @@ const avatar = useResourceAvatar();
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
const summaryRecord = computed(() =>
mode.value === 'detail' ? record.value : { ...record.value, ...form },
);
@@ -149,7 +164,11 @@ const formFields = computed(() => recordFormFields(
definition.value.name,
definition.value.fields,
mode.value as 'create' | 'edit',
).filter((field) => field.key !== 'avatar'));
).filter((field) => field.key !== 'avatar').map((field) =>
isCredentialResource.value && field.key === 'staff_account_identity'
? { ...field, readonly: true }
: field,
));
const canEditRecord = computed(
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
);
@@ -197,6 +216,23 @@ async function loadRecord() {
}
}
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
async function loadCredentialOwner() {
if (!isCredentialResource.value) return;
const queryOwner = typeof route.query.owner_identity === 'string'
? route.query.owner_identity.trim()
: '';
const recordOwner = String(record.value.staff_account_identity ?? '');
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
if (!owner || String(owner.identity ?? '') !== targetIdentity)
throw new Error('人员详情响应无效');
credentialOwner.value = owner;
relationOptions['/staff_account'] = [owner];
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
}
/** 保存新建或编辑表单并进入记录详情。 */
async function save() {
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
@@ -280,6 +316,10 @@ function confirmArchive() {
/** 加载关联候选,配送人员关系固定为配送角色。 */
async function loadRelation(resource: string, keyword = '') {
if (isCredentialResource.value && resource === '/staff_account') {
await loadCredentialOwner();
return;
}
relationLoading[resource] = true;
try {
const filters: Record<string, string> = keyword ? { keyword } : {};
@@ -301,13 +341,28 @@ function searchRelation(resource: string | undefined, keyword: string) {
}
onMounted(async () => {
if (
isCredentialResource.value && mode.value === 'create' &&
(typeof route.query.owner_identity !== 'string' ||
route.query.relation_key !== 'staff_account_identity')
) {
Message.warning('请从配送人员列表进入资质新建页');
await router.replace({ name: 'staff-delivery' });
return;
}
await loadRecord();
if (errorMessage.value) return;
const paths = new Set(
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
.map((field: ResourceField) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all([...paths].map((resource) => loadRelation(resource)));
await loadRecord();
try {
await Promise.all([...paths].map((resource) => loadRelation(resource)));
if (isCredentialResource.value && mode.value === 'detail') await loadCredentialOwner();
} catch (error) {
errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`;
}
});
</script>

View File

@@ -1,6 +1,9 @@
<!-- 功能描述展示配送点标准资源列表并导航到独立记录页版本v1.0.0 -->
<template>
<a-card :title="definition.title" :bordered="false">
<a-card :title="listTitle" :bordered="false">
<template v-if="isCredentialList" #extra>
<a-button @click="returnToStaffList"><template #icon><icon-left /></template>返回配送人员列表</a-button>
</template>
<a-alert
v-if="definition.detailActions?.length"
class="workflow-alert"
@@ -10,10 +13,26 @@
>
请从详情页中的专用操作推进流程操作结果由服务端状态校验并保留审计记录
</a-alert>
<div class="list-toolbar">
<a-form :model="filters" layout="inline" @submit="search">
<a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" />
<div v-if="credentialOwner" class="owner-context">
<div>
<div class="owner-label">当前配送人员</div>
<strong>{{ credentialOwner.name || credentialOwner.username || '未命名人员' }}</strong>
</div>
<IdentityText :value="String(credentialOwner.identity)" />
</div>
<a-result
v-if="contextError"
status="error"
title="无法加载配送人员资质"
:subtitle="contextError"
>
<template #extra><a-button type="primary" @click="returnToStaffList">返回配送人员列表</a-button></template>
</a-result>
<template v-else>
<div class="list-toolbar" :class="{ 'no-search': !searchEnabled }">
<a-form v-if="searchEnabled" :model="filters" layout="inline" @submit="search">
<a-form-item label="模糊搜索">
<a-input v-model="filters.keyword" allow-clear :placeholder="searchPlaceholder" />
</a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
@@ -94,6 +113,7 @@
@change="changePage"
/>
</div>
</template>
</a-card>
</template>
@@ -108,9 +128,9 @@ import {
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import { recordRouteLocation } from '@/api/resource-navigation';
import { recordRouteLocation, safeReturnPath } from '@/api/resource-navigation';
import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { resourceFieldLabel, type ResourceField, type ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
import {
@@ -127,16 +147,57 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
const pageSize = 50;
const total = ref(0);
const list = ref<ResourceRow[]>([]);
const credentialOwner = ref<ResourceRow>();
const contextError = ref('');
const avatarLoader = createProtectedListAvatarLoader();
const avatarRefreshKey = ref(0);
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
const isCredentialList = computed(() => props.definition.name === 'staff_credential');
const ownerIdentity = computed(() =>
typeof route.query.owner_identity === 'string' ? route.query.owner_identity.trim() : '',
);
const listTitle = computed(() => {
if (!isCredentialList.value || !credentialOwner.value) return props.definition.title;
const name = credentialOwner.value.name ?? credentialOwner.value.username ?? '未命名人员';
return `${props.definition.title} · ${name}`;
});
const searchEnabled = computed(() => props.definition.searchFields.length > 0);
const searchPlaceholder = computed(() =>
`可搜索:${props.definition.searchFields.map((field) => resourceFieldLabel(field.key)).join('、')}`,
);
const displayFields = computed(() =>
props.definition.fields
.filter((field) => !['identity', 'password', 'status'].includes(field.key))
.filter((field) => field.key !== 'avatar' || isProtectedListAvatarField(props.definition.name, field.key))
.filter((field) => !(isCredentialList.value && credentialOwner.value && field.key === 'staff_account_identity'))
.slice(0, 6),
);
/** 验证人员资质必须来自一个当前范围内的配送人员。 */
async function ensureCredentialContext() {
if (!isCredentialList.value) return true;
if (!ownerIdentity.value || route.query.relation_key !== 'staff_account_identity') {
Message.warning('请先选择配送人员查看资质');
await returnToStaffList();
return false;
}
if (credentialOwner.value?.identity === ownerIdentity.value) return true;
try {
const owner = await resourceApi.detail<ResourceRow>('/staff_account', ownerIdentity.value);
if (!owner || String(owner.identity ?? '') !== ownerIdentity.value)
throw new Error('人员详情响应无效');
credentialOwner.value = owner;
contextError.value = '';
return true;
} catch (error) {
credentialOwner.value = undefined;
contextError.value = `人员不存在或无权访问:${(error as Error).message}`;
list.value = [];
total.value = 0;
return false;
}
}
/** 返回列表列宽。 */
function columnWidth(field: ResourceField) {
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
@@ -156,7 +217,9 @@ async function load() {
avatarRefreshKey.value += 1;
loading.value = true;
try {
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
if (!(await ensureCredentialContext())) return;
const keyword = searchEnabled.value ? filters.keyword.trim() : '';
const serverFilters: Record<string, string> = keyword ? { keyword } : {};
if (typeof route.query.owner_identity === 'string' && typeof route.query.relation_key === 'string')
serverFilters[route.query.relation_key] = route.query.owner_identity;
const result = await resourceApi.list<ResourceRow>(
@@ -179,8 +242,9 @@ async function syncQuery() {
const query: Record<string, string> = {};
if (typeof route.query.owner_identity === 'string') query.owner_identity = route.query.owner_identity;
if (typeof route.query.relation_key === 'string') query.relation_key = route.query.relation_key;
if (typeof route.query.return_to === 'string') query.return_to = route.query.return_to;
if (page.value > 1) query.page = String(page.value);
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
if (searchEnabled.value && filters.keyword.trim()) query.keyword = filters.keyword.trim();
await router.replace({ query });
}
@@ -197,7 +261,12 @@ async function resetSearch() {
/** 打开当前列表对应的正式新建页。 */
function openCreate() {
return router.push(recordRouteLocation(String(route.name), 'create', '', route.fullPath));
const context: Record<string, string> = {};
if (isCredentialList.value) {
context.owner_identity = ownerIdentity.value;
context.relation_key = 'staff_account_identity';
}
return router.push(recordRouteLocation(String(route.name), 'create', '', route.fullPath, context));
}
/** 打开独立详情或编辑页。 */
@@ -216,10 +285,17 @@ function viewCredentials(row: ResourceRow) {
query: {
owner_identity: String(row.identity ?? ''),
relation_key: 'staff_account_identity',
return_to: route.fullPath,
},
});
}
/** 返回经过校验的来源人员列表。 */
function returnToStaffList() {
const returnPath = safeReturnPath(route.query.return_to);
return router.push(returnPath || { name: 'staff-delivery' });
}
async function changePage(next: number) {
page.value = next;
await syncQuery();
@@ -228,9 +304,11 @@ async function changePage(next: number) {
onMounted(load);
onBeforeUnmount(avatarLoader.reset);
watch(() => props.definition.resource, async () => {
watch(() => [props.definition.resource, ownerIdentity.value], async () => {
page.value = 1;
filters.keyword = '';
credentialOwner.value = undefined;
contextError.value = '';
await load();
});
</script>
@@ -238,6 +316,19 @@ watch(() => props.definition.resource, async () => {
<style scoped>
.workflow-alert { margin-bottom: 16px; }
.list-toolbar { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.list-toolbar.no-search { justify-content: flex-end; }
.owner-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
margin-bottom: 16px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-fill-1);
}
.owner-label { margin-bottom: 4px; color: var(--color-text-3); font-size: 12px; }
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
@media (max-width: 768px) { .list-toolbar { align-items: stretch; flex-direction: column; } }
</style>