完善配送端详情字段中文化

This commit is contained in:
czl231
2026-08-23 09:24:47 +08:00
parent bebf9b52b7
commit cbaedafaf1
8 changed files with 757 additions and 117 deletions

View File

@@ -0,0 +1,39 @@
# 操作日志:配送点详情字段中文化
操作时间2026-08-23
操作类型:新增、修改
影响模块5176 标准资源详情、合同关联记录、合同动作区
## 操作前状态
详情组件按响应对象键顺序动态展示字段。合同详情出现 `user_account_display_name``user_service_active``bound_at``product_name``product_params` 等英文键合同状态显示数字JSON 参数在表格中被截断。
## 具体操作
1. 新增配送端聚合详情契约,固定合同、订单的主字段和关联记录列序。
2. 建立主详情与集合上下文中文标签,未知响应键默认不展示。
3. 将用户名称合并到用户唯一标识行,将设备和操作人名称与唯一标识组合展示。
4. 增加合同状态、合同动作、订单状态、布尔、金额和时间格式化。
5. 为 JSON 参数增加“查看参数”弹层,支持对象、合法 JSON 字符串和历史普通文本。
6. 明确当前设备名称与合同绑定快照的语义区别。
7. 当签约用户不再属于合同气站时,仅保留终止合同操作。
## 行为变化
- 变更前:未知英文键直接成为标签,子表列序随响应漂移。
- 变更后:只显示具有中文业务契约的字段;合同气瓶和修订记录使用固定列与完整参数查看器。
- 兼容性:不修改 API、数据库和原始业务值未知历史状态或动作仍保留原编码用于排障。
## 验证结果
- `npm.cmd run type:check`:通过。
- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5并覆盖详情白名单、固定列序、关系合并、JSON 查看器和动作限制。
- `npm.cmd run contract:check``npm.cmd run profile:check`:通过。
- `npm.cmd run build`通过2643 个模块完成生产构建。
- 应用内浏览器当前没有已登录的 5176 标签页,自动视觉回归被登录状态阻挡;用户提供的修复前截图已用于定位,不替代修复后回归。
## 风险评估
- 新后端字段默认隐藏:通过自动检查和维护规范要求先补中文契约,避免内部字段意外暴露。
- 当前设备名称可能变化:页面说明其非历史快照,编码和类型继续作为绑定快照展示。
- 子表字段较多:保留固定列宽和横向滚动,避免压缩导致内容不可读。

View File

@@ -87,6 +87,7 @@ npm.cmd run build
- 新增配送人员资质的强制人员范围、上下文展示、关系预填锁定和安全返回链路。
- 新增后端生成的资源搜索契约,隐藏无效搜索并支持枚举中文名称与稳定编码。
- 新增配送合同受控 PDF 附件上传、鉴权预览、下载、替换和移除流程,列表及接口均不暴露内部路径。
- 新增详情字段中文白名单和聚合子表固定契约,未知响应键不再直接回显;合同状态、动作和 JSON 参数对齐 5173。
- 保持配送点资料专用只读页面和现有公共后端接口不变。
## 7. 已知边界

View File

@@ -0,0 +1,39 @@
# 项目文档:配送点详情字段中文化 v1.0
## 1. 项目概述
本项目将 5176 配送点管理端的标准资源详情展示对齐 5173。所有管理员可见字段必须来自显式中文契约后端辅助键、内部 ID 和未知响应字段不得直接成为页面标签。
技术栈Vue 3、TypeScript、Arco Design。功能仅修改展示层和前端动作可见性不修改数据库及聚合详情接口。
## 2. 目录结构
```text
frontend/delivery_admin/src/
├── api/resource-detail-contract.ts # 聚合详情字段、列序和 JSON 契约
├── api/resource-display.ts # 中文标签、状态、动作、金额与时间格式化
├── views/resource/ResourceDetailContent.vue # 白名单详情与关联表渲染
└── views/resource/ResourceRecordPage.vue # 服务关系失效时的动作限制
```
## 3. 核心规则
- 普通详情仅展示资源定义或公共中文字典中已声明的字段,未知响应键默认隐藏。
- `*_display_name` 只作为对应 `*_identity` 的可读名称,不重复显示独立字段。
- 合同状态显示“草稿、待处理、生效中、已过期、已终止”;未知值显示原编码。
- 合同动作显示“启用合同、续签合同、终止合同”;未知动作保留原编码。
- `user_service_active` 显示为“签约用户仍属合同气站:是/否”;为否时只保留终止合同操作。
- 合同气瓶和修订记录使用固定中文列序,超宽表格横向滚动。
- 名称字段与唯一标识组合展示;`product_params` 通过弹层完整查看格式化 JSON。
## 4. 合同气瓶语义
“智能气阀名称”读取当前设备资料,可能随设备改名变化;气瓶编码和气瓶类型名称是合同绑定时保存的快照。页面必须明确说明这一差异,避免把当前名称误认为历史审计快照。
## 5. 维护与验证
后端新增详情字段时,应先在资源字段字典或资源专属详情契约中声明中文名称和展示语义,再开放页面显示。禁止回退到 `Object.keys()` 后直接使用未知键名。修改后运行 `type:check``resource-pages:check``contract:check` 和生产构建,并抽查合同、订单及用户详情。
## 6. 变更记录
- v1.02026-08-23新增配送端详情中文白名单、合同固定子表、JSON 查看器、关系合并和合同动作限制。

