11 小时以前 35722562e9e13f0504acc15b740d042ecb810199
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import type { QualityComponentDefinition, QualityProps } from '../core/types';
 
import { QUALITY_REPEAT_ATTR, QUALITY_REPEAT_ROW_ATTR } from '../core/attrs';
import { parseColumnSpec, parseHeaderSpans } from '../core/column-spec';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
 
/** 检验项数组在报告上下文中的默认路径 */
const DEFAULT_ITEMS_PATH = 'inspectionItems';
 
/** 循环变量名,行内占位符写作 {{item.xxx}} */
const LOOP_VAR = 'item';
 
/** 渲染期由重复容器注入的行号变量,写作 {{index}},从 1 开始 */
const INDEX_VAR = 'index';
 
const CELL_BORDER = '1px solid #d9d9d9';
 
/** 单元格静态样式(合并格与普通格一致,差别只在属性) */
const CELL_STYLE = cssStyle({ border: CELL_BORDER, padding: '6px 8px' });
 
/**
 * 自定列时的单元格样式,比默认列多一条允许长串中途折行。
 * <p>
 * 默认列带手工调过的宽度(序号 48px、检测要求 30%、实测值 26%……),宽度由这些声明说了算;
 * 自定列一个宽度都没有,整张表的列宽就只能由各自的**最小内容宽度**倒推。而报告里的值是
 * `{{item.standardValue}}` 这种不含空格的长串,最小内容宽度就是整串的长度——几列加起来
 * 轻易超过 A4 正文宽度,浏览器只能把表格撑出纸张,最末一列被挤成十几个像素的竖条。
 * <p>
 * `anywhere` 会参与最小内容宽度的计算(`break-word` 不会),列宽因此不再被长串绑架:
 * 值在**出件时**本来就短(「烘箱干燥法」「13.2」),折行极少触发,产物与设计器所见一致。
 * <p>
 * 只在自定列时加:默认列的宽度声明已经定住了版面,再叠一条折行反而让「检验项目」「子项」
 * 这类没写宽度的列被挤到 30 余像素、行高翻几倍——实测过,不加比加好。
 */
const CUSTOM_CELL_STYLE = cssStyle({
  border: CELL_BORDER,
  padding: '6px 8px',
  'overflow-wrap': 'anywhere',
});
 
/**
 * 组名格的合并属性。
 * <p>
 * 只能写成「属性」而不能写进行内 style:行内样式会被 GrapesJS 解析成节点 style 对象,
 * 而渲染期对 style 对象不跑绑定(renderStyle),绑定串会被整条丢掉;
 * 属性则原样保留,两侧渲染器都会把 {{}} 解成值。
 * <p>
 * hidden 用字符串而非布尔:这个属性靠「在不在」起作用(hidden="false" 照样隐藏),
 * 所以只有「空串 = 不输出该属性 = 可见」这一个可判定的形态。
 * 两侧渲染器都跳过求值后为空串的属性(render.ts / HtmlRenderer.java),行为逐字一致。
 */
const GROUP_CELL_ATTRS =
  ` rowspan="{{${LOOP_VAR}.group.span}}" hidden="{{${LOOP_VAR}.group.hidden}}"`;
 
interface ColumnSpec {
  /** 表头文字 */
  label: string;
  /** 行内取值表达式 */
  expr: string;
  /** 写在 <td> 上的额外属性(绑定) */
  attrs?: string;
  /** 对应的显示开关属性名,空串表示不可隐藏 */
  toggle: string;
  width?: string;
}
 
