368 lines
13 KiB
JavaScript
368 lines
13 KiB
JavaScript
/**
|
||
* 功能描述:校验平台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` 字段名后缀',
|
||
);
|
||
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(
|
||
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');
|