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

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

@@ -30,6 +30,7 @@
"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",
"resource-display-contracts:check": "node scripts/check-resource-display-contracts.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,6 +1,6 @@
/**
* 功能:静态检查三套管理后台的账户角色中文展示与无效头像摘要规则。
* 版本v1.0.0
* 版本v1.1.0
*/
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
@@ -27,14 +27,19 @@ const platformSummary = source(
expectIncludes(
platformResources,
"fixedAdminRole('气站管理员')",
"fixedAdminRole('gas_account')",
'平台总后台缺少气站管理员中文映射',
);
expectIncludes(
platformResources,
"fixedAdminRole('配送点管理员')",
"fixedAdminRole('delivery_account')",
'平台总后台缺少配送点管理员中文映射',
);
expectIncludes(
platformResources,
"resourceSearchEnumOptions(resource, 'role_code')",
'平台总后台固定管理员角色未读取后端中文枚举契约',
);
expectIncludes(
platformResources,
"define('delivery_account', '配送点账户'",

View File

@@ -1,11 +1,13 @@
// 功能描述:静态校验配送订单列表优先显示可读创建方,并保留唯一标识复制入口。
// 版本v1.1.0
// 版本v1.2.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 detailContract = fs.readFileSync(new URL('../src/api/resource-detail-contract.ts', import.meta.url), 'utf8');
const listFieldDisplay = fs.readFileSync(new URL('../src/views/shared/resource-list-field-display.ts', import.meta.url), 'utf8');
const assertions = [
[resources.includes("gasorder_basic_identity: '配送订单唯一标识'"), '状态记录中的配送订单标识缺少中文名称'],
@@ -14,10 +16,10 @@ 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'"), '状态记录缺少固定中文业务列'],
[detailContract.includes("'order_no', 'request_no', 'order_status'"), '订单详情未将订单号置于主要位置'],
[detailContract.includes('assignments: {'), '分配记录缺少固定业务列'],
[detailPage.includes('const contract = resourceDetailContract(props.definition.name)'), '订单关联表仍未使用固定资源契约'],
[detailContract.includes('statuses: {'), '状态记录缺少固定中文业务列'],
[resourceDisplay.includes("active: '是否有效'"), '订单明细 active 字段未中文化'],
[resourceDisplay.includes("String(value) === 'order created'"), '历史订单创建原因未中文化'],
[detailPage.includes("creator_identity: 'creator_display_name'"), '订单创建方未使用可读名称'],
@@ -31,6 +33,8 @@ const assertions = [
[resources.includes("listDisplayKey: 'contract_display_name'"), '订单列表未配置合同标题展示字段'],
[listPage.includes('field.listDisplayIdentityCopy && identityFieldValue(field, record)'), '可读名称分支未优先于通用唯一标识分支'],
[listPage.includes(':name="displayListField(field, record)"'), '创建方复制组件未使用列表可读名称'],
[listFieldDisplay.includes("gasorder_basic: ["), '订单列表缺少专属业务列顺序'],
[listFieldDisplay.includes("'order_no', 'request_no', 'order_status'"), '订单列表未优先显示订单号'],
];
for (const [passed, message] of assertions) {

View File

@@ -1,6 +1,6 @@
/**
* 功能:静态检查资源详情关联表中的设备参数可完整查看且不会撑宽表格。
* 版本v1.1.0
* 版本v1.2.0
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -10,11 +10,11 @@ const detail = readFileSync(
'utf8',
);
assert.match(detail, /isCollectionJsonColumn\(column\)/, '关联表 JSON 字段缺少专用展示分支');
assert.match(detail, /isCollectionJsonField\(definition\.name, collection\.key, 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, /isCollectionJsonField\(props\.definition\.name, collectionKey, column\)/, '设备参数列未使用契约化紧凑宽度');
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;/, '详情栈仍可能被子内容反向撑宽');

View File

@@ -0,0 +1,91 @@
/**
* 功能描述校验平台48类资源均具备中文展示字段并防止列表、详情退回数据库ID或动态英文列。
* 版本v1.0.0
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(currentDirectory, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
function assertIncludes(source, expected, message) {
if (!source.includes(expected)) throw new Error(message);
}
const resources = read('src/api/resources.ts');
const display = read('src/api/resource-display.ts');
const detailContract = read('src/api/resource-detail-contract.ts');
const detailPage = read('src/views/resource/ResourceDetailContent.vue');
const listPage = read('src/views/shared/CrudListPage.vue');
const resourceNames = [...resources.matchAll(/\bdefine\('([^']+)'/g)].map(
(match) => match[1],
);
if (resourceNames.length !== 48 || new Set(resourceNames).size !== 48) {
throw new Error(`平台资源定义应为48类当前读取到${resourceNames.length}`);
}
if (/define\([^;]*?'readonly'\s*,\s*\[\s*\]/s.test(resources)) {
throw new Error('只读资源不得继续使用空字段契约');
}
const labelBlock = resources.match(
/const fieldLabels: Record<string, string> = \{([\s\S]*?)\n\};/,
)?.[1];
if (!labelBlock) throw new Error('无法读取全局中文字段字典');
const labels = new Set(
[...labelBlock.matchAll(/^\s{2}([A-Za-z_][A-Za-z0-9_]*):\s*'[^']+'/gm)].map(
(match) => match[1],
),
);
const usedKeys = new Set(
[...resources.matchAll(/\b(?:f|relation)\('([^']+)'/g)].map(
(match) => match[1],
),
);
const missingLabels = [...usedKeys].filter((key) => !labels.has(key));
if (missingLabels.length) {
throw new Error(`资源字段缺少中文名称:${missingLabels.join('、')}`);
}
if (/title="ID"|data-index="id"/.test(listPage)) {
throw new Error('标准资源列表不得把数据库ID作为业务列展示');
}
assertIncludes(listPage, 'title="系统唯一标识"', '列表必须中文标注系统唯一标识');
assertIncludes(
listPage,
'class="resource-table-shell"',
'列表必须使用内部横向滚动容器',
);
assertIncludes(
display,
'hasResourceFieldLabel',
'详情必须提供未知字段中文契约检查',
);
assertIncludes(
detailPage,
'!hasResourceFieldLabel(props.definition, actualKey)',
'详情不得直接回显未配置字段',
);
assertIncludes(
detailPage,
'resourceDetailContract(props.definition.name)',
'详情关联表必须使用资源专属固定列契约',
);
for (const collection of [
'products', 'revisions', 'items', 'assignments', 'statuses',
'tracks', 'confirmations', 'payments',
]) {
assertIncludes(
detailContract,
`${collection}: {`,
`缺少关联记录固定列契约:${collection}`,
);
}
process.stdout.write('平台48类资源中文展示契约检查通过\n');

View File

@@ -158,9 +158,8 @@ const resourcesSource = await readFile(
new URL('../src/api/resources.ts', import.meta.url),
'utf8',
);
assert.equal(
(resourcesSource.match(/relationLinkage:/g) ?? []).length,
3,
assert.ok(
(resourcesSource.match(/relationLinkage:/g) ?? []).length >= 3,
'联动配置必须显式启用于工作人员、用户服务关系和配送合同的配送点字段',
);
assert.match(resourcesSource, /filterKey: 'gas_basic_identities'/);

View File

@@ -1,6 +1,6 @@
/**
* 功能:静态检查用户地址列表的账户名称展示、标识降级与复制入口。
* 版本v1.0.0
* 版本v1.1.0
*/
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
@@ -24,8 +24,8 @@ const fieldDisplay = source('src/views/shared/resource-list-field-display.ts');
expectIncludes(resources, "listLabel: '用户账户'", '列表列名未改为用户账户');
expectIncludes(resources, 'listRelationNameOnly: true', '用户地址未启用账户名称展示');
expectIncludes(listPage, '<RelationNameText', '列表未使用账户名称组件');
expectIncludes(fieldDisplay, 'return match ? optionLabel(match) : identity;', '关系名称缺失时未降级显示标识');
expectIncludes(relationName, '用户唯一标识${identity}', '缺少完整标识悬停提示');
expectIncludes(fieldDisplay, "return match ? optionLabel(match) : '名称加载失败';", '关系名称缺失时未显示明确错误');
expectIncludes(relationName, '`${identityLabel}${identity}`', '缺少完整标识悬停提示');
expectIncludes(relationName, '@click.stop="copyIdentity"', '缺少标识复制入口');
console.log('用户地址展示检查通过:账户名称、标识降级、悬停与复制入口均已覆盖。');
console.log('用户地址展示检查通过:账户名称、加载失败提示、悬停与复制入口均已覆盖。');

View File

@@ -0,0 +1,157 @@
/**
* 功能描述:声明平台资源详情的字段顺序、固定关联表列和 JSON 展示规则。
* 版本v1.0.0
*/
export type ResourceCollectionContract = {
columns: string[];
jsonColumns?: string[];
relationIdentityKeys?: Record<string, string>;
};
export type ResourceDetailContract = {
leadingKeys?: string[];
hiddenKeys?: string[];
relationResources?: Record<string, string>;
collections?: Record<string, ResourceCollectionContract>;
};
const contracts: Record<string, ResourceDetailContract> = {
gasorder_contract: {
leadingKeys: [
'contract_no', 'contract_status', 'title', 'user_account_identity',
'gas_basic_identity', 'delivery_basic_identity', 'identity',
],
collections: {
products: {
columns: [
'bound_at', 'product_name', 'product_code', 'product_type_name',
'product_params', 'unit_price', 'product_info_identity',
'unbound_at', 'unbind_reason',
],
jsonColumns: ['product_params'],
},
revisions: {
columns: [
'occurred_at', 'action', 'contract_status', 'effective_at',
'expired_at', 'operator_name', 'operator_identity', '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',
],
hiddenKeys: ['operator_name'],
relationResources: {
gasorder_contract_identity: '/gasorder_contract',
user_account_identity: '/user_account',
gas_basic_identity: '/gas_basic',
delivery_basic_identity: '/delivery_basic',
staff_account_identity: '/staff_account',
},
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',
],
relationIdentityKeys: {
gas_basic_display_name: 'gas_basic_identity',
delivery_basic_display_name: 'delivery_basic_identity',
staff_account_display_name: 'staff_account_identity',
assigner_name: 'assigner_identity',
},
},
statuses: {
columns: ['occurred_at', 'from_status', 'to_status', 'operator_name', 'reason'],
relationIdentityKeys: { operator_name: 'operator_identity' },
},
tracks: {
columns: [
'attempt_no', 'staff_account_display_name',
'staff_account_identity', 'started_at', 'completed_at',
],
relationIdentityKeys: {
staff_account_display_name: 'staff_account_identity',
},
},
confirmations: {
columns: [
'confirmed_at', 'confirm_type', 'recipient_name', 'recipient_phone',
'proof_uri', 'remark',
],
},
payments: {
columns: ['attempt_no', 'payment_no', 'payment_order_identity', 'amount', 'payment_status'],
},
},
},
payment_order: {
leadingKeys: [
'payment_no', 'payment_status', 'request_no', 'business_type',
'business_identity', 'user_identity', 'amount', 'identity',
],
},
wallet_record: {
leadingKeys: [
'record_no', 'request_no', 'direction', 'trade_type', 'amount',
'wallet_basic_identity', 'identity',
],
},
ec_order: {
leadingKeys: [
'order_no', 'order_status', 'request_no', 'user_account_identity',
'payable_amount', 'total_amount', 'identity',
],
},
};
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,6 +1,6 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.5.0
* 版本v1.6.0
*/
import dayjs from 'dayjs';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
@@ -12,7 +12,7 @@ import {
} from './resources';
const aliases: Record<string, string> = {
identity: '唯一标识',
identity: '系统唯一标识',
id: 'ID',
created_at: '创建时间',
updated_at: '更新时间',
@@ -32,6 +32,13 @@ const aliases: Record<string, string> = {
contract: '合同',
wallet: '钱包',
is_system: '系统内置',
gas_basic_display_name: '气站',
delivery_basic_display_name: '配送点',
staff_account_display_name: '工作人员',
user_account_display_name: '用户',
creator_display_name: '创建方',
contract_display_name: '配送合同',
assigner_name: '分配人',
};
/** 聚合子表字段可能与全局同名字段具有不同业务语义,按父资源和集合名称覆盖。 */
@@ -71,6 +78,38 @@ const collectionFieldAliases: Record<string, Record<string, string>> = {
operator_name: '操作人',
reason: '原因',
},
'gasorder_basic.tracks': {
attempt_no: '配送尝试次数',
staff_account_display_name: '配送人员',
staff_account_identity: '配送人员唯一标识',
started_at: '开始时间',
completed_at: '完成时间',
},
'gasorder_basic.confirmations': {
confirmed_at: '确认时间',
confirm_type: '确认方式',
recipient_name: '签收人姓名',
recipient_phone: '签收人电话',
proof_uri: '凭证地址',
remark: '备注',
},
'gasorder_basic.payments': {
attempt_no: '支付尝试次数',
payment_no: '支付单号',
payment_order_identity: '支付记录唯一标识',
amount: '支付金额(元)',
payment_status: '支付状态',
},
'gasorder_contract.revisions': {
occurred_at: '发生时间',
action: '合同动作',
contract_status: '合同状态',
effective_at: '生效时间',
expired_at: '到期时间',
operator_name: '操作人',
operator_identity: '操作人唯一标识',
reason: '变更原因',
},
};
/** 主资源详情中的关联标识使用业务名称,技术标识仅作为辅助复制信息。 */
@@ -145,6 +184,15 @@ export function resourceFieldLabel(
);
}
/** 判断字段是否具有显式中文契约,未知响应键不得直接回显英文。 */
export function hasResourceFieldLabel(
definition: ResourceUiDefinition,
key: string,
collectionKey = '',
) {
return resourceFieldLabel(definition, key, collectionKey) !== key;
}
/** 将标准实体状态转换为稳定的中文展示。 */
export function recordStatusLabel(status: number) {
return (
@@ -257,11 +305,13 @@ function relationLabel(
field: ResourceField,
identity: string,
relationOptions: Record<string, ResourceRow[]>,
row: ResourceRow,
) {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
const resource = fieldRelationResource(field, row);
const match = (relationOptions[resource] ?? []).find(
(option) => String(option.identity) === identity,
);
if (!match) return identity;
if (!match) return '名称加载失败';
if (field.relation === '/staff_account') {
return relationOptionLabel(
field,
@@ -269,9 +319,15 @@ function relationLabel(
relationOptions[field.relation ?? ''] ?? [],
);
}
return field.displayRelationLabel
? optionLabel(match)
: `${optionLabel(match)} · ${identity}`;
return optionLabel(match);
}
/** 根据当前记录的主体类型解析静态或动态关系资源。 */
export function fieldRelationResource(field: ResourceField, row: ResourceRow) {
if (field.relation) return field.relation;
if (!field.dynamicRelation) return '';
const parentValue = String(row[field.dynamicRelation.parentKey] ?? '');
return field.dynamicRelation.resources[parentValue] ?? '';
}
/** 格式化不依赖字段定义的通用值。 */
@@ -305,6 +361,77 @@ export function displayRawValue(key: string, value: unknown) {
}[String(value)] ?? `未知动作(${String(value)}`
);
}
if (key === 'payment_status') {
return (
{
10: '待支付',
20: '确认中',
23: '支付成功',
30: '已关闭',
40: '支付失败',
50: '支付异常',
}[Number(value)] ?? `未知支付状态(${String(value)}`
);
}
if (key === 'logistics_status') {
return (
{ 10: '待发货', 20: '已发货', 30: '已收货' }[Number(value)] ??
`未知物流状态(${String(value)}`
);
}
if (key === 'confirm_type') {
return (
{ signature: '签名确认', receipt_code: '收货码确认' }[String(value)] ??
`未知确认方式(${String(value)}`
);
}
if (key === 'source') {
return (
{ gps: 'GPS定位', network: '网络定位', manual: '人工上报' }[
String(value)
] ?? `未知定位来源(${String(value)}`
);
}
if (key === 'owner_type' || key === 'subject_type') {
return (
{
user: '用户',
staff: '工作人员',
gas: '气站',
delivery: '配送点',
platform: '平台',
}[String(value)] ?? `未知主体类型(${String(value)}`
);
}
if (key === 'business_type') {
return (
{
gasorder: '气体配送订单',
ec_order: '商城订单',
recharge: '钱包充值',
}[String(value)] ?? `未知业务类型(${String(value)}`
);
}
if (key === 'direction') {
return (
{ income: '收入', expense: '支出' }[String(value)] ??
`未知收支方向(${String(value)}`
);
}
if (key.endsWith('_status')) {
return (
{
10: '待处理', 11: '生效中', 12: '已过期', 13: '已终止',
14: '已记录', 15: '已绑定', 16: '已创建', 17: '已下单',
18: '已分配', 19: '充装中', 20: '已就绪', 21: '异常',
22: '已取消', 23: '已完成', 24: '已入账', 25: '已通过',
26: '已驳回', 27: '已报废', 28: '在库', 29: '运输中',
30: '使用中', 31: '维修中', 32: '待受理', 33: '配送中',
34: '待确认', 35: '已支付', 36: '已发布', 37: '成功',
38: '已匹配',
}[Number(value)] ?? `未知状态(${String(value)}`
);
}
if (
amountKeys.has(key) ||
key.endsWith('_amount') ||
@@ -363,11 +490,11 @@ export function displayResourceField(
: String(value);
}
if (field.type === 'identity' && typeof value === 'string') {
return relationLabel(field, value, relationOptions);
return relationLabel(field, value, relationOptions, row);
}
if (field.type === 'identity-list' && Array.isArray(value)) {
return value
.map((item) => relationLabel(field, String(item), relationOptions))
.map((item) => relationLabel(field, String(item), relationOptions, row))
.join('、');
}
return displayRawValue(field.key, value);

View File

@@ -302,14 +302,43 @@ const fieldLabels: Record<string, string> = {
description: '说明',
ec_category_identity: '商品分类唯一标识',
ec_product_identity: '商品唯一标识',
ec_order_identity: '商城订单唯一标识',
gas_basic_identity: '气站唯一标识',
gasorder_basic_identity: '配送订单唯一标识',
gasorder_contract_identity: '配送合同唯一标识',
gasorder_contract_product_identity: '合同气瓶唯一标识',
gasorder_contract_product_identities: '合同气瓶唯一标识',
producer_account_identity: '生产商唯一标识',
product_info_identity: '智能气阀唯一标识',
product_type_identity: '智能气阀类型唯一标识',
proof_uri: '凭证地址',
active: '是否有效',
assigner_identity: '分配人唯一标识',
assigner_name: '分配人',
source: '定位来源',
accuracy: '定位精度',
speed: '定位速度',
received_at: '接收时间',
merchant_identity: '商户唯一标识',
channel_trade_no: '渠道交易号',
subject: '支付标题',
failure_code: '失败代码',
failure_message: '失败原因',
expires_at: '支付过期时间',
closed_at: '关闭时间',
bind_id: '渠道绑定标识',
bank_type: '银行卡类型',
bank: '银行编码',
in_trade_no: '内部业务流水号',
out_trade_no: '外部渠道流水号',
logistics_no: '物流单号',
logistics_company: '物流公司',
logistics_status: '物流状态',
shipped_at: '发货时间',
gasorder_track_identity: '配送轨迹唯一标识',
payment_order_identity: '支付记录唯一标识',
related_record_identity: '关联流水唯一标识',
icon_name: '图标',
recipient_name: '签收人姓名',
recipient_phone: '签收人电话',
staff_account_identity: '工作人员唯一标识',
@@ -343,6 +372,7 @@ const datetimes = new Set([
'expired_at', 'produced_at', 'enabled_at', 'started_at', 'completed_at',
'occurred_at', 'reviewed_at', 'signed_at', 'effective_at', 'unbound_at',
'assigned_at', 'confirmed_at', 'paid_at', 'period_start', 'period_end',
'received_at', 'expires_at', 'closed_at', 'shipped_at',
]);
const textareas = new Set([
'params', 'content', 'remark', 'reason', 'terms', 'args', 'callback_msg',
@@ -518,7 +548,14 @@ export const resources: ResourceUiDefinition[] = [
{ name: '变更智能气阀归属', resource: '/product_info/:identity', method: 'PUT', fields: [relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true, type: 'select', options: [{ label: '入库', value: 'warehouse' }, { label: '分配', value: 'assigned' }, { label: '归还', value: 'returned' }, { label: '人工调整', value: 'manual' }] }), f('reason', { required: true }), f('remark')] },
]),
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: resourceSearchEnumOptions('product_repair', 'result') }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
define('product_owner', '智能气阀归属记录', 'readonly', []),
define('product_owner', '智能气阀归属记录', 'readonly', [
relation('product_info_identity', '/product_info'),
relation('warehouse_identity', '/product_warehouse'),
relation('gas_basic_identity', '/gas_basic'),
relation('delivery_basic_identity', '/delivery_basic'),
relation('user_account_identity', '/user_account'),
f('action'), f('occurred_at'), f('operator_name'), f('operator_identity'), f('reason'), f('remark'),
]),
define('gasorder_contract', '配送合同', 'managed', [
f('contract_no', { required: true, listCopyable: true }),
@@ -565,7 +602,11 @@ export const resources: ResourceUiDefinition[] = [
}), f('unit_price')], 'list', [
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
]),
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
define('gasorder_contract_revision', '合同修订记录', 'readonly', [
relation('gasorder_contract_identity', '/gasorder_contract'),
f('action'), f('contract_status'), f('effective_at'), f('expired_at'),
f('operator_name'), f('operator_identity'), f('occurred_at'), f('reason'),
]),
define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true, { label: '配送合同', listDisplayKey: 'contract_display_name', detailDisplayKey: 'contract_display_name', listDisplayIdentityCopy: true, showIdentityCopy: true, placeholder: '请选择当前可履约的生效合同', relationFilters: { candidate: 'order' }, relationStrictFilter: true, relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择', relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { label: '创建方', required: true, listDisplayKey: 'creator_display_name', listDisplayIdentityCopy: true, placeholder: '请先选择配送合同和创建方类型', readonlyRelationText: true, relationFilters: { status: '1' }, relationEmptyText: '当前合同下暂无可用的创建方', dynamicRelation: {
parentKey: 'creator_type', parentLabel: '创建方类型', contextParentKey: 'gasorder_contract_identity',
resources: { user: '/user_account', staff: '/staff_account', delivery: '/delivery_basic', gas: '/gas_basic' },
@@ -587,7 +628,7 @@ export const resources: ResourceUiDefinition[] = [
parentChangeMessage: '配送合同已变更,请重新选择该合同用户的收货地址',
},
}), f('gasorder_contract_product_identities', { label: '合同气瓶', required: true, type: 'identity-list', listDisplayKey: 'contract_products_summary', listDetailLink: true, emptyText: '未填写', relation: '/gasorder_contract_product', placeholder: '请输入智能气阀名称、设备类型或设备编码搜索', relationOptionDisplay: 'product-name-type', relationEmptyText: '该合同暂无可用气瓶,请先为合同绑定气瓶', relationLinkage: { parentKey: 'gasorder_contract_identity', optionParentKey: '', filterKey: 'contract_identity', filterOnly: true, requiresParent: true, parentChangeMessage: '配送合同已变更,请重新选择该合同可用的气瓶' } }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [
{ name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true, { label: '配送点', placeholder: '请选择合同履约范围内的配送点', relationFilters: { status: '1' } }), relation('staff_account_identity', '/staff_account', true, { label: '配送人员', placeholder: '请选择配送点', relationEmptyText: '该配送点暂无在岗且资质有效的配送人员', staffRelation: { roles: ['delivery'], enabledOnly: true, workStatus: 'on_duty', validCredentialOnly: true } }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
{ name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true, { label: '配送点', placeholder: '请选择合同履约范围内的配送点', relationFilters: { status: '1' } }), relation('staff_account_identity', '/staff_account', true, { label: '配送人员', placeholder: '请选择当前配送点内的配送人员', relationEmptyText: '该配送点暂无在岗且资质有效的配送人员', staffRelation: { roles: ['delivery'], enabledOnly: true, workStatus: 'on_duty', validCredentialOnly: true } }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
{ name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } },
{ name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } },
{ name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason, visibleFor: { field: 'order_status', values: [20] } },
@@ -597,34 +638,109 @@ export const resources: ResourceUiDefinition[] = [
{ name: '恢复订单', resource: '/gasorder_basic/:identity/recover', fields: reason, visibleFor: { field: 'order_status', values: [21] } },
{ name: '取消订单', resource: '/gasorder_basic/:identity/cancel', danger: true, fields: reason, visibleFor: { field: 'order_status', values: [16, 18] } },
]),
define('gasorder_item', '订单明细', 'readonly', []),
define('gasorder_assign', '分配记录', 'readonly', []),
define('gasorder_status', '状态记录', 'readonly', []),
define('gasorder_track', '运行轨迹', 'readonly', []),
define('gasorder_track_point', '轨迹点', 'readonly', []),
define('gasorder_confirm', '确认记录', 'readonly', []),
define('gasorder_payment', '支付记录', 'readonly', []),
define('gasorder_item', '订单明细', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
relation('gasorder_contract_product_identity', '/gasorder_contract_product'),
relation('product_info_identity', '/product_info'),
f('product_code'), f('product_type_name'), f('product_params'), f('unit_price'), f('active'),
]),
define('gasorder_assign', '分配记录', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
relation('gas_basic_identity', '/gas_basic'),
relation('delivery_basic_identity', '/delivery_basic'),
relation('staff_account_identity', '/staff_account'),
f('assigner_name'), f('assigner_identity'), f('assigned_at'), f('reason'),
]),
define('gasorder_status', '状态记录', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
f('from_status'), f('to_status'), f('operator_name'), f('operator_identity'), f('occurred_at'), f('reason'),
]),
define('gasorder_track', '运行轨迹', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
relation('staff_account_identity', '/staff_account'),
f('attempt_no'), f('started_at'), f('completed_at'),
]),
define('gasorder_track_point', '轨迹点', 'readonly', [
relation('gasorder_track_identity', '/gasorder_track'),
f('request_no'), f('longitude'), f('latitude'), f('occurred_at'), f('received_at'),
f('source'), f('accuracy'), f('speed'), f('direction'),
]),
define('gasorder_confirm', '确认记录', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
f('request_no'), f('confirm_type'), f('recipient_name'), f('recipient_phone'),
f('proof_uri'), f('confirmed_at'), f('remark'),
]),
define('gasorder_payment', '支付记录', 'readonly', [
relation('gasorder_basic_identity', '/gasorder_basic'),
relation('payment_order_identity', '/payment_order'),
f('attempt_no'), f('amount'),
]),
define('ec_category', '商品分类', 'writable', [relation('parent_identity', '/ec_category'), f('name', { required: true }), f('sort_no')], 'tree'),
define('ec_product', '商品', 'writable', [relation('ec_category_identity', '/ec_category', true), f('product_code', { required: true }), f('name', { required: true }), f('price_amount', { required: true }), f('stock_quantity')]),
define('ec_product_attribute', '商品属性', 'writable', [relation('ec_product_identity', '/ec_product', true), f('name', { required: true }), f('value', { required: true }), f('sort_no')]),
define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true), f('image_uri', { required: true }), f('sort_no'), f('is_cover')]),
define('ec_cart', '购物车', 'readonly', []),
define('ec_order', '商城订单', 'readonly', []),
define('ec_order_item', '商城订单明细', 'readonly', []),
define('ec_review', '商品评价', 'readonly', []),
define('ec_cart', '购物车', 'readonly', [
relation('user_account_identity', '/user_account'), relation('ec_product_identity', '/ec_product'),
f('quantity'), f('selected'),
]),
define('ec_order', '商城订单', 'readonly', [
f('order_no'), f('request_no'), f('order_status', { type: 'select', options: [
{ label: '待支付', value: 16 },
{ label: '已支付', value: 18 },
{ label: '已取消', value: 22 },
], unknownValueLabel: '未知订单状态' }),
relation('user_account_identity', '/user_account'), relation('gas_basic_identity', '/gas_basic'),
relation('delivery_basic_identity', '/delivery_basic'), relation('user_address_identity', '/user_address'),
f('contact_name'), f('contact_phone'), f('product_amount'), f('discount_amount'), f('payable_amount'),
f('total_amount'), f('paid_at'), f('logistics_no'), f('logistics_company'), f('logistics_status'),
f('shipped_at'), f('received_at'), f('remark'),
]),
define('ec_order_item', '商城订单明细', 'readonly', [
relation('ec_order_identity', '/ec_order'), relation('ec_product_identity', '/ec_product'),
f('product_snapshot'), f('quantity'), f('sale_amount'),
]),
define('ec_review', '商品评价', 'readonly', [
relation('ec_order_identity', '/ec_order'), relation('ec_product_identity', '/ec_product'),
relation('user_account_identity', '/user_account'), f('score'), f('content'),
]),
define('wallet_basic', '钱包', 'readonly', [f('owner_type'), f('owner_identity'), f('alipay_id'), f('alipay_name'), f('wxpay_id'), f('wxpay_name'), f('balance'), f('withdrawal_balance')], 'list', [
define('wallet_basic', '钱包', 'readonly', [f('owner_type'), f('owner_identity', {
type: 'identity', label: '归属主体', listRelationNameOnly: true,
displayRelationLabel: true, showIdentityCopy: true,
dynamicRelation: {
parentKey: 'owner_type', parentLabel: '归属类型',
resources: { user: '/user_account', staff: '/staff_account', gas: '/gas_basic', delivery: '/delivery_basic' },
},
}), f('alipay_id'), f('alipay_name'), f('wxpay_id'), f('wxpay_name'), f('balance'), f('withdrawal_balance')], 'list', [
{ name: '后台充值', resource: '/wallet_basic/:identity/recharge', fields: [f('request_no', { required: true }), f('amount', { required: true }), f('withdrawable'), ...reason, f('remark')] },
{ name: '修改钱包状态', resource: '/wallet_basic/:identity/status', method: 'PATCH', fields: [f('status', { required: true, type: 'select', options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }, { label: '冻结', value: 4 }] })] },
]),
define('wallet_bank', '银行卡', 'readonly', []),
define('payment_order', '钱包支付记录', 'readonly', []),
define('wallet_record', '钱包流水', 'readonly', []),
define('wallet_bank', '银行卡', 'readonly', [
relation('wallet_basic_identity', '/wallet_basic'), f('bank_name'), f('card_owner'),
f('card_no_last4'), f('bind_id'), f('bank_type'), f('bank'),
]),
define('payment_order', '钱包支付记录', 'readonly', [
f('payment_no'), f('request_no'), f('payment_status'), f('business_type'),
f('business_identity', { type: 'identity', label: '业务对象', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, dynamicRelation: {
parentKey: 'business_type', parentLabel: '业务类型', resources: { gasorder: '/gasorder_basic', ec_order: '/ec_order' },
} }), relation('user_identity', '/user_account'), f('merchant_identity'), f('channel'), f('pay_type'),
f('channel_trade_no'), f('amount'), f('subject'), f('failure_code'), f('failure_message'),
f('expires_at'), f('paid_at'), f('closed_at'),
]),
define('wallet_record', '钱包流水', 'readonly', [
relation('wallet_basic_identity', '/wallet_basic'), f('record_no'), f('request_no'),
f('direction'), f('trade_type'), f('amount'), f('fee'), f('balance_after'),
f('withdrawal_balance_after'), f('in_trade_no'), f('out_trade_no'), f('pay_channel'),
f('pay_type'), relation('related_record_identity', '/wallet_record'), f('operator_name'),
f('operator_identity'), f('ymd'), f('ym'), f('remark'),
]),
define('payment_refund', '退款审核', 'readonly', [
f('refund_no'), f('business_type'), f('business_identity'), f('user_identity'),
f('refund_no'), f('business_type'), f('business_identity', { type: 'identity', label: '业务对象', listRelationNameOnly: true, displayRelationLabel: true, showIdentityCopy: true, dynamicRelation: {
parentKey: 'business_type', parentLabel: '业务类型', resources: { gasorder: '/gasorder_basic', ec_order: '/ec_order' },
} }), relation('user_identity', '/user_account'),
f('amount'), f('reason'), f('description'), f('refund_status'),
f('reviewer_identity'), f('review_remark'), f('reviewed_at'), f('completed_at'),
relation('reviewer_identity', '/platform_account'), f('review_remark'), f('reviewed_at'), f('completed_at'),
], 'list', [
{ name: '审核通过并退入钱包', resource: '/payment_refund/:identity/approve', fields: [f('remark')], visibleFor: { field: 'refund_status', values: [10] } },
{ name: '驳回退款', resource: '/payment_refund/:identity/reject', danger: true, fields: [f('remark', { required: true })], visibleFor: { field: 'refund_status', values: [10] } },
@@ -652,16 +768,30 @@ export const resources: ResourceUiDefinition[] = [
{ name: '标记处理完成', resource: '/wallet_apply_cash/:identity/complete', fields: [f('trade_no', { required: true }), f('callback_msg')], visibleFor: { field: 'apply_status', values: [25] } },
]),
define('fin_payment', '财务支付记录', 'readonly', []),
define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', { required: true }), f('period_start', { required: true }), f('period_end', { required: true })]),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('fin_payment', '财务支付记录', 'readonly', [
relation('ec_order_identity', '/ec_order'), f('payment_status'), f('channel'), f('amount'), f('paid_at'),
]),
define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', {
required: true, type: 'identity', label: '结算主体', listRelationNameOnly: true,
displayRelationLabel: true, showIdentityCopy: true,
dynamicRelation: {
parentKey: 'subject_type', parentLabel: '结算主体类型',
resources: { gas: '/gas_basic', delivery: '/delivery_basic' },
},
}), f('period_start', { required: true }), f('period_end', { required: true })]),
define('fin_reconciliation', '财务对账', 'readonly', [
f('reconciliation_status'), f('channel'), f('bill_date'), f('difference_amount'),
]),
define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true, unknownValueLabel: '未知角色' }), f('phone')]),
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: resourceSearchEnumOptions('platform_role', 'location_scope') })], 'list', [
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
]),
define('platform_menu', '平台菜单', 'readonly', [], 'tree'),
define('platform_menu', '平台菜单', 'readonly', [
relation('parent_identity', '/platform_menu'), f('group_code'), f('name'), f('icon'),
f('path'), f('sort_no'),
], 'tree'),
];
export const resourceByPath = Object.fromEntries(

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) : '名称加载失败';
}
/** 按字段类型返回标准列表列宽。 */