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;
|
},
|
};
|