gaoluyang
4 小时以前 a342672f32352c1337b8977c25c101725fccedd5
1
2
3
4
5
6
7
8
9
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
/**
 * 探针:分组检验项(父项 + 子项)在现有渲染引擎下能表达成什么样。
 *
 * 目的只有一个:把「rowspan 合并单元格要不要动引擎」这件事从推断变成实测。
 * 只读,不改代码、不碰数据库。
 *
 * 关键前提(读 report-evaluator.ts:237 得到):判定阶段会把上下文重建成
 * `{ report, inspectionItems }` 两个键,任何自定义的顶层数组都进不了渲染作用域。
 * 所以本探针同时验证「顶层数组不可用」与「挂在每个检验项上的字段可用」两条。
 *
 * 用法:npx tsx .qc-conformance/verify-group-repeat.ts
 */
import type { RenderNode, RenderSchema } from '../src/components/quality/engine/render';
 
import { writeFileSync } from 'node:fs';
 
import { createContext } from '../src/components/quality/engine/context';
import { renderReport } from '../src/components/quality/engine/render';
 
const REPEAT = 'data-qc-repeat';
const REPEAT_ROW = 'data-qc-repeat-row';
 
const HEADERS = ['检验项目', '检测要求', '标准值', '实测值', '单位', '判定'];
 
/**
 * 把「组 → 子项」摊平成一行一项的检验项数组,组信息只落在组头行上。
 * 这是后端要产出的形状:组头行有自己的 itemName(组名),明细行 groupName 为空。
 */
const items = [
  { itemName: '水分', standardValue: 'w = (m1 - m0) / m × 100%', actualValue: '', unit: '', groupSpan: 5 },
  { itemName: '试样质量 m', standardValue: '', actualValue: '50.2', unit: 'g', groupSpan: 1 },
  { itemName: '试样+称量瓶 m1', standardValue: '', actualValue: '78.9', unit: 'g', groupSpan: 1 },
  { itemName: '称量瓶 m0', standardValue: '', actualValue: '28.7', unit: 'g', groupSpan: 1 },
  { itemName: '水分结果 w', standardValue: '≤0.5', actualValue: '0.32', unit: '%', groupSpan: 1 },
  { itemName: '正丁醇含量', standardValue: '纯度 = 100% - 水分 - 酸含量 - 其它杂质', actualValue: '', unit: '', groupSpan: 2 },
  { itemName: '正丁醇含量 /%', standardValue: '≥99.5', actualValue: '99.7', unit: '%', groupSpan: 1 },
];
 
const BASE_CONTEXT = {
  report: { reportNo: 'QC-2026-0001', reportName: '来料检验报告', spec: 'M8×20', inspectType: 'IQC' },
  inspectionItems: items,
};
 
const cell = (text: string, attributes?: Record<string, string>): RenderNode => ({
  type: 'cell',
  attributes,
  content: text,
});
 
const row = (cells: RenderNode[], attributes?: Record<string, string>): RenderNode => ({
  type: 'row',
  attributes,
  components: cells,
});
 
const tbody = (rows: RenderNode[], attributes?: Record<string, string>): RenderNode => ({
  type: 'tbody',
  attributes,
  components: rows,
});
 
const table = (headers: string[], bodies: RenderNode[]): RenderNode => ({
  type: 'table',
  components: [{ type: 'thead', components: [row(headers.map((text) => cell(text)))] }, ...bodies],
});
 
function run(
  label: string,
  component: RenderNode,
  context: Record<string, unknown> = BASE_CONTEXT,
): { body: string; html: string; errors: string[] } {
  const schema = {
    grapes: { pages: [{ frames: [{ component: { components: [component] } }] }] },
  } as unknown as RenderSchema;
  const outcome = renderReport({ context: createContext(context), rules: [], schema });
  // eslint-disable-next-line no-console
  console.log(`\n========== ${label} ==========`);
  // eslint-disable-next-line no-console
  console.log(outcome.body.replace(/></g, '>\n<'));
  // eslint-disable-next-line no-console
  console.log(`── 未解析绑定:${JSON.stringify(outcome.errors)}`);
  return { body: outcome.body, html: outcome.html, errors: outcome.errors };
}
 
// ----------------------------- P1 扁平:repeat over inspectionItems,组头信息挂在行上
 
const p1 = run(
  'P1 扁平:repeat inspectionItems,组头行的「检验项目/检测要求」放组名与公式',
  table(HEADERS, [
    tbody(
      [
        row(
          [
            cell('{{item.itemName}}'),
            cell('{{item.standardValue}}'),
            cell('{{item.actualValue}}'),
            cell('{{item.unit}}'),
            cell('{{item.resultText}}'),
            cell('{{item.index}}'),
          ],
          { [REPEAT_ROW]: 'item' },
        ),
      ],
      { [REPEAT]: 'inspectionItems' },
    ),
  ]),
);
 
// ----------------------------- P2 同上,但给首列加 rowspan 绑定
 
const p2 = run(
  'P2 rowspan 绑定:首列 rowspan="{{item.groupSpan}}",看属性里的绑定能否解析',
  table(HEADERS, [
    tbody(
      [
        row(
          [
            cell('{{item.itemName}}', { rowspan: '{{item.groupSpan}}' }),
            cell('{{item.standardValue}}'),
            cell('{{item.actualValue}}'),
            cell('{{item.unit}}'),
            cell('{{item.resultText}}'),
            cell('{{item.index}}'),
          ],
          { [REPEAT_ROW]: 'item' },
        ),
      ],
      { [REPEAT]: 'inspectionItems' },
    ),
  ]),
);
 