const COLUMNS: ColumnSpec[] = [
  { label: '序号', expr: `{{${INDEX_VAR}}}`, toggle: 'showIndex', width: '48px' },
  { label: '检验项目', expr: `{{${LOOP_VAR}.itemName}}`, attrs: GROUP_CELL_ATTRS, toggle: '' },
  { label: '子项', expr: `{{${LOOP_VAR}.group.childName}}`, toggle: '' },
  {
    label: '检测要求',
    expr: `{{${LOOP_VAR}.requirement}}`,
    toggle: 'showRequirement',
    width: '30%',
  },
  {
    label: '实测值',
    expr: `{{${LOOP_VAR}.actualValue}}`,
    toggle: 'showActual',
    width: '26%',
  },
  { label: '单位', expr: `{{${LOOP_VAR}.unit}}`, toggle: 'showUnit', width: '60px' },
  { label: '判定', expr: `{{${LOOP_VAR}.resultText}}`, toggle: 'showResult', width: '72px' },
];
 
/** 真正参与出表的一列:默认列与自定义列都收敛成这个形状再拼 HTML */
interface RenderColumn {
  label: string;
  expr: string;
  /** 是不是纵向合并跨住整组的组名列 */
  merge: boolean;
  width?: string;
}
 
/** 按显示开关挑出默认列 */
function defaultColumns(properties: QualityProps): RenderColumn[] {
  return COLUMNS.filter(
    (column) => column.toggle === '' || properties[column.toggle] !== false,
  ).map((column) => ({
    label: column.label,
    expr: column.expr,
    merge: Boolean(column.attrs),
    width: column.width,
  }));
}
 
/**
 * 出表用的列:填了 columns 就整表照它来,否则用默认列。
 * <p>
 * 自定义列时**不再看各显示开关**:columns 已经把列与顺序说全了,
 * 再叠一层「序号被隐藏了但用户又写回来了」的开关判断,只会让两边打架。
 *
 * @return custom 为真表示这张表照 columns 来(拿不到任何宽度声明,单元格要允许折行)
 */
function effectiveColumns(properties: QualityProps): {
  columns: RenderColumn[];
  custom: boolean;
} {
  const custom = parseColumnSpec(textOf(properties.columns));
  return custom.items.length > 0
    ? { columns: custom.items, custom: true }
    : { columns: defaultColumns(properties), custom: false };
}
 
function textOf(value: unknown): string {
  return String(value ?? '').trim();
}
 
/** 上层的出表方案:上层那一行的单元格,以及下层要跳过哪些列 */
interface HeaderPlan {
  cells: string;
  covered: Set<number>;
}
 
/**
 * 规划上层表头。
 * <p>
 * 表头里的格子有两种形态,都从同一串 `标题^跨列数` 里认出来:
 * <ul>
 *   <li>分组标题:横向跨住下面若干列,如 `检验结果^5`;</li>
 *   <li>纵向合并格:某一列自己既是列名又是上格,原件里是一格跨住上下两层
 *       (提取文本里表现为下层表头那一格写着「↑同上」),如 `结论^1`。
 *       认出它靠「该段标题与它正下方那一列的列标题一字不差、且只跨 1 列」。</li>
 * </ul>
 * 纵向合并格必须用 rowspan 出、并且下层不再重复出这一格;否则「结论」二字会在上下两层各印一次。
 */
function planHeader(properties: QualityProps, columns: RenderColumn[]): HeaderPlan {
  const parsed = parseHeaderSpans(textOf(properties.headerSpans), columns.length);
  if (parsed.items.length === 0 || parsed.errors.length > 0) {
    // 没填或不合法:不出上层,表头退回单层
    return { cells: '', covered: new Set() };
  }
  const covered = new Set<number>();
  let lowerIndex = 0;
  const cells = parsed.items
    .map((span) => {
      const start = lowerIndex;
      lowerIndex += span.span;
      const vertical = span.span === 1 && columns[start]?.label === span.label;
      if (vertical) {
        covered.add(start);
      }
      const size = vertical ? 'rowspan="2"' : `colspan="${span.span}"`;
      return `<th ${size} style="${cssStyle({
        border: CELL_BORDER,
        padding: '6px 8px',
        background: '#fafafa',
        'font-weight': 600,
      })}">${escapeHtml(span.label)}</th>`;
    })
    .join('');
  return { cells, covered };
}
 
