统一平台资源中文展示并修复模拟订单状态

This commit is contained in:
czl231
2026-08-15 22:06:42 +08:00
parent a20e228296
commit a663821853
24 changed files with 1048 additions and 175 deletions

View File

@@ -1,6 +1,6 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.7.0
版本v1.8.0
-->
<template>
<div class="detail-stack">
@@ -8,12 +8,19 @@
<div class="detail-grid">
<div v-for="entry in entries" :key="entry.key" class="detail-item" :class="{ 'detail-item-wide': entry.wide }">
<span class="detail-label">{{ entry.label }}</span>
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
<a-popover v-if="entry.jsonValue" position="left">
<a-button type="text" size="mini">查看内容</a-button>
<template #content><pre class="json-value">{{ entry.value }}</pre></template>
</a-popover>
<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>
<RelationNameText
v-else-if="entry.identityValue"
:name="entry.value"
:identity="entry.identityValue"
:identity-label="entry.label"
:clickable="Boolean(entry.relationResource)"
@open="openRelation(entry.relationResource, entry.identityValue)"
/>
<span v-else class="detail-value">{{ entry.value }}</span>
</div>
</div>
@@ -29,20 +36,20 @@
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(column)"
:width="collectionColumnWidth(collection.key, column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<div v-if="collectionRelationIdentityKey(collection.key, column)" class="collection-relation-value">
<div v-if="collectionRelationIdentityKey(definition.name, collection.key, column)" class="collection-relation-value">
<span>{{ displayRawValue(column, record[column]) }}</span>
<IdentityText
v-if="record[collectionRelationIdentityKey(collection.key, column)]"
:value="String(record[collectionRelationIdentityKey(collection.key, column)])"
v-if="record[collectionRelationIdentityKey(definition.name, collection.key, column)]"
:value="String(record[collectionRelationIdentityKey(definition.name, collection.key, column)])"
/>
</div>
<IdentityText v-else-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<a-popover v-else-if="isCollectionJsonColumn(column)" position="left">
<a-popover v-else-if="isCollectionJsonField(definition.name, collection.key, column)" position="left">
<a-button type="text" size="mini">查看参数</a-button>
<template #content>
<pre class="collection-json-value">{{ formatCollectionJson(record[column]) }}</pre>
@@ -62,16 +69,25 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import {
displayRawValue,
displayResourceField,
hasResourceFieldLabel,
isEmptyDeletedAt,
primaryRecord,
resourceFieldLabel,
} from '@/api/resource-display';
import {
collectionRelationIdentityKey,
isCollectionJsonField,
isResourceJsonField,
resourceDetailContract,
} from '@/api/resource-detail-contract';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import RelationNameText from '@/views/shared/RelationNameText.vue';
const props = defineProps<{
definition: ResourceUiDefinition;
@@ -88,22 +104,34 @@ type DetailEntry = {
key: string;
label: string;
value: string;
objectValue: boolean;
jsonValue: boolean;
wide: boolean;
identityValue: string;
relationResource: string;
};
/** 统一关联记录表列宽:日期时间列保持紧凑,唯一标识列保留完整复制空间。 */
function collectionColumnWidth(column: string) {
if (column.endsWith('_at')) return 150;
if (column.includes('identity')) return 220;
if (isCollectionJsonColumn(column)) return 100;
return 160;
const router = useRouter();
/** 打开关系资源详情;没有详情路由的树形资源保持只读。 */
function openRelation(resource: string, identity: string) {
if (!resource) return;
const target = router
.getRoutes()
.find(
(item) =>
item.meta.resource === resource && item.meta.recordMode === 'detail',
);
if (!target?.name) return;
void router.push({ name: target.name, params: { identity } });
}
/** 标识关联子表中需要完整查看的 JSON 快照字段。 */
function isCollectionJsonColumn(column: string) {
return column === 'product_params';
/** 统一关联记录表列宽:日期时间列保持紧凑,唯一标识列保留完整复制空间。 */
function collectionColumnWidth(collectionKey: string, column: string) {
if (column.endsWith('_at')) return 150;
if (column.includes('identity')) return 220;
if (isCollectionJsonField(props.definition.name, collectionKey, column))
return 100;
return 160;
}
/** 将字符串或对象参数格式化为可读 JSON历史非 JSON 文本保持原样。 */
@@ -119,20 +147,16 @@ function formatCollectionJson(value: unknown) {
}
}
/** 返回集合可读名称对应的稳定标识字段。 */
function collectionRelationIdentityKey(collectionKey: string, column: string) {
if (collectionKey !== 'assignments') return '';
return (
{
gas_basic_display_name: 'gas_basic_identity',
delivery_basic_display_name: 'delivery_basic_identity',
staff_account_display_name: 'staff_account_identity',
}[column] ?? ''
);
}
/** 返回订单详情关联标识对应的服务端可读名称字段。 */
function detailRelationDisplayName(row: ResourceRow, key: string) {
function detailRelationDisplayName(
row: ResourceRow,
key: string,
configuredDisplayKey = '',
) {
const conventionalKey = key.endsWith('_identity')
? `${key.slice(0, -9)}_display_name`
: '';
const displayKey =
{
creator_identity: 'creator_display_name',
@@ -141,13 +165,16 @@ function detailRelationDisplayName(row: ResourceRow, key: string) {
delivery_basic_identity: 'delivery_basic_display_name',
staff_account_identity: 'staff_account_display_name',
operator_identity: 'operator_name',
assigner_identity: 'assigner_name',
}[key] ?? '';
if (!displayKey) return '';
return String(row[displayKey] ?? '').trim();
return String(
row[configuredDisplayKey || displayKey || conventionalKey] ?? '',
).trim();
}
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const contract = resourceDetailContract(props.definition.name);
const excluded = new Set([
'id',
'password',
@@ -156,27 +183,12 @@ const entries = computed<DetailEntry[]>(() => {
'attachment',
// 合同可读名称只负责渲染“配送合同”,不作为独立业务字段重复展示。
'contract_display_name',
...(contract.hiddenKeys ?? []),
]);
if (props.accountSummary) {
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
}
const leadingKeys =
props.definition.name === 'gasorder_basic'
? [
'order_no',
'request_no',
'order_status',
'gasorder_contract_identity',
'creator_type',
'creator_identity',
'user_account_identity',
'gas_basic_identity',
'delivery_basic_identity',
'staff_account_identity',
'identity',
'status',
]
: ['identity', 'status'];
const leadingKeys = contract.leadingKeys ?? ['identity', 'status'];
const preferred = [
...leadingKeys,
...props.definition.fields.map((field) => field.key),
@@ -202,7 +214,7 @@ const entries = computed<DetailEntry[]>(() => {
excluded.has(key) ||
key.endsWith('_id') ||
key.endsWith('_display_name') ||
(props.definition.name === 'gasorder_basic' && key === 'operator_name')
!hasResourceFieldLabel(props.definition, actualKey)
)
return [];
const value = row[actualKey];
@@ -210,7 +222,12 @@ const entries = computed<DetailEntry[]>(() => {
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
return [];
const field = props.definition.fields.find((item) => item.key === key);
const relationDisplayName = detailRelationDisplayName(row, key);
// 资源已声明详情名称键时优先直接使用接口返回值,避免关系候选慢查询导致显示失败。
const relationDisplayName = detailRelationDisplayName(
row,
key,
field?.detailDisplayKey,
);
const display = relationDisplayName
? relationDisplayName
: field
@@ -221,22 +238,32 @@ const entries = computed<DetailEntry[]>(() => {
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
const jsonValue =
isResourceJsonField(key) ||
(typeof value === 'object' && value !== null);
return [
{
key,
label: resourceFieldLabel(props.definition, actualKey),
value: display,
objectValue,
value: jsonValue ? formatCollectionJson(value) : display,
jsonValue,
identityValue:
(relationDisplayName ||
field?.type === 'identity' ||
field?.showIdentityCopy ||
field?.staffRelation?.showIdentityCopy) &&
value
? String(value)
: '',
relationResource:
field?.relation ??
field?.dynamicRelation?.resources[
String(row[field.dynamicRelation.parentKey] ?? '')
] ??
contract.relationResources?.[key] ??
'',
wide:
objectValue ||
jsonValue ||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
},
];
@@ -246,7 +273,10 @@ const entries = computed<DetailEntry[]>(() => {
const collections = computed(() =>
Object.entries(props.detail)
.filter(([, value]) => Array.isArray(value) && value.length > 0)
.map(([key, value]) => {
.flatMap(([key, value]) => {
const contract = resourceDetailContract(props.definition.name)
.collections?.[key];
if (!contract) return [];
const rows = value as ResourceRow[];
const availableColumns = [
...new Set(
@@ -261,56 +291,15 @@ const collections = computed(() =>
),
),
];
const preferredColumns: Record<string, string[]> = {
'gasorder_contract.products': [
'bound_at',
'product_name',
'product_code',
'product_type_name',
'unit_price',
'product_info_identity',
'unbound_at',
'unbind_reason',
],
'gasorder_basic.items': [
'product_name',
'product_code',
'product_type_name',
'product_params',
'unit_price',
],
'gasorder_basic.assignments': [
'assigned_at',
'gas_basic_display_name',
'delivery_basic_display_name',
'staff_account_display_name',
'assigner_name',
'reason',
],
'gasorder_basic.statuses': [
'occurred_at',
'from_status',
'to_status',
'operator_name',
'reason',
],
};
const preferred =
preferredColumns[`${props.definition.name}.${key}`] ?? [];
const fixedColumns = ['assignments', 'items', 'statuses'].includes(key);
const columns = (
fixedColumns
? preferred
: [...new Set([...preferred, ...availableColumns])]
)
.filter((column) => availableColumns.includes(column))
.slice(0, 8);
return {
const columns = contract.columns.filter((column) =>
availableColumns.includes(column),
);
return [{
key,
title: resourceFieldLabel(props.definition, key),
rows,
columns,
};
}];
}),
);
</script>

View File

@@ -11,6 +11,7 @@ import {
} from '@/api/resource-staff-relation';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import { fieldRelationResource } from '@/api/resource-display';
import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy';
export type ResourceRelationLoadOptions = {
@@ -133,13 +134,14 @@ export function useResourceRelations(
/** 补载表单或详情中已经保存的关系值,避免分页和筛选导致回显裸标识。 */
async function ensureValues(fields: ResourceField[], values: ResourceRow) {
const requests = fields.flatMap((field) => {
if (!field.relation) return [];
const resource = fieldRelationResource(field, values);
if (!resource) 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));
.map((identity) => ensure(resource, identity));
});
await Promise.all(requests);
}

View File

@@ -1,7 +1,8 @@
/* 功能描述标准资源列表工具栏、分页和辅助文本布局。版本v1.0.0。 */
/* 功能描述:标准资源列表工具栏、响应式表格、分页和辅助文本布局。版本v1.1.0。 */
.filters { flex: 1 1 420px; }
.list-toolbar { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px 24px; margin-bottom: 16px; }
.list-actions { margin-left: auto; }
.workflow-alert { margin-bottom: 16px; }
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
.muted-text { color: var(--color-text-3); }
.resource-table-shell { width: 100%; min-width: 0; max-width: 100%; overflow-x: auto; }

View File

@@ -1,6 +1,6 @@
<!--
功能展示标准资源列表并将新建详情和编辑入口导航到独立页面
版本v2.2.1
版本v2.3.0
-->
<template>
<a-card :title="listTitle" :bordered="false">
@@ -32,12 +32,9 @@
</a-space>
</div>
<div class="resource-table-shell">
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="ID" data-index="id" :width="80" />
<a-table-column title="唯一标识" :width="150">
<template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template>
</a-table-column>
<a-table-column
v-for="field in displayFields"
:key="field.key"
@@ -61,12 +58,16 @@
:name="relationListName(field, record, relations.options)"
:identity="identityFieldValue(field, record)"
:identity-label="field.label"
:clickable="Boolean(fieldRelationResource(field, record))"
@open="openRelation(field, record)"
/>
<RelationNameText
v-else-if="field.listDisplayIdentityCopy && identityFieldValue(field, record)"
:name="displayListField(field, record)"
:identity="identityFieldValue(field, record)"
:identity-label="field.label"
:clickable="Boolean(fieldRelationResource(field, record))"
@open="openRelation(field, record)"
/>
<IdentityText
v-else-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
@@ -110,6 +111,9 @@
</a-tag>
</template>
</a-table-column>
<a-table-column title="系统唯一标识" :width="170">
<template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template>
</a-table-column>
<a-table-column title="操作" :width="definition.accountManagement ? 360 : 285" fixed="right">
<template #cell="{ record }">
<a-space>
@@ -143,6 +147,7 @@
</a-table-column>
</template>
</a-table>
</div>
<div class="pagination">
<a-pagination
:total="total"
@@ -182,6 +187,7 @@ import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
displayResourceField,
fieldRelationResource,
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
@@ -409,6 +415,24 @@ function openStatus(row: ResourceRow) {
statusVisible.value = true;
}
/** 打开列表关系字段对应的详情页;树形资源没有详情路由时保持只读展示。 */
function openRelation(field: ResourceField, row: ResourceRow) {
const resource = fieldRelationResource(field, row);
if (!resource) return;
const target = router
.getRoutes()
.find(
(item) =>
item.meta.resource === resource &&
item.meta.recordMode === 'detail',
);
if (!target?.name) return;
void router.push({
name: target.name,
params: { identity: identityFieldValue(field, row) },
});
}
async function saveStatus() {
if (![1, 2].includes(Number(statusTarget.value))) {
Message.warning('请选择目标状态');

View File

@@ -1,8 +1,14 @@
<!-- 功能描述列表以关系名称为主展示并提供完整唯一标识的悬停查看与复制版本v1.0.0 -->
<!-- 功能描述以关系名称为主展示支持进入关联详情并复制完整唯一标识版本v1.1.0 -->
<template>
<div class="relation-name-text">
<a-tooltip :content="`${identityLabel}${identity}`">
<span class="relation-name">{{ displayName }}</span>
<button
v-if="clickable"
class="relation-name relation-link"
type="button"
@click.stop="emit('open')"
>{{ displayName }}</button>
<span v-else class="relation-name">{{ displayName }}</span>
</a-tooltip>
<a-tooltip :content="`复制${identityLabel}`">
<button
@@ -26,7 +32,9 @@ const props = defineProps<{
name: string;
identity: string;
identityLabel?: string;
clickable?: boolean;
}>();
const emit = defineEmits<{ open: [] }>();
const displayName = computed(() => props.name || props.identity);
/** 复制当前关联记录的完整唯一标识,并给出明确操作反馈。 */
@@ -56,6 +64,16 @@ async function copyIdentity() {
white-space: nowrap;
}
.relation-link {
padding: 0;
color: rgb(var(--primary-6));
font: inherit;
text-align: left;
background: transparent;
border: 0;
cursor: pointer;
}
.copy-button {
display: inline-flex;
flex: 0 0 auto;

View File

@@ -9,7 +9,11 @@
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }">
<template #title="node">
<a-space>
{{ node.title }}
<span>{{ node.title }}</span>
<span v-if="node.group_code || node.path" class="tree-business-key">
{{ node.group_code || node.path }}
</span>
<IdentityText :value="String(node.identity)" />
<a-button v-if="canEdit" size="mini" @click.stop="openEdit(node)">编辑</a-button>
<a-button v-if="canChangeStatus" size="mini" @click.stop="confirmStatus(node)">{{ node.status === 1 ? '停用' : '启用' }}</a-button>
<a-button v-if="canArchive" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
@@ -40,6 +44,7 @@ import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { ResourceUiDefinition } from '@/api/resources';
import { useUserStore } from '@/store';
import IdentityText from '@/components/IdentityText.vue';
type Node = Record<string, unknown> & {
identity: string;
@@ -185,3 +190,10 @@ async function load() {
onMounted(load);
</script>
<style scoped>
.tree-business-key {
color: var(--color-text-3);
font-size: 12px;
}
</style>

View File

@@ -1,17 +1,82 @@
/**
* 功能描述:集中处理标准资源列表字段的关系名称、唯一标识和列宽展示。
* 版本v1.0.0
* 版本v1.1.0
*/
import { optionLabel } from '@/api/resource-display';
import { fieldRelationResource, optionLabel } from '@/api/resource-display';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { isProtectedListAvatarField } from './protected-list-avatar-loader';
const resourceListPriorities: Record<string, string[]> = {
gasorder_basic: [
'order_no', 'request_no', 'order_status', 'gasorder_contract_identity',
'creator_identity', 'user_account_identity',
],
gasorder_contract: [
'contract_no', 'contract_status', 'title', 'user_account_identity',
'gas_basic_identity', 'delivery_basic_identity',
],
payment_order: [
'payment_no', 'payment_status', 'business_type', 'business_identity',
'amount', 'channel',
],
wallet_record: [
'record_no', 'direction', 'trade_type', 'amount',
'balance_after', 'operator_name',
],
ec_order: [
'order_no', 'order_status', 'user_account_identity', 'payable_amount',
'logistics_status', 'paid_at',
],
gasorder_item: [
'product_name', 'product_code', 'product_type_name', 'unit_price',
'gasorder_basic_identity', 'active',
],
gasorder_assign: [
'assigned_at', 'gasorder_basic_identity', 'gas_basic_identity',
'delivery_basic_identity', 'staff_account_identity', 'assigner_name',
],
gasorder_status: [
'occurred_at', 'gasorder_basic_identity', 'from_status', 'to_status',
'operator_name', 'reason',
],
gasorder_contract_revision: [
'occurred_at', 'gasorder_contract_identity', 'action', 'contract_status',
'operator_name', 'reason',
],
};
const generatedListFields: Record<string, ResourceField> = {
// 订单号是业务单号,必须完整展示,不得复用只显示末 12 位的系统标识组件。
order_no: { key: 'order_no', label: '订单号' },
order_status: { key: 'order_status', label: '订单状态' },
contract_status: { key: 'contract_status', label: '合同状态' },
product_name: { key: 'product_name', label: '智能气阀名称' },
user_account_identity: {
key: 'user_account_identity', label: '用户账户', type: 'identity',
relation: '/user_account', listRelationNameOnly: true,
displayRelationLabel: true, showIdentityCopy: true,
},
};
/** 返回标准列表实际渲染的业务字段,搜索提示与表格共同复用该规则。 */
export function resourceListDisplayFields(
definition: ResourceUiDefinition,
): ResourceField[] {
return definition.fields
const fieldsByKey = new Map(
[...Object.values(generatedListFields), ...definition.fields].map(
(field) => [field.key, field],
),
);
const priority = resourceListPriorities[definition.name] ?? [];
const ordered = [
...priority.flatMap((key) => {
const field = fieldsByKey.get(key);
return field ? [field] : [];
}),
...definition.fields.filter((field) => !priority.includes(field.key)),
];
return ordered
.filter((field) => field.key !== 'identity' && field.type !== 'password')
.filter(
(field) =>
@@ -30,17 +95,18 @@ export function identityFieldValue(field: ResourceField, row: ResourceRow) {
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
}
/** 获取列表关系的可读名称,关系未加载时降级为完整唯一标识。 */
/** 获取列表关系的可读名称;加载失败时由唯一标识复制入口保留排障能力。 */
export function relationListName(
field: ResourceField,
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
) {
const identity = identityFieldValue(field, row);
const match = (relationOptions[field.relation ?? ''] ?? []).find(
const resource = fieldRelationResource(field, row);
const match = (relationOptions[resource] ?? []).find(
(option) => String(option.identity) === identity,
);
return match ? optionLabel(match) : identity;
return match ? optionLabel(match) : '名称加载失败';
}
/** 按字段类型返回标准列表列宽。 */