优化配送订单详情关联与参数展示

This commit is contained in:
czl231
2026-08-15 20:32:43 +08:00
parent 42a4fc2f41
commit a20e228296
14 changed files with 584 additions and 55 deletions

View File

@@ -29,6 +29,7 @@
"gasorder-assignment:check": "node scripts/check-gasorder-assignment-linkage.mjs",
"gasorder-list-display:check": "node scripts/check-gasorder-list-display.mjs",
"gasorder-status-display:check": "node scripts/check-gasorder-status-display.mjs",
"resource-detail-json:check": "node scripts/check-resource-detail-json-display.mjs",
"product-ownership:check": "node scripts/check-product-ownership-action.mjs",
"product-ownership-display:check": "node scripts/check-product-ownership-display.mjs",
"product-lifecycle-display:check": "node scripts/check-product-lifecycle-display.mjs",

View File

@@ -1,10 +1,11 @@
// 功能描述:静态校验配送订单列表优先显示可读创建方,并保留唯一标识复制入口。
// 版本v1.0.0
// 版本v1.1.0
import fs from 'node:fs';
const resources = fs.readFileSync(new URL('../src/api/resources.ts', import.meta.url), 'utf8');
const listPage = fs.readFileSync(new URL('../src/views/shared/CrudListPage.vue', import.meta.url), 'utf8');
const detailPage = fs.readFileSync(new URL('../src/views/resource/ResourceDetailContent.vue', import.meta.url), 'utf8');
const resourceDisplay = fs.readFileSync(new URL('../src/api/resource-display.ts', import.meta.url), 'utf8');
const assertions = [
[resources.includes("gasorder_basic_identity: '配送订单唯一标识'"), '状态记录中的配送订单标识缺少中文名称'],
@@ -13,6 +14,17 @@ const assertions = [
[detailPage.includes("'contract_display_name'"), '订单详情未隐藏重复的合同标题辅助字段'],
[detailPage.includes("column.endsWith('_at')) return 150"), '关联记录的日期时间列未固定为 150px'],
[detailPage.includes("key.endsWith('_masked')"), '详情页未去除重复的脱敏辅助字段'],
[detailPage.includes("'order_no',\n 'request_no',\n 'order_status'"), '订单详情未将订单号置于主要位置'],
[detailPage.includes("'gasorder_basic.assignments'"), '分配记录缺少固定业务列'],
[detailPage.includes("['assignments', 'items', 'statuses'].includes(key)"), '订单关联表仍会泄漏任意英文响应字段'],
[detailPage.includes("'gasorder_basic.statuses'"), '状态记录缺少固定中文业务列'],
[resourceDisplay.includes("active: '是否有效'"), '订单明细 active 字段未中文化'],
[resourceDisplay.includes("String(value) === 'order created'"), '历史订单创建原因未中文化'],
[detailPage.includes("creator_identity: 'creator_display_name'"), '订单创建方未使用可读名称'],
[resourceDisplay.includes("'gasorder_basic.assignments'"), '分配记录缺少专用中文字段名称'],
[resourceDisplay.includes("staff_account_display_name: '配送人员'"), '分配人员名称未中文化'],
[resourceDisplay.includes("identity: '系统唯一标识'"), '订单内部标识仍与业务订单号混用'],
[resourceDisplay.includes("operator_identity: '建单操作人'"), '建单操作人仍以裸标识单独展示'],
[resources.includes("listDisplayKey: 'creator_display_name'"), '创建方未配置可读名称字段'],
[resources.includes('listDisplayIdentityCopy: true'), '创建方未保留唯一标识复制入口'],
[resources.includes("detailDisplayKey: 'contract_display_name'"), '订单详情未配置合同标题展示字段'],

View File

@@ -0,0 +1,23 @@
/**
* 功能:静态检查资源详情关联表中的设备参数可完整查看且不会撑宽表格。
* 版本v1.1.0
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const detail = readFileSync(
new URL('../src/views/resource/ResourceDetailContent.vue', import.meta.url),
'utf8',
);
assert.match(detail, /isCollectionJsonColumn\(column\)/, '关联表 JSON 字段缺少专用展示分支');
assert.match(detail, />查看参数<\/a-button>/, '设备参数缺少完整查看入口');
assert.match(detail, /formatCollectionJson\(record\[column\]\)/, '设备参数没有格式化完整内容');
assert.match(detail, /JSON\.stringify\(JSON\.parse\(text\), null, 2\)/, '字符串 JSON 未格式化');
assert.match(detail, /isCollectionJsonColumn\(column\)\) return 100/, '设备参数列未使用紧凑宽度');
assert.match(detail, /max-width: min\(560px, 70vw\)/, '参数浮层缺少视口宽度限制');
assert.match(detail, /class="collection-table-shell"/, '关联表缺少独立宽度约束容器');
assert.match(detail, /\.detail-stack[\s\S]*?min-width: 0;/, '详情栈仍可能被子内容反向撑宽');
assert.match(detail, /\.collection-table-shell[\s\S]*?overflow-x: auto;/, '关联表缺少内部横向溢出兜底');
console.log('资源详情设备参数检查通过100% 视口不被关联表撑宽,参数可完整查看。');

View File

@@ -1,15 +1,15 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.4.0
* 版本v1.5.0
*/
import dayjs from 'dayjs';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
import { staffRoleLabel } from './resource-staff-relation';
import {
defaultResourceFieldLabel,
type ResourceField,
type ResourceUiDefinition,
} from './resources';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
const aliases: Record<string, string> = {
identity: '唯一标识',
@@ -54,6 +54,35 @@ const collectionFieldAliases: Record<string, Record<string, string>> = {
product_type_name: '设备类型',
product_params: '设备参数',
unit_price: '成交单价(元)',
active: '是否有效',
},
'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 resourceFieldAliases: Record<string, Record<string, string>> = {
gasorder_basic: {
identity: '系统唯一标识',
gas_basic_identity: '履约气站',
delivery_basic_identity: '当前配送点',
staff_account_identity: '当前配送人员',
user_account_identity: '服务用户',
creator_identity: '创建方',
operator_identity: '建单操作人',
},
};
@@ -107,6 +136,9 @@ export function resourceFieldLabel(
return (
collectionAliases?.[normalized] ??
definition.fields.find((field) => field.key === normalized)?.label ??
(collectionKey
? undefined
: resourceFieldAliases[definition.name]?.[normalized]) ??
defaultResourceFieldLabel(normalized) ??
aliases[normalized] ??
key
@@ -186,9 +218,10 @@ export function relationOptionLabel(
const configuredLabel = field.relationOptionLabelKey
? option[field.relationOptionLabelKey]
: undefined;
const label = configuredLabel == null || configuredLabel === ''
? optionLabel(option)
: String(configuredLabel);
const label =
configuredLabel == null || configuredLabel === ''
? optionLabel(option)
: String(configuredLabel);
if (
field.relationOptionDefaultKey &&
option[field.relationOptionDefaultKey]
@@ -253,7 +286,15 @@ export function displayRawValue(key: string, value: unknown) {
if (key === 'status') return recordStatusLabel(Number(value));
if (key === 'contract_status') return contractStatusLabel(Number(value));
if (key === 'previous_order_status' && Number(value) === 0) return '无';
if (['order_status', 'previous_order_status', 'from_status', 'to_status'].includes(key))
if (key === 'reason' && String(value) === 'order created') return '订单创建';
if (
[
'order_status',
'previous_order_status',
'from_status',
'to_status',
].includes(key)
)
return gasorderStatusLabel(Number(value));
if (key === 'action') {
return (
@@ -301,7 +342,9 @@ export function displayResourceField(
Array<{ label: string; value: string | number }>
> = {},
) {
const displayValue = field.detailDisplayKey ? row[field.detailDisplayKey] : undefined;
const displayValue = field.detailDisplayKey
? row[field.detailDisplayKey]
: undefined;
const value = displayValue ?? row[field.key] ?? row[`${field.key}_masked`];
if (value == null || value === '') return field.emptyText ?? '-';
const hasOptionSource =

View File

@@ -1,6 +1,6 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.4.0
版本v1.7.0
-->
<template>
<div class="detail-stack">
@@ -22,23 +22,38 @@
<a-card v-if="collections.length" title="关联记录" :bordered="false" class="detail-card">
<a-tabs>
<a-tab-pane v-for="collection in collections" :key="collection.key" :title="collection.title">
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
<template #columns>
<a-table-column
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<IdentityText v-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
<div class="collection-table-shell">
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
<template #columns>
<a-table-column
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<div v-if="collectionRelationIdentityKey(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)])"
/>
</div>
<IdentityText v-else-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<a-popover v-else-if="isCollectionJsonColumn(column)" position="left">
<a-button type="text" size="mini">查看参数</a-button>
<template #content>
<pre class="collection-json-value">{{ formatCollectionJson(record[column]) }}</pre>
</template>
</a-popover>
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
@@ -47,7 +62,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
displayResourceField,
@@ -55,8 +69,9 @@ import {
primaryRecord,
resourceFieldLabel,
} from '@/api/resource-display';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
const props = defineProps<{
definition: ResourceUiDefinition;
@@ -82,9 +97,55 @@ type DetailEntry = {
function collectionColumnWidth(column: string) {
if (column.endsWith('_at')) return 150;
if (column.includes('identity')) return 220;
if (isCollectionJsonColumn(column)) return 100;
return 160;
}
/** 标识关联子表中需要完整查看的 JSON 快照字段。 */
function isCollectionJsonColumn(column: string) {
return column === 'product_params';
}
/** 将字符串或对象参数格式化为可读 JSON历史非 JSON 文本保持原样。 */
function formatCollectionJson(value: unknown) {
if (value == null || value === '') return '暂无参数';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
const text = String(value).trim();
if (!text) return '暂无参数';
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
/** 返回集合可读名称对应的稳定标识字段。 */
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) {
const displayKey =
{
creator_identity: 'creator_display_name',
user_account_identity: 'user_account_display_name',
gas_basic_identity: 'gas_basic_display_name',
delivery_basic_identity: 'delivery_basic_display_name',
staff_account_identity: 'staff_account_display_name',
operator_identity: 'operator_name',
}[key] ?? '';
if (!displayKey) return '';
return String(row[displayKey] ?? '').trim();
}
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const excluded = new Set([
@@ -99,9 +160,25 @@ const entries = computed<DetailEntry[]>(() => {
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 preferred = [
'identity',
'status',
...leadingKeys,
...props.definition.fields.map((field) => field.key),
'created_at',
'updated_at',
@@ -120,20 +197,30 @@ const entries = computed<DetailEntry[]>(() => {
: Object.prototype.hasOwnProperty.call(row, maskedKey)
? maskedKey
: '';
if (!actualKey || excluded.has(key) || key.endsWith('_id')) return [];
if (
!actualKey ||
excluded.has(key) ||
key.endsWith('_id') ||
key.endsWith('_display_name') ||
(props.definition.name === 'gasorder_basic' && key === 'operator_name')
)
return [];
const value = row[actualKey];
if (Array.isArray(value)) return [];
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
return [];
const field = props.definition.fields.find((item) => item.key === key);
const display = field
? displayResourceField(
field,
row,
props.relationOptions,
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const relationDisplayName = detailRelationDisplayName(row, key);
const display = relationDisplayName
? relationDisplayName
: field
? displayResourceField(
field,
row,
props.relationOptions,
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
return [
{
@@ -142,7 +229,10 @@ const entries = computed<DetailEntry[]>(() => {
value: display,
objectValue,
identityValue:
(field?.showIdentityCopy || field?.staffRelation?.showIdentityCopy) && value
(relationDisplayName ||
field?.showIdentityCopy ||
field?.staffRelation?.showIdentityCopy) &&
value
? String(value)
: '',
wide:
@@ -188,13 +278,31 @@ const collections = computed(() =>
'product_type_name',
'product_params',
'unit_price',
'active',
'product_info_identity',
],
'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 columns = [...new Set([...preferred, ...availableColumns])]
const fixedColumns = ['assignments', 'items', 'statuses'].includes(key);
const columns = (
fixedColumns
? preferred
: [...new Set([...preferred, ...availableColumns])]
)
.filter((column) => availableColumns.includes(column))
.slice(0, 8);
return {
@@ -210,9 +318,15 @@ const collections = computed(() =>
<style scoped lang="less">
.detail-stack {
display: grid;
width: 100%;
min-width: 0;
gap: 12px;
}
.detail-card {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
border-radius: 10px;
}
.detail-card :deep(.arco-card-header) {
@@ -220,8 +334,15 @@ const collections = computed(() =>
padding: 0 24px;
}
.detail-card :deep(.arco-card-body) {
min-width: 0;
padding: 20px 24px;
}
.detail-card :deep(.arco-tabs),
.detail-card :deep(.arco-tabs-content),
.detail-card :deep(.arco-tabs-pane) {
min-width: 0;
max-width: 100%;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -237,6 +358,11 @@ const collections = computed(() =>
padding: 8px 0;
border-bottom: 1px solid var(--color-border-1);
}
.detail-item,
.detail-value,
.detail-relation-value {
min-width: 0;
}
.detail-item-wide {
grid-column: 1 / -1;
}
@@ -263,6 +389,28 @@ const collections = computed(() =>
border-radius: 6px;
white-space: pre-wrap;
}
.collection-json-value {
min-width: 280px;
max-width: min(560px, 70vw);
max-height: 360px;
margin: 0;
overflow: auto;
color: var(--color-text-1);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.collection-table-shell {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.collection-relation-value {
display: grid;
min-width: 0;
gap: 2px;
}
@media (max-width: 1280px) {
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -1,10 +1,13 @@
/*
* 功能:定义标准资源独立页面的布局、卡片和响应式样式。
* 版本v1.3.0
* 版本v1.4.0
*/
.record-page {
display: grid;
align-content: start;
width: 100%;
min-width: 0;
max-width: 100%;
gap: 12px;
min-height: 100%;
padding: 16px 20px 20px;
@@ -25,6 +28,8 @@
}
.section-card,
.form-card {
min-width: 0;
max-width: 100%;
border-radius: 10px;
}
.section-card :deep(.arco-card-header) {