export const groupedQualityTableDefinition: QualityComponentDefinition = {
  type: 'GroupedQualityTable',
  name: 'groupedQualityTable',
  label: '分组检验项表',
  category: QUALITY_CATEGORY.INSPECTION,
  icon: QUALITY_ICONS.groupedTable,
  aiHint:
    '有分组层级的检验项表格:组名纵向合并、组内子项逐行展开(序号/检验项目/子项/检测要求/实测值/单位/判定)。' +
    '检验单里存在「一个检验项目下挂若干子项」这种两层结构时用它;检验项是平铺一层、没有父子关系时用 QualityTable。' +
    '原件这张表的**列**比上面列出的多(如还有「检测方法」「标准值」「结论」「备注」)时不要改用别的组件,' +
    '把全部列写进我的 columns 属性;表头分成上下两层时,把上面那一层写进 headerSpans。' +
    '某一列在原件里是「一格纵向合并跨住上下两层表头」(表现为下层表头那一格写着「↑同上」、' +
    '或该列名上下各出现一次)时,它不算任何分组标题的下属列,同样写进 headerSpans 但标题与列标题保持一致,' +
    '我会把它合成跨两行的一格。',
  defaults: {
    title: '检验项目',
    itemsPath: DEFAULT_ITEMS_PATH,
    showIndex: true,
    showRequirement: true,
    showActual: true,
    showUnit: true,
    showResult: true,
  },
  propertySchema: [
    { key: 'title', label: '表格标题', type: 'string', defaultValue: '检验项目' },
    {
      key: 'itemsPath',
      label: '检验项数据源',
      type: 'string',
      defaultValue: DEFAULT_ITEMS_PATH,
      required: true,
      bindable: true,
      // 渲染期按这个路径取数组逐行生成,必须是裸路径,不能写成 {{...}}
      bindAs: 'path',
      tip: '报告上下文中检验项目数组的路径,渲染时按它逐行生成。分组信息随每一项的下发,不需要另外指定',
    },
    { key: 'showIndex', label: '显示序号', type: 'boolean', defaultValue: true },
    { key: 'showRequirement', label: '显示检测要求', type: 'boolean', defaultValue: true },
    { key: 'showActual', label: '显示实测值', type: 'boolean', defaultValue: true },
    { key: 'showUnit', label: '显示单位', type: 'boolean', defaultValue: true },
    { key: 'showResult', label: '显示判定', type: 'boolean', defaultValue: true },
    {
      key: 'columns',
      label: '列定义(覆盖默认列)',
      type: 'text',
      tip:
        '留空则用上面的默认列。格式:列标题=绑定表达式,多列用 | 分隔,' +
        '如 序号={{index}}|检验项目={{item.itemName}}|检测方法={{item.checkMethod}}|实测值={{item.actualValue}}。' +
        '标题前加 # 表示该列纵向合并跨住整组(放组名的那一列)。' +
        '填了它整表就按它来,上面的显示开关不再生效',
    },
    {
      key: 'headerSpans',
      label: '表头跨列(两级表头才填)',
      type: 'text',
      tip:
        '只有表头分上下两层时才填,写的是上面那一层:每个单元格写作 标题^跨越列数,多段用 | 分隔,' +
        '如 检验结果^5|结论^1。下面那一层由列标题自动拼出,不要重复写。' +
        '各段跨列数之和必须等于列数。' +
        '某一列自己既是列名又是上格(原件里是一格纵向合并跨住上下两层)时,仍照写一段、跨列数写 1,' +
        '但该段标题要与这一列的列标题写成完全一样的字,报告会合出一格跨两行,不会上下各印一遍',
    },
  ],
  dataSchema: [
    { key: DEFAULT_ITEMS_PATH, label: '检验项目列表', type: 'string', bindable: true },
    { key: INDEX_VAR, label: '行序号', type: 'number', bindable: true },
    { key: `${LOOP_VAR}.itemName`, label: '检验项目名称', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.group.childName`, label: '子项名称', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.group.span`, label: '组名合并行数', type: 'number', bindable: true },
    { key: `${LOOP_VAR}.group.hidden`, label: '子项行隐藏合并格', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.requirement`, label: '检测要求 / 组公式', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.checkMethod`, label: '检测方法', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.actualValue`, label: '实测值', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.unit`, label: '单位', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.resultText`, label: '判定结果', type: 'string', bindable: true },
  ],
  buildContent: (properties) => {
    const { columns, custom } = effectiveColumns(properties);
    const cellStyle = custom ? CUSTOM_CELL_STYLE : CELL_STYLE;
    const header = planHeader(properties, columns);
    const head = columns
      .map((column, index) =>
        header.covered.has(index)
          ? ''
          : `<th style="${cssStyle({
              border: CELL_BORDER,
              padding: '6px 8px',
              background: '#fafafa',
              'font-weight': 600,
              width: column.width,
            })}">${escapeHtml(column.label)}</th>`,
      )
      .join('');
    const row = columns
      .map(
        (column) =>
          `<td${column.merge ? GROUP_CELL_ATTRS : ''} style="${cellStyle}">${escapeHtml(column.expr)}</td>`,
      )
      .join('');
    const rawTitle = String(properties.title ?? '').trim();
    const heading = rawTitle
      ? `<div style="${cssStyle({ 'font-size': '13px', 'font-weight': 600, margin: '0 0 4px' })}">${escapeHtml(rawTitle)}</div>`
      : '';
    const itemsPath = String(properties.itemsPath ?? DEFAULT_ITEMS_PATH).trim() || DEFAULT_ITEMS_PATH;
    const table =
      `<table style="${cssStyle({ width: '100%', 'border-collapse': 'collapse', 'font-size': '12px' })}">` +
      `<thead>${header.cells ? `<tr>${header.cells}</tr>` : ''}<tr>${head}</tr></thead>` +
      // 渲染期按 data-qc-repeat 指向的数组复制 data-qc-repeat-row 所在的行;
      // 组头行与组内子项行在这套结构里都是数组里的一项,合并靠组名格自带的 rowspan
      `<tbody ${QUALITY_REPEAT_ATTR}="${escapeHtml(itemsPath)}">` +
      `<tr ${QUALITY_REPEAT_ROW_ATTR}="${LOOP_VAR}">${row}</tr>` +
      `</tbody></table>`;
    return {
      tagName: 'div',
      components: heading + table,
      style: { margin: '0 0 10px' },
    };
  },
  validate: (properties) => {
    const errors: string[] = [];
    const path = textOf(properties.itemsPath);
    if (!path) {
      errors.push('分组检验项表的「检验项数据源」不能为空,渲染时无法确定生成哪些行');
    }
 
    const parsed = parseColumnSpec(textOf(properties.columns));
    errors.push(...parsed.errors);
    const customColumns = parsed.items.length > 0;
 
    // 显示开关只在用默认列时说了算:填了 columns 整表就按 columns 来,
    // 「隐藏了检测要求却保留判定」在这里不再成立,报了就是误报
    if (!customColumns && properties.showRequirement === false && properties.showResult === true) {
      errors.push(
        '分组检验项表隐藏了「检测要求」却保留「判定」列,判定依据将不可见,请一并隐藏判定或恢复检测要求',
      );
    }
 
    const headerSpec = textOf(properties.headerSpans);
    // 跨列数的合计校验要先知道实际列数,而列定义本身有错时列数不可信:
    // 此时报「合计 N,实际列数 M」会把用户往错误的方向带,先只报列定义的错
    if (headerSpec && parsed.errors.length === 0) {
      const columnCount = (customColumns ? parsed.items : defaultColumns(properties)).length;
      errors.push(...parseHeaderSpans(headerSpec, columnCount).errors);
    }
    return errors;
  },
};