View File

@@ -93,6 +93,9 @@ assert(listPage.includes("field.key === 'staff_account_identity'"), '人员范
assert(recordPage.includes('已锁定,不可更换'), '人员资质独立页未锁定所属配送人员');
const attachmentApi = read('src/api/contract-attachment.ts');
const attachmentState = read('src/views/resource/use-contract-attachment.ts');
const detailContent = read('src/views/resource/ResourceDetailContent.vue');
const detailContract = read('src/api/resource-detail-contract.ts');
const resourceDisplay = read('src/api/resource-display.ts');
assert(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染');
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
@@ -101,5 +104,19 @@ assert(attachmentApi.includes('/gasorder_contract/attachment/upload'), '前端
assert(attachmentApi.includes('/:identity') === false, '前端附件接口不得拼接路由模板字面量');
assert(attachmentState.includes('delete payload.file_uri'), '新版管理端仍可能提交裸附件 URI');
assert(attachmentState.includes('payload.attachment_receipt'), '附件保存未提交签名收据');
assert(
detailContract.indexOf("'bound_at'") < detailContract.indexOf("'product_name'") &&
detailContract.indexOf("'product_name'") < detailContract.indexOf("'product_code'"),
'合同气瓶缺少固定业务列序',
);
assert(detailContract.includes("relationIdentityKeys: { product_name: 'product_info_identity' }"), '智能气阀名称未合并唯一标识');
assert(detailContract.includes("relationIdentityKeys: { operator_name: 'operator_identity' }"), '合同修订操作人未合并唯一标识');
assert(detailContent.includes("key.endsWith('_display_name')"), '详情页仍可能重复显示英文辅助名称字段');
assert(detailContent.includes('hasDetailFieldLabel'), '详情页未启用中文字段白名单');
assert(detailContent.includes('查看参数'), '关联记录缺少完整 JSON 查看入口');
assert(detailContent.includes('智能气阀名称取当前设备资料'), '合同气瓶名称与快照语义未说明');
assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签');
assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化');
assert(recordPage.includes("record.value.user_service_active === false"), '失效服务关系下未限制合同动作');
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);

View File

@@ -0,0 +1,186 @@
/**
* 功能描述:声明配送端聚合详情的固定字段顺序、关联表列和 JSON 展示规则。
* 版本v1.0.0。
*/
export type ResourceCollectionContract = {
columns: string[];
jsonColumns?: string[];
relationIdentityKeys?: Record<string, string>;
};
export type ResourceDetailContract = {
leadingKeys?: string[];
hiddenKeys?: string[];
collections?: Record<string, ResourceCollectionContract>;
};
const contracts: Record<string, ResourceDetailContract> = {
gasorder_contract: {
leadingKeys: [
'contract_no',
'contract_status',
'title',
'user_account_identity',
'user_service_active',
'gas_basic_identity',
'delivery_basic_identity',
'default_delivery_fee',
'signed_at',
'effective_at',
'expired_at',
'terms',
'identity',
'status',
'created_at',
'updated_at',
],
collections: {
products: {
columns: [
'bound_at',
'product_name',
'product_code',
'product_type_name',
'product_params',
'unit_price',
'unbound_at',
'unbind_reason',
],
jsonColumns: ['product_params'],
relationIdentityKeys: { product_name: 'product_info_identity' },
},
revisions: {
columns: [
'occurred_at',
'action',
'contract_status',
'effective_at',
'expired_at',
'operator_name',
'reason',
],
relationIdentityKeys: { operator_name: 'operator_identity' },
},
},
},
gasorder_basic: {
leadingKeys: [
'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',
],
collections: {
items: {
columns: [
'product_name',
'product_code',
'product_type_name',
'product_params',
'unit_price',
],
jsonColumns: ['product_params'],
},
assignments: {
columns: [
'assigned_at',
'gas_basic_display_name',
'delivery_basic_display_name',
'staff_account_display_name',
'assigner_name',
'reason',
],
},
statuses: {
columns: [
'occurred_at',
'from_status',
'to_status',
'operator_name',
'reason',
],
},
tracks: {
columns: [
'attempt_no',
'staff_account_display_name',
'started_at',
'completed_at',
],
relationIdentityKeys: {
staff_account_display_name: 'staff_account_identity',
},
},
confirmations: {
columns: [
'confirmed_at',
'confirm_type',
'recipient_name',
'recipient_phone',
'remark',
],
},
payments: {
columns: [
'attempt_no',
'payment_no',
'payment_order_identity',
'amount',
'payment_status',
],
},
},
},
};
const jsonFieldKeys = new Set([
'params',
'product_params',
'product_snapshot',
'args',
'callback_msg',
]);
/** 返回资源专属详情契约;普通资源使用中文字段白名单动态排序。 */
export function resourceDetailContract(name: string): ResourceDetailContract {
return contracts[name] ?? {};
}
/** 判断主详情字段是否使用完整 JSON 查看器。 */
export function isResourceJsonField(key: string) {
return jsonFieldKeys.has(key);
}
/** 返回集合名称字段对应的稳定唯一标识字段。 */
export function collectionRelationIdentityKey(
resourceName: string,
collectionKey: string,
column: string,
) {
return (
contracts[resourceName]?.collections?.[collectionKey]
?.relationIdentityKeys?.[column] ?? ''
);
}
/** 判断集合字段是否使用完整 JSON 查看器。 */
export function isCollectionJsonField(
resourceName: string,
collectionKey: string,
column: string,
) {
return Boolean(
contracts[resourceName]?.collections?.[
collectionKey
]?.jsonColumns?.includes(column),
);
}

View File

@@ -1,10 +1,13 @@
/**
* 功能描述:统一配送点资源独立页面的字段名称和值展示。
* 版本v1.0.0。
* 版本v1.1.0。
*/
import dayjs from 'dayjs';
import { resourceFieldLabel, type ResourceUiDefinition } from './resources';
import type { ResourceRow } from './resource-record-form';
import {
resourceFieldLabel as defaultResourceFieldLabel,
type ResourceUiDefinition,
} from './resources';
const aliases: Record<string, string> = {
id: 'ID',
@@ -19,34 +22,175 @@ const aliases: Record<string, string> = {
payments: '支付记录',
revisions: '修订记录',
products: '合同气瓶',
addresses: '用户地址',
contract: '合同',
order: '订单',
user_service_active: '签约用户仍属合同气站',
user_account_display_name: '用户账户',
gas_basic_display_name: '气站',
delivery_basic_display_name: '配送点',
staff_account_display_name: '配送人员',
assigner_name: '分配人',
};
/** 聚合子表字段按父资源和集合覆盖全局同名字段语义。 */
const collectionAliases: Record<string, Record<string, string>> = {
'gasorder_contract.products': {
bound_at: '绑定时间',
product_name: '智能气阀名称',
product_code: '气瓶编码',
product_type_name: '气瓶类型名称',
product_params: '气瓶规格参数',
unit_price: '约定充装单价(元)',
unbound_at: '解绑时间',
unbind_reason: '解绑原因',
},
'gasorder_contract.revisions': {
occurred_at: '发生时间',
action: '合同动作',
contract_status: '合同状态',
effective_at: '生效时间',
expired_at: '到期时间',
operator_name: '操作人',
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: '原因',
},
'gasorder_basic.tracks': {
attempt_no: '配送尝试次数',
staff_account_display_name: '配送人员',
started_at: '开始时间',
completed_at: '完成时间',
},
'gasorder_basic.confirmations': {
confirmed_at: '确认时间',
confirm_type: '确认方式',
recipient_name: '签收人姓名',
recipient_phone: '签收人电话',
remark: '备注',
},
'gasorder_basic.payments': {
attempt_no: '支付尝试次数',
payment_no: '支付单号',
payment_order_identity: '支付记录唯一标识',
amount: '支付金额(元)',
payment_status: '支付状态',
},
};
const moneyKeys = new Set([
'amount', 'unit_price', 'balance', 'withdrawal_balance', 'delivery_fee',
'discount_amount', 'total_amount', 'default_delivery_fee', 'fee',
'amount',
'unit_price',
'balance',
'withdrawal_balance',
'delivery_fee',
'discount_amount',
'total_amount',
'default_delivery_fee',
'fee',
]);
/** 返回聚合详情中的主记录。 */
export function primaryRecord(detail: ResourceRow): ResourceRow {
for (const key of ['order', 'contract', 'track', 'user']) {
if (detail[key] && typeof detail[key] === 'object') return detail[key] as ResourceRow;
if (detail[key] && typeof detail[key] === 'object')
return detail[key] as ResourceRow;
}
return detail;
}
/** 返回字段中文名称。 */
export function detailFieldLabel(key: string) {
return aliases[key] ?? resourceFieldLabel(key);
export function detailFieldLabel(
definition: ResourceUiDefinition,
key: string,
collectionKey = '',
) {
const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key;
return (
collectionAliases[`${definition.name}.${collectionKey}`]?.[normalized] ??
definition.fields.find((field) => field.key === normalized)?.label ??
aliases[normalized] ??
defaultResourceFieldLabel(normalized)
);
}
/** 判断字段是否具有明确中文契约,未知响应键不得直接回显。 */
export function hasDetailFieldLabel(
definition: ResourceUiDefinition,
key: string,
collectionKey = '',
) {
const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key;
return detailFieldLabel(definition, key, collectionKey) !== normalized;
}
/** 返回通用记录状态名称。 */
export function recordStatusLabel(status: number) {
return { 0: '待审核', 1: '启用', 2: '停用', 3: '已归档', 4: '已冻结' }[status] ?? '未知状态';
return (
{ 0: '待审核', 1: '启用', 2: '停用', 3: '已归档', 4: '已冻结' }[status] ??
`未知状态(${status}`
);
}
/** 返回通用记录状态颜色。 */
export function recordStatusColor(status: number) {
return { 0: 'orange', 1: 'green', 2: 'red', 3: 'gray', 4: 'purple' }[status] ?? 'gray';
return (
{ 0: 'orange', 1: 'green', 2: 'red', 3: 'gray', 4: 'purple' }[status] ??
'gray'
);
}
/** 返回合同业务状态名称。 */
export function contractStatusLabel(status: number) {
return (
{
0: '草稿',
10: '待处理',
11: '生效中',
12: '已过期',
13: '已终止',
}[status] ?? `未知状态(${status}`
);
}
/** 返回合同业务状态颜色。 */
export function contractStatusColor(status: number) {
return (
{ 0: 'gray', 10: 'orange', 11: 'green', 12: 'red', 13: 'gray' }[status] ??
'gray'
);
}
/** 返回合同修订动作名称。 */
export function contractActionLabel(action: unknown) {
const value = String(action);
return (
{
activate: '启用合同',
renew: '续签合同',
terminate: '终止合同',
}[value] ?? `未知动作(${value}`
);
}
/** 将接口字段值转换为中文可读内容。 */
@@ -57,17 +201,53 @@ export function displayResourceValue(
) {
if (value == null || value === '') return '-';
const field = definition.fields.find((item) => item.key === key);
const option = field?.options?.find((item) => String(item.value) === String(value));
const option = field?.options?.find(
(item) => String(item.value) === String(value),
);
if (option) return option.label;
if (key === 'role_code') {
return { delivery: '配送人员', installer: '安装人员', operations: '运维人员' }[String(value)]
?? String(value);
return (
{ delivery: '配送人员', installer: '安装人员', operations: '运维人员' }[
String(value)
] ?? String(value)
);
}
if (typeof value === 'boolean') return value ? '是' : '否';
if (key === 'status') return recordStatusLabel(Number(value));
if (moneyKeys.has(key) || key.endsWith('_amount') || key.endsWith('_balance_after')) {
if (key === 'contract_status') return contractStatusLabel(Number(value));
if (key === 'action') return contractActionLabel(value);
if (
[
'order_status',
'previous_order_status',
'from_status',
'to_status',
].includes(key)
) {
return (
{
0: '草稿',
16: '已创建',
18: '已分配',
19: '充装中',
20: '已就绪',
21: '异常',
22: '已取消',
23: '已完成',
33: '配送中',
34: '待确认',
}[Number(value)] ?? `未知状态(${String(value)}`
);
}
if (
moneyKeys.has(key) ||
key.endsWith('_amount') ||
key.endsWith('_balance_after')
) {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : String(value);
return Number.isFinite(amount)
? `¥${(amount / 100).toFixed(2)}`
: String(value);
}
if (key.endsWith('_at') || key === 'created_at' || key === 'updated_at') {
const date = dayjs(String(value));
@@ -77,10 +257,23 @@ export function displayResourceValue(
return String(value);
}
/** 判断软删除时间是否为空对象或空值。 */
export function isEmptyDeletedAt(value: unknown) {
if (value == null || value === '') return true;
if (typeof value !== 'object') return false;
return Object.values(value as Record<string, unknown>).every(
(item) => item == null || item === '' || item === false || item === 0,
);
}
/** 返回合同编辑阻止原因。 */
export function recordEditReason(definition: ResourceUiDefinition, row: ResourceRow) {
export function recordEditReason(
definition: ResourceUiDefinition,
row: ResourceRow,
) {
if (definition.name !== 'gasorder_contract') return '';
if (Number(row.contract_status) !== 0) return '只有草稿状态的合同可以编辑';
if (![0, 1].includes(Number(row.status))) return '停用、已归档或已冻结的合同不可编辑';
if (![0, 1].includes(Number(row.status)))
return '停用、已归档或已冻结的合同不可编辑';
return '';
}

View File

@@ -1,44 +1,91 @@
<!-- 功能描述以信息表和集合页签展示配送点资源详情版本v1.0.0 -->
<!-- 功能描述按中文白名单和固定子表契约展示配送点资源详情版本v1.1.0 -->
<template>
<div class="detail-stack">
<a-card title="基本信息" :bordered="false" class="detail-card">
<div class="detail-grid">
<div
v-for="[key, value] in entries"
:key="key"
v-for="entry in entries"
:key="entry.key"
class="detail-item"
:class="{ 'detail-item-wide': wideKeys.has(key) || isObject(value) }"
:class="{ 'detail-item-wide': entry.wide }"
>
<span class="detail-label">{{ detailFieldLabel(key) }}</span>
<a-popover v-if="isObject(value)" position="left">
<span class="detail-label">{{ entry.label }}</span>
<a-popover v-if="entry.jsonValue" position="left">
<a-button type="text" size="mini">查看内容</a-button>
<template #content>
<pre class="json-value">{{ displayResourceValue(definition, key, value) }}</pre>
</template>
<template #content><pre class="json-value">{{ entry.value }}</pre></template>
</a-popover>
<IdentityText v-else-if="isIdentity(key, value)" :value="String(value)" />
<span v-else class="detail-value">{{ displayResourceValue(definition, key, value) }}</span>
<a-tag
v-else-if="entry.key === 'contract_status'"
:color="contractStatusColor(Number(entry.rawValue))"
>{{ entry.value }}</a-tag>
<a-tag
v-else-if="entry.key === 'user_service_active'"
:color="entry.rawValue === true ? 'green' : 'red'"
>{{ entry.value }}</a-tag>
<IdentityText
v-else-if="entry.key === 'identity' || (entry.identityValue && !entry.relationName)"
:value="entry.identityValue || String(entry.rawValue)"
/>
<RelationNameText
v-else-if="entry.identityValue && entry.relationName"
:name="entry.relationName"
:identity="entry.identityValue"
:identity-label="entry.label"
/>
<span v-else class="detail-value">{{ entry.value }}</span>
</div>
</div>
</a-card>
<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="detailFieldLabel(collection.key)">
<a-tab-pane
v-for="collection in collections"
:key="collection.key"
:title="collection.title"
>
<a-alert
v-if="definition.name === 'gasorder_contract' && collection.key === 'products'"
class="collection-note"
type="info"
show-icon
>
智能气阀名称取当前设备资料气瓶编码和类型名称为合同绑定快照
</a-alert>
<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="detailFieldLabel(column)"
:width="column.endsWith('_at') ? 150 : 180"
:title="detailFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(collection.key, column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<IdentityText v-if="isIdentity(column, record[column])" :value="String(record[column])" />
<template v-else>{{ displayResourceValue(definition, column, record[column]) }}</template>
<template #cell="{ record: row }">
<RelationNameText
v-if="collectionIdentityKey(collection.key, column) && row[collectionIdentityKey(collection.key, column)]"
:name="displayResourceValue(definition, column, row[column])"
:identity="String(row[collectionIdentityKey(collection.key, column)])"
:identity-label="detailFieldLabel(definition, column, collection.key)"
/>
<IdentityText
v-else-if="column.includes('identity') && row[column]"
:value="String(row[column])"
/>
<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">{{ formatJson(row[column]) }}</pre>
</template>
</a-popover>
<template v-else>
{{ displayResourceValue(definition, column, row[column]) }}
</template>
</template>
</a-table-column>
</template>
@@ -53,89 +100,202 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
contractStatusColor,
detailFieldLabel,
displayResourceValue,
hasDetailFieldLabel,
isEmptyDeletedAt,
primaryRecord,
} from '@/api/resource-display';
import {
collectionRelationIdentityKey,
isCollectionJsonField,
isResourceJsonField,
resourceDetailContract,
} from '@/api/resource-detail-contract';
import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import RelationNameText from '@/views/shared/RelationNameText.vue';
const props = defineProps<{ definition: ResourceUiDefinition; detail: ResourceRow }>();
const wideKeys = new Set(['address', 'terms', 'remark', 'reason', 'content', 'body']);
type DetailEntry = {
key: string;
label: string;
value: string;
rawValue: unknown;
jsonValue: boolean;
wide: boolean;
identityValue: string;
relationName: string;
};
const props = defineProps<{
definition: ResourceUiDefinition;
detail: ResourceRow;
}>();
/** 返回聚合记录中标识字段对应的服务端可读名称。 */
function relationDisplayName(row: ResourceRow, key: string) {
const conventionalKey = key.endsWith('_identity')
? `${key.slice(0, -9)}_display_name`
: '';
const displayKey =
{
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',
creator_identity: 'creator_display_name',
operator_identity: 'operator_name',
}[key] ?? conventionalKey;
return displayKey ? String(row[displayKey] ?? '').trim() : '';
}
/** 将对象、合法 JSON 字符串或历史普通文本转换为完整可读内容。 */
function formatJson(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 collectionIdentityKey(collectionKey: string, column: string) {
return collectionRelationIdentityKey(
props.definition.name,
collectionKey,
column,
);
}
/** 保持日期紧凑、关系可复制、JSON 按钮清晰,其余列使用业务可读宽度。 */
function collectionColumnWidth(collectionKey: string, column: string) {
if (column.endsWith('_at')) return 170;
if (collectionIdentityKey(collectionKey, column)) return 210;
if (column.includes('identity')) return 210;
if (isCollectionJsonField(props.definition.name, collectionKey, column))
return 120;
return 160;
}
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const contract = resourceDetailContract(props.definition.name);
const excluded = new Set([
'id',
'password',
'password_hash',
'avatar',
'attachment',
...(contract.hiddenKeys ?? []),
]);
const preferred = [
...(contract.leadingKeys ?? ['identity', 'status']),
...props.definition.fields.map((field) => field.key),
'created_at',
'updated_at',
];
return [...new Set([...preferred, ...Object.keys(row)])].flatMap((key) => {
// 脱敏辅助键只作为原业务字段的回退值,不能再生成重复详情项。
if (
key.endsWith('_masked') &&
props.definition.fields.some((field) => field.key === key.slice(0, -7))
)
return [];
const maskedKey = `${key}_masked`;
const actualKey = Object.prototype.hasOwnProperty.call(row, key)
? key
: Object.prototype.hasOwnProperty.call(row, maskedKey)
? maskedKey
: '';
if (
!actualKey ||
excluded.has(key) ||
key.endsWith('_id') ||
key.endsWith('_display_name') ||
!hasDetailFieldLabel(props.definition, actualKey)
)
return [];
const rawValue = row[actualKey];
if (Array.isArray(rawValue)) return [];
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(rawValue))
return [];
const relationName = relationDisplayName(row, key);
const jsonValue =
isResourceJsonField(key) ||
Boolean(rawValue && typeof rawValue === 'object');
const value =
relationName ||
(jsonValue
? formatJson(rawValue)
: displayResourceValue(props.definition, actualKey, rawValue));
const field = props.definition.fields.find((item) => item.key === key);
const identityValue =
rawValue &&
(relationName ||
key === 'identity' ||
key.endsWith('_identity') ||
field?.type === 'identity' ||
field?.showIdentityCopy)
? String(rawValue)
: '';
return [
{
key,
label: detailFieldLabel(props.definition, actualKey),
value,
rawValue,
jsonValue,
identityValue,
relationName,
wide:
jsonValue ||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
},
];
});
});
const record = computed(() => primaryRecord(props.detail));
const entries = computed(() =>
Object.entries(record.value).filter(
([key, value]) =>
key !== 'id' &&
key !== 'avatar' &&
key !== 'password' &&
key !== 'password_hash' &&
key !== 'DeletedAt' &&
key !== 'deleted_at' &&
!key.endsWith('_id') &&
!key.endsWith('_masked') &&
!Array.isArray(value),
),
);
const collections = computed(() =>
Object.entries(props.detail)
.filter(([, value]) => Array.isArray(value) && value.length > 0)
.map(([key, value]) => {
.flatMap(([key, value]) => {
const rows = value as ResourceRow[];
const columns = [
...new Set(
rows.flatMap((row) =>
Object.keys(row).filter(
(column) => column !== 'id' && column !== 'DeletedAt' &&
column !== 'deleted_at' && !column.endsWith('_id'),
),
),
),
].slice(0, 10);
return { key, rows, columns };
const collectionContract = resourceDetailContract(props.definition.name)
.collections?.[key];
if (!collectionContract && !hasDetailFieldLabel(props.definition, key))
return [];
const available = [...new Set(rows.flatMap((row) => Object.keys(row)))];
const columns = (collectionContract?.columns ?? available).filter(
(column) =>
available.includes(column) &&
column !== 'id' &&
!column.endsWith('_id') &&
!['deleted_at', 'DeletedAt'].includes(column) &&
hasDetailFieldLabel(props.definition, column, key),
);
if (!columns.length) return [];
return [
{ key, title: detailFieldLabel(props.definition, key), rows, columns },
];
}),
);
/** 判断字段是否应使用可复制唯一标识组件。 */
function isIdentity(key: string, value: unknown) {
return Boolean(value && (key === 'identity' || key.endsWith('_identity')));
}
/** 判断详情值是否需要折叠到弹出层查看。 */
function isObject(value: unknown) {
return Boolean(value && typeof value === 'object');
}
</script>
<style scoped>
.detail-stack {
display: grid;
width: 100%;
min-width: 0;
gap: 20px;
}
.detail-card {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
border-radius: 10px;
}
.detail-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.detail-card :deep(.arco-card-body) {
min-width: 0;
padding: 20px 24px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 42px;
}
<style scoped lang="less">
.detail-stack { display: grid; width: 100%; min-width: 0; gap: 20px; }
.detail-card { width: 100%; min-width: 0; max-width: 100%; overflow: hidden; border-radius: 10px; }
.detail-card :deep(.arco-card-header) { height: 48px; 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)); gap: 8px 42px; }
.detail-item {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
@@ -148,26 +308,24 @@ function isObject(value: unknown) {
}
.detail-item-wide { grid-column: 1 / -1; }
.detail-label { color: var(--color-text-3); white-space: nowrap; }
.detail-value {
min-width: 0;
.detail-value { min-width: 0; color: var(--color-text-1); white-space: pre-wrap; word-break: break-word; }
.json-value,
.collection-json-value {
min-width: 280px;
max-width: min(560px, 70vw);
max-height: 360px;
margin: 0;
padding: 12px;
overflow: auto;
color: var(--color-text-1);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
background: var(--color-fill-1);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.json-value {
max-height: 320px;
margin: 0;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.collection-table-shell {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.collection-note { margin-bottom: 12px; }
.collection-table-shell { width: 100%; min-width: 0; max-width: 100%; overflow-x: auto; }
@media (max-width: 1280px) {
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

View File

@@ -216,8 +216,15 @@ const canEditRecord = computed(
const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter(
(action) =>
!action.visibleFor ||
action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number),
!(
definition.value.name === 'gasorder_contract' &&
record.value.user_service_active === false &&
action.name !== '终止合同'
) &&
(
!action.visibleFor ||
action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)
),
),
);
const unsaved = useUnsavedRecord(