Files
platforms/frontend/platform_admin/scripts/check-resource-display-contracts.mjs
2026-08-17 19:28:46 +08:00

405 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 功能描述校验平台48类资源均具备中文展示字段并防止列表、详情退回数据库ID或动态英文列。
* 版本v1.3.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 treePage = read('src/views/shared/TreePage.vue');
const identityText = read('src/components/IdentityText.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('、')}`);
}
// 运行轨迹必须复用统一关联名称组件,避免再次把截断唯一标识当成业务名称。
const trackDefinition = resources.match(
/define\('gasorder_track',[\s\S]*?\n\s*\]\),/,
)?.[0];
if (!trackDefinition) throw new Error('无法读取运行轨迹资源定义');
for (const [key, label] of [
['gasorder_basic_identity', '配送订单'],
['staff_account_identity', '配送人员'],
]) {
const field = trackDefinition.match(
new RegExp(`relation\\('${key}'[\\s\\S]*?\\}\\)`),
)?.[0];
if (
!field ||
!field.includes(`listLabel: '${label}'`) ||
!field.includes('listRelationNameOnly: true') ||
!field.includes('showIdentityCopy: true') ||
(key === 'staff_account_identity' &&
!field.includes("staffRelation: { roles: ['delivery'] }"))
) {
throw new Error(`运行轨迹的${label}必须以业务名称展示并保留唯一标识复制入口`);
}
}
// 提现详情必须展示可读钱包摘要和审核人姓名UUID仅作为复制信息保留。
const withdrawalDefinition = resources.match(
/define\('wallet_apply_cash',[\s\S]*?\n\s*\], 'list',/,
)?.[0];
if (!withdrawalDefinition) throw new Error('无法读取提现记录资源定义');
for (const contract of [
"relation('wallet_basic_identity', '/wallet_basic'",
"relationOptionDisplay: 'wallet-owner'",
"relation('reviewer_identity', '/platform_account'",
"listDisplayKey: 'reviewer_name'",
'listDisplayIdentityCopy: true',
"detailDisplayKey: 'reviewer_name'",
]) {
if (!withdrawalDefinition.includes(contract)) {
throw new Error(`提现详情缺少可读关系展示契约:${contract}`);
}
}
assertIncludes(
detailContract,
"wallet_apply_cash: {",
'提现详情必须声明专属字段顺序与审核姓名快照隐藏规则',
);
// 退款审核的原订单和用户必须使用接口返回的可读名称,不依赖列表临时加载关系。
const refundDefinition = resources.match(
/define\('payment_refund',[\s\S]*?\n\s*\], 'list',/,
)?.[0];
if (!refundDefinition) throw new Error('无法读取退款审核资源定义');
for (const contract of [
"listDisplayKey: 'business_display_name'",
"detailDisplayKey: 'business_display_name'",
"listDisplayKey: 'user_display_name'",
"detailDisplayKey: 'user_display_name'",
"f('refund_product_summary', { emptyText: '退款商品信息缺失' })",
"f('description', { emptyText: '无说明' })",
"f('review_remark', { emptyText: '无审核说明' })",
"detailDisplayKey: 'reviewer_display_name'",
"relation('payment_order_identity', '/payment_order'",
"relation('wallet_basic_identity', '/wallet_basic'",
"relationOptionDisplay: 'wallet-owner'",
'listDisplayIdentityCopy: true',
]) {
if (!refundDefinition.includes(contract)) {
throw new Error(`退款审核缺少可读关系展示契约:${contract}`);
}
}
assertIncludes(
detailContract,
"payment_refund: {",
'退款详情必须声明专属字段顺序和审核人展示名称隐藏规则',
);
assertIncludes(
detailContract,
"hiddenKeys: ['reviewer_display_name']",
'退款详情不得重复展示审核人名称辅助字段',
);
assertIncludes(
detailContract,
"hiddenKeys: ['reviewer_name']",
'提现详情不得重复展示审核人姓名快照和审核人唯一标识',
);
assertIncludes(
display,
"field.relationOptionDisplay === 'wallet-owner'",
'钱包关系必须提供按归属类型生成的可读摘要',
);
// 财务支付记录必须使用本领域的支付状态与渠道,不得复用支付订单状态。
const financePaymentDefinition = resources.match(
/define\('fin_payment',[\s\S]*?\n\s*\]\),/,
)?.[0];
if (!financePaymentDefinition) throw new Error('无法读取财务支付记录资源定义');
for (const contract of [
"relation('ec_order_identity', '/ec_order'",
"listRelationNameOnly: true",
"f('product_summary', { emptyText: '商品信息缺失' })",
"{ label: '已支付', value: 35 }",
"{ label: '余额支付', value: 'wallet' }",
"{ label: '支付宝', value: 'alipay' }",
"{ label: '微信支付', value: 'wechat' }",
]) {
if (!financePaymentDefinition.includes(contract)) {
throw new Error(`财务支付记录缺少中文展示契约:${contract}`);
}
}
// 购物车关联列必须展示用户和商品名称UUID仅作为复制与排障信息保留。
const cartDefinition = resources.match(
/define\('ec_cart',[\s\S]*?\n\s*\]\),/,
)?.[0];
if (!cartDefinition) throw new Error('无法读取购物车资源定义');
for (const [key, label] of [
['user_account_identity', '用户'],
['ec_product_identity', '商品'],
]) {
const field = cartDefinition.match(
new RegExp(`relation\\('${key}'[\\s\\S]*?\\}\\)`),
)?.[0];
if (
!field ||
!field.includes(`listLabel: '${label}'`) ||
!field.includes('listRelationNameOnly: true') ||
!field.includes('displayRelationLabel: true') ||
!field.includes('showIdentityCopy: true')
) {
throw new Error(`购物车的${label}列必须以业务名称展示并保留唯一标识复制入口`);
}
}
// 商城订单必须遵循独立交易状态,并以名称展示用户关系。
const orderDefinition = resources.match(
/define\('ec_order',[\s\S]*?\n\s*\]\),/,
)?.[0];
if (!orderDefinition) throw new Error('无法读取商城订单资源定义');
for (const [value, label] of [
[16, '待支付'],
[18, '已支付'],
[22, '已取消'],
]) {
if (!orderDefinition.includes(`{ label: '${label}', value: ${value} }`)) {
throw new Error(`商城订单状态 ${value} 缺少中文展示:${label}`);
}
}
const orderUserField = orderDefinition.match(
/relation\('user_account_identity'[\s\S]*?\}\)/,
)?.[0];
if (
!orderUserField ||
!orderUserField.includes("listLabel: '用户'") ||
!orderUserField.includes('listRelationNameOnly: true') ||
!orderUserField.includes('displayRelationLabel: true') ||
!orderUserField.includes('showIdentityCopy: true')
) {
throw new Error('商城订单用户列必须以用户名称展示并保留唯一标识复制入口');
}
for (const invalidStatus of [10, 35]) {
if (orderDefinition.includes(`value: ${invalidStatus}`)) {
throw new Error(`商城订单不得把跨领域通用状态 ${invalidStatus} 当作有效交易状态`);
}
}
if (!orderDefinition.includes("f('paid_at', { emptyText: '未支付' })")) {
throw new Error('商城订单空支付时间必须明确显示为未支付');
}
for (const displayContract of [
"f('request_no', { emptyText: '未记录' })",
"f('contact_name', { emptyText: '未记录' })",
"f('contact_phone', { emptyText: '未记录' })",
"f('logistics_no', { emptyText: '未发货' })",
"f('logistics_company', { emptyText: '未发货' })",
"f('shipped_at', { emptyText: '未发货' })",
"f('received_at', { emptyText: '未收货' })",
"f('remark', { emptyText: '无备注' })",
]) {
if (!orderDefinition.includes(displayContract)) {
throw new Error(`商城订单详情缺少空值业务文案:${displayContract}`);
}
}
// 商品评价的订单、商品和用户关系必须以可读业务名称展示。
const reviewDefinition = resources.match(
/define\('ec_review',[\s\S]*?\n\s*\]\),/,
)?.[0];
if (!reviewDefinition) throw new Error('无法读取商品评价资源定义');
for (const [key, label] of [
['ec_order_identity', '商城订单'],
['ec_product_identity', '商品'],
['user_account_identity', '用户'],
]) {
const field = reviewDefinition.match(
new RegExp(`relation\\('${key}'[\\s\\S]*?\\}\\)`),
)?.[0];
if (
!field ||
!field.includes(`listLabel: '${label}'`) ||
!field.includes('listRelationNameOnly: true') ||
!field.includes('displayRelationLabel: true') ||
!field.includes('showIdentityCopy: true')
) {
throw new Error(`商品评价的${label}列必须展示业务名称并保留唯一标识复制入口`);
}
}
if (/title="ID"|data-index="id"/.test(listPage)) {
throw new Error('标准资源列表不得把数据库ID作为业务列展示');
}
assertIncludes(listPage, 'title="系统唯一标识"', '列表必须中文标注系统唯一标识');
assertIncludes(
treePage,
'<a-table',
'树形资源必须使用带明确列标题的树形表格',
);
assertIncludes(
treePage,
'<template #columns>',
'Arco 树形表格列必须放入 columns 插槽,避免运行时渲染为空白',
);
for (const columnTitle of ['分类名称', '排序', '状态', '操作']) {
assertIncludes(treePage, columnTitle, `树形表格缺少必要列:${columnTitle}`);
}
assertIncludes(
treePage,
"definition.name === 'ec_category' ? '系统唯一标识' : '系统标识'",
'商品分类必须使用与标准列表一致的系统唯一标识列名',
);
assertIncludes(
treePage,
'v-if="definition.name === \'ec_category\'"',
'商品分类必须显示截断系统唯一标识而不是通用复制文案',
);
assertIncludes(
treePage,
'{{ record.name }}',
'树形表格必须以接口返回的中文业务名称作为主标题',
);
assertIncludes(
treePage,
'display-text="复制标识"',
'非商品分类树形资源仍应保留低优先级复制文案',
);
assertIncludes(
identityText,
'displayText?: string',
'唯一标识组件必须保留可选的低优先级复制文案能力',
);
if (treePage.includes('node.title')) {
throw new Error('树节点不得读取标题插槽中不存在的 title 字段');
}
assertIncludes(
listPage,
'class="resource-table-shell"',
'列表必须使用内部横向滚动容器',
);
assertIncludes(
display,
'hasResourceFieldLabel',
'详情必须提供未知字段中文契约检查',
);
assertIncludes(
display,
"field.type === 'datetime'",
'日期时间必须按字段类型统一格式化,不能依赖 `_at` 字段名后缀',
);
assertIncludes(
display,
"field.type === 'date'",
'纯日期必须按字段类型格式化,不能回显后端序列化的时间部分',
);
for (const settlementContract of [
"{ label: '气站', value: 'gas' }",
"{ label: '配送点', value: 'delivery' }",
"{ label: '工作人员', value: 'staff' }",
"resources: { gas: '/gas_basic', delivery: '/delivery_basic', staff: '/staff_account' }",
]) {
assertIncludes(
resources,
settlementContract,
`财务结算缺少主体类型联动契约:${settlementContract}`,
);
}
assertIncludes(
resources,
"unknownValueLabel: '未知对账渠道'",
'财务对账渠道必须使用中文枚举并保留未知值提示',
);
for (const contentContract of [
"{ label: '公告', value: 'notice' }",
"{ label: '协议', value: 'agreement' }",
"{ label: '草稿', value: 'draft' }",
"{ label: '已发布', value: 'published' }",
"unknownValueLabel: '未知发布状态'",
]) {
assertIncludes(
resources,
contentContract,
`内容管理缺少中文枚举契约:${contentContract}`,
);
}
for (const ticketContract of [
"define('cs_ticket', '客服工单'",
"{ label: '配送', value: 'delivery' }",
"{ label: '客服咨询', value: 'customer_service' }",
"unknownValueLabel: '未知工单分类'",
"{ label: '普通', value: 'normal' }",
"unknownValueLabel: '未知优先级'",
]) {
assertIncludes(
resources,
ticketContract,
`客服工单缺少用户关系或中文枚举契约:${ticketContract}`,
);
}
assertIncludes(
detailPage,
'!hasResourceFieldLabel(props.definition, actualKey)',
'详情不得直接回显未配置字段',
);
assertIncludes(
detailPage,
'resourceDetailContract(props.definition.name)',
'详情关联表必须使用资源专属固定列契约',
);
for (const collection of [
'products', 'revisions', 'items', 'assignments', 'statuses',
'tracks', 'confirmations', 'payments', 'points',
]) {
assertIncludes(
detailContract,
`${collection}: {`,
`缺少关联记录固定列契约:${collection}`,
);
}
assertIncludes(
display,
"if (detail.track && typeof detail.track === 'object')",
'运行轨迹详情必须解包 track 聚合主记录',
);
assertIncludes(
detailContract,
"rawColumns: ['direction']",
'轨迹点行进方向必须保留原始采集值,不得套用收支方向枚举',
);
process.stdout.write('平台48类资源中文展示契约检查通过\n');