完善配送端资源搜索与资质导航
This commit is contained in:
@@ -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}`);
|
||||
|
||||
@@ -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 } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
29
frontend/delivery_admin/src/api/resource-search-contract.ts
Normal file
29
frontend/delivery_admin/src/api/resource-search-contract.ts
Normal 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 })),
|
||||
}));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user