// ----------------------------- P3 顶层自定义数组(预期为空,证明上下文被收口)
 
const p3 = run(
  'P3 顶层自定义数组:repeat inspectionGroups(预期渲染不出任何行)',
  table(HEADERS, [
    tbody(
      [row([cell('{{g.groupName}}'), cell('{{g.formula}}')], { [REPEAT_ROW]: 'g' })],
      { [REPEAT]: 'inspectionGroups' },
    ),
  ]),
);
 
// ----------------------------- P4 嵌套:组作为一项、子项挂在 item.children 上
 
/** 每个「项」是一个组,子项挂在 children 上(仍走 inspectionItems 这一个顶层数组) */
const groupedItems = [
  {
    itemName: '水分',
    standardValue: 'w = (m1 - m0) / m × 100%',
    span: 5,
    children: items.slice(1, 5),
  },
  {
    itemName: '正丁醇含量',
    standardValue: '纯度 = 100% - 水分 - 酸含量 - 其它杂质',
    span: 2,
    children: items.slice(6),
  },
];
 
const p4 = run(
  'P4 嵌套 repeat:外层按组、组内 tbody 再按 item.children 展开,组名/公式带 rowspan',
  table([...HEADERS, '备注'], [
    tbody(
      [
        {
          type: 'tbody',
          attributes: { [REPEAT_ROW]: 'g' },
          components: [
            row([
              cell('{{g.itemName}}', { rowspan: '{{g.span}}' }),
              cell('{{g.standardValue}}', { rowspan: '{{g.span}}' }),
            ]),
            tbody(
              [
                row(
                  [
                    cell('{{c.itemName}}'),
                    cell('{{c.standardValue}}'),
                    cell('{{c.actualValue}}'),
                    cell('{{c.unit}}'),
                    cell('{{c.resultText}}'),
                    cell(''),
                  ],
                  { [REPEAT_ROW]: 'c' },
                ),
              ],
              { [REPEAT]: 'g.children' },
            ),
          ],
        },
      ],
      { [REPEAT]: 'inspectionItems' },
    ),
  ]),
  { ...BASE_CONTEXT, inspectionItems: groupedItems },
);
 
// --------- P5 扁平 + rowspan + 隐藏重复单元格:数组里只有真实检验项,组头行靠绑定样式"让位"
 
/** 组信息挂在每个真实检验项上,组头单元格只在组内第一行可见并向下合并 */
const groupedByChild = [
  { itemName: '试样质量 m', actualValue: '50.2', unit: 'g', group: { name: '水分', formula: 'w = (m1 - m0) / m × 100%', span: 4, cellStyle: '' } },
  { itemName: '试样+称量瓶 m1', actualValue: '78.9', unit: 'g', group: { name: '', formula: '', span: 1, cellStyle: 'display:none' } },
  { itemName: '称量瓶 m0', actualValue: '28.7', unit: 'g', group: { name: '', formula: '', span: 1, cellStyle: 'display:none' } },
  { itemName: '水分结果 w', standardValue: '≤0.5', actualValue: '0.32', unit: '%', group: { name: '', formula: '', span: 1, cellStyle: 'display:none' } },
  { itemName: '正丁醇含量 /%', standardValue: '≥99.5', actualValue: '99.7', unit: '%', group: { name: '正丁醇含量', formula: '纯度 = 100% - 水分 - 酸含量 - 其它杂质', span: 1, cellStyle: '' } },
];
 
const p5 = run(
  'P5 扁平 + rowspan + 隐藏单元格:数组只装真实检验项,组头两列由绑定样式控制显隐',
  table(['检验项目', '检测要求', '子项', '标准值', '实测值', '单位', '判定'], [
    tbody(
      [
        row(
          [
            {
              type: 'cell',
              content: '{{item.group.name}}',
              attributes: { rowspan: '{{item.group.span}}', style: '{{item.group.cellStyle}}' },
            },
            {
              type: 'cell',
              content: '{{item.group.formula}}',
              attributes: { rowspan: '{{item.group.span}}', style: '{{item.group.cellStyle}}' },
            },
            cell('{{item.itemName}}'),
            cell('{{item.standardValue}}'),
            cell('{{item.actualValue}}'),
            cell('{{item.unit}}'),
            cell('{{item.resultText}}'),
          ],
          { [REPEAT_ROW]: 'item' },
        ),
      ],
      { [REPEAT]: 'inspectionItems' },
    ),
  ]),
  { ...BASE_CONTEXT, inspectionItems: groupedByChild },
);
 
// 把探针产物写到磁盘,供浏览器校验 rowspan 有没有真的合并、隐藏单元格是否被表格布局忽略
writeFileSync('.qc-conformance/group-probe-p4.html', p4.html, 'utf8');
writeFileSync('.qc-conformance/group-probe-p5.html', p5.html, 'utf8');
 
// eslint-disable-next-line no-console
console.log('\n========== 结论速览 ==========');
(
  [
    ['P1 扁平 repeat inspectionItems', p1],
    ['P2 rowspan 绑定', p2],
    ['P3 顶层自定义数组', p3],
    ['P4 嵌套 repeat(组内再按 children 展开)', p4],
    ['P5 扁平+rowspan+隐藏单元格', p5],
  ] as Array<[string, { body: string; errors: string[] }]>
).forEach(([label, result]) => {
  // eslint-disable-next-line no-console
  console.log(
    `${label}: tr 数=${(result.body.match(/<tr/g) ?? []).length} `< +
      `td 数=${(result.body.match(/<td/g) ?? []).length} ` +
      `未解析绑定=${result.errors.length} rowspan 出现=${result.body.includes('rowspan=')}`,
  );
});