/**
|
* 报告渲染引擎
|
* <p>
|
* 输入「模板 Schema + 报告上下文」,输出可直接打印的 HTML 文档。
|
* 渲染顺序固定为「先判定、后渲染」:PASS/FAIL 由 report-evaluator 算好写进上下文,
|
* 模板里只做取值,因此同一份数据在任何模板下判定一致,模板也无法左右判定结果。
|
* <p>
|
* 这里不依赖 GrapesJS,只吃 GrapesJS 导出的项目数据(组件树 + 样式),
|
* 后续换成服务端渲染时这套投影逻辑可以照搬。
|
*/
|
import type { ReportContext } from './context';
|
import type { QualityRuleDefinition } from './report-evaluator';
|
|
import {
|
QUALITY_ID_ATTR,
|
QUALITY_PROP_PREFIX,
|
QUALITY_REPEAT_ATTR,
|
QUALITY_REPEAT_ROW_ATTR,
|
QUALITY_TYPE_ATTR,
|
} from '../core/attrs';
|
import { cssStyle, escapeHtml } from '../core/html';
|
|
import { resolveTextWithReport } from './binding';
|
import type { PageSetting } from './page';
|
import { readPath } from './path';
|
import { evaluateReport } from './report-evaluator';
|
import { resolvePageMm } from './page';
|
|
/** 渲染用到的 GrapesJS 项目数据节点(只声明渲染需要的字段) */
|
export interface RenderNode {
|
attributes?: Record<string, string>;
|
components?: RenderNode[] | string;
|
content?: string;
|
style?: Record<string, string>;
|
tagName?: string;
|
type?: string;
|
}
|
|
/** GrapesJS 导出的一条样式规则 */
|
export interface RenderStyleRule {
|
selectors?: string[];
|
style?: Record<string, string>;
|
/** @media 的查询条件,理论上模板不含媒体查询(画布设备已置空 widthMedia) */
|
mediaText?: string;
|
}
|
|
/** 渲染用到的 Schema 结构,结构等价于 ReportTemplateSchema,不依赖具体接口类型定义 */
|
export interface RenderSchema {
|
page?: PageSetting;
|
grapes?: {
|
pages?: Array<{ frames?: Array<{ component?: RenderNode }> }>;
|
/** 新数据是规则数组,历史数据可能是 CSS 文本 */
|
styles?: RenderStyleRule[] | string;
|
};
|
}
|
|
export interface RenderOptions {
|
schema?: RenderSchema;
|
context: ReportContext;
|
rules?: QualityRuleDefinition[];
|
}
|
|
export interface RenderOutcome {
|
/** 完整 HTML 文档,打印/PDF 直接使用 */
|
html: string;
|
/** 正文片段,便于嵌入页面预览 */
|
body: string;
|
/** 判定后的上下文(含 PASS/FAIL 判定与合格率) */
|
context: ReportContext;
|
/** 规则与绑定的问题清单,调用方必须显式暴露,不能悄悄吞掉 */
|
errors: string[];
|
}
|
|
/** 渲染作用域:判定后的报告上下文,外加循环变量(如 item)与序号 index */
|
type RenderScope = Record<string, unknown>;
|
|
/** 一次渲染的行走状态:作用域 + 当前位置 + 问题清单,按引用在递归里共享 */
|
interface RenderState {
|
scope: RenderScope;
|
/** 当前渲染位置的描述,用于把问题定位到具体行 */
|
where: string;
|
errors: string[];
|
/** 已上报的缺口,同一处只提示一次 */
|
reported: Set<string>;
|
}
|
|
/** 自闭合标签,不能生成结束标签 */
|
const VOID_TAGS = new Set([
|
'br',
|
'col',
|
'hr',
|
'img',
|
'input',
|
'link',
|
'meta',
|
'source',
|
'track',
|
'wbr',
|
]);
|
|
/**
|
* GrapesJS 组件类型 → HTML 标签。
|
* <p>
|
* 项目数据只在标签与类型默认值不同时才写 tagName(例如 th、span、h2),
|
* 表格结构(table/thead/tbody/row/cell)在数据里都只有 type,
|
* 所以漏掉这张表就会把整张表格渲染成一堆 div。未收录的类型回退到 div,
|
* 与 GrapesJS 基础组件的默认标签一致。
|
*/
|
const TYPE_TAGS: Record<string, string> = {
|
cell: 'td',
|
row: 'tr',
|
table: 'table',
|
tbody: 'tbody',
|
tfoot: 'tfoot',
|
thead: 'thead',
|
text: 'div',
|
wrapper: 'div',
|
};
|
|
/** 渲染产物的基础排版。浏览器默认的表格与页边距会让报告走样,这里收敛成打印友好的基线 */
|
const BASE_CSS = [
|
'* { box-sizing: border-box; }',
|
'body { margin: 0; color: #000; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 12px; line-height: 1.5; }',
|
'table { border-collapse: collapse; width: 100%; }',
|
'img { max-width: 100%; }',
|
'tr, td, th { page-break-inside: avoid; }',
|
].join('\n');
|
|
/** 渲染报告:判定 → 生成正文 → 拼样式 → 组装文档 */
|
export function renderReport(options: RenderOptions): RenderOutcome {
|
const { context, errors } = evaluateReport(options.context, options.rules ?? []);
|
const state: RenderState = {
|
scope: { ...context },
|
where: '模板',
|
errors,
|
reported: new Set<string>(),
|
};
|
|
const root = options.schema?.grapes?.pages?.[0]?.frames?.[0]?.component;
|
const body = root ? renderNode(root, state) : '';
|
const css = buildCss(options.schema?.grapes?.styles);
|
const html = buildDocument(options.schema?.page, body, css, context);
|
return { html, body, context, errors };
|
}
|
|
/** 渲染单个节点:文本节点与重复容器单独处理,其余按「标签 + 属性 + 子节点」展开 */
|
function renderNode(node: RenderNode, state: RenderState): string {
|
// 文本节点只贡献文本,自身不产生标签
|
if (node.type === 'textnode') {
|
return renderText(node.content ?? '', state);
|
}
|
|
const repeatPath = node.attributes?.[QUALITY_REPEAT_ATTR];
|
if (repeatPath) {
|
return renderRepeat(node, repeatPath, state);
|
}
|
|
const tag = resolveTag(node);
|
const attributes = renderAttributes(node, state);
|
const style = renderStyle(node);
|
if (VOID_TAGS.has(tag)) {
|
return `<${tag}${attributes}${style}>`;
|
}
|
return `<${tag}${attributes}${style}>${renderChildren(node, state)}</${tag}>`;
|
}
|
|
/** 标签优先取数据里的 tagName,其次按组件类型推断 */
|
function resolveTag(node: RenderNode): string {
|
return node.tagName ?? TYPE_TAGS[node.type ?? ''] ?? 'div';
|
}
|
|
/**
|
* 子节点按声明顺序展开。
|
* <p>
|
* 有子组件时忽略 content——与 GrapesJS 一致:往一个带文本的组件里再拖入组件后,
|
* 文本就让位给子组件,这里必须同规则,否则渲染产物会和设计器看到的不一样。
|
*/
|
function renderChildren(node: RenderNode, state: RenderState): string {
|
if (Array.isArray(node.components) && node.components.length > 0) {
|
return node.components.map((child) => renderNode(child, state)).join('');
|
}
|
return typeof node.content === 'string' ? renderText(node.content, state) : '';
|
}
|
|
/**
|
* 重复容器:按数组路径展开行模板,其余子节点在原位置渲染一次。
|
* <p>
|
* 行模板上的 data-qc-repeat-row 声明了行内的循环变量名(如 item),
|
* 行内容用 {{item.xxx}} 取值,与设计期在组件里约定的绑定路径一致。
|
*/
|
function renderRepeat(node: RenderNode, path: string, state: RenderState): string {
|
const list = toRowList(readPath(state.scope, path));
|
const children = Array.isArray(node.components) ? node.components : [];
|
const inner = children
|
.map((child) => {
|
const rowVar = child.attributes?.[QUALITY_REPEAT_ROW_ATTR];
|
if (!rowVar) {
|
return renderNode(child, state);
|
}
|
return list
|
.map((element, index) => {
|
const rowState: RenderState = {
|
...state,
|
scope: { ...state.scope, [rowVar]: element, index: index + 1 },
|
where: `第 ${index + 1} 行`,
|
};
|
return renderNode(child, rowState);
|
})
|
.join('');
|
})
|
.join('');
|
|
const tag = resolveTag(node);
|
return `<${tag}${renderAttributes(node, state)}${renderStyle(node)}>${inner}</${tag}>`;
|
}
|
|
/** 非数组按单行处理,空值得到空表;比抛错更贴合"数据没填全"的报告场景 */
|
function toRowList(value: unknown): unknown[] {
|
if (Array.isArray(value)) {
|
return value;
|
}
|
if (value === undefined || value === null) {
|
return [];
|
}
|
return [value];
|
}
|
|
/**
|
* 渲染节点属性。
|
* <p>
|
* 设计器标记(data-quality-type / data-qc-*)不进入产物;
|
* 属性值里的 {{path}} 一并解析,图片地址、链接等也能绑定数据。
|
*/
|
function renderAttributes(node: RenderNode, state: RenderState): string {
|
const attributes = node.attributes ?? {};
|
const parts: string[] = [];
|
Object.entries(attributes).forEach(([name, value]) => {
|
if (name === QUALITY_TYPE_ATTR || name.startsWith(QUALITY_PROP_PREFIX)) {
|
return;
|
}
|
const text = renderText(String(value ?? ''), state);
|
if (text === '') {
|
return;
|
}
|
// GrapesJS 的组件 id 换成 data 属性:重复行里 id 会重名,data 属性不会
|
parts.push(` ${name === 'id' ? QUALITY_ID_ATTR : name}="${escapeHtml(text)}"`);
|
});
|
return parts.join('');
|
}
|
|
/** 节点自身的行内样式 */
|
function renderStyle(node: RenderNode): string {
|
const style = cssStyle(node.style ?? {});
|
return style ? ` style="${escapeHtml(style)}"` : '';
|
}
|
|
/** 取文本里的绑定值,取不到的路径记进问题清单(未解析的 {{}} 不会留在产物里) */
|
function renderText(template: string, state: RenderState): string {
|
return resolveTextWithReport(template, state.scope, (path) => {
|
const key = `${state.where}|${path}`;
|
if (state.reported.has(key)) {
|
return;
|
}
|
state.reported.add(key);
|
state.errors.push(`${state.where}绑定「{{${path}}}」在当前数据中取不到值,已渲染为空`);
|
});
|
}
|
|
/** 样式规则数组 → CSS 文本;GrapesJS 用 #组件id 选择器,这里改写成 data 属性选择器 */
|
function buildCss(styles: RenderStyleRule[] | string | undefined): string {
|
if (typeof styles === 'string') {
|
return styles;
|
}
|
if (!Array.isArray(styles)) {
|
return '';
|
}
|
const blocks: string[] = [];
|
const mediaBlocks = new Map<string, string[]>();
|
|
styles.forEach((rule) => {
|
const selector = (rule.selectors ?? []).map(toSafeSelector).filter(Boolean).join(', ');
|
const body = cssStyle(rule.style ?? {});
|
if (!selector || !body) {
|
return;
|
}
|
const line = `${selector} { ${body} }`;
|
const mediaText = rule.mediaText?.trim();
|
if (mediaText) {
|
mediaBlocks.set(mediaText, [...(mediaBlocks.get(mediaText) ?? []), line]);
|
return;
|
}
|
blocks.push(line);
|
});
|
|
mediaBlocks.forEach((lines, mediaText) => {
|
const atRule = mediaText.startsWith('@') ? mediaText : `@media ${mediaText}`;
|
blocks.push(`${atRule} { ${lines.join(' ')} }`);
|
});
|
return blocks.join('\n');
|
}
|
|
/** #组件id → [data-qc-id="组件id"],重复行复制后样式仍然命中 */
|
function toSafeSelector(selector: string): string {
|
return selector.replaceAll(/#([\w-]+)/g, `[${QUALITY_ID_ATTR}="$1"]`);
|
}
|
|
/** 组装最终文档:纸张与页边距交给 @page,正文里不再重复留白,才能保证每页都有边距 */
|
function buildDocument(
|
page: PageSetting | undefined,
|
body: string,
|
css: string,
|
context: ReportContext,
|
): string {
|
const { heightMm, marginMm, widthMm } = resolvePageMm(page);
|
const size = `${widthMm}mm ${heightMm}mm`;
|
const margin = `${marginMm.top}mm ${marginMm.right}mm ${marginMm.bottom}mm ${marginMm.left}mm`;
|
const title = context.report.reportName || context.report.reportNo || '质检报告';
|
return [
|
'<!doctype html>',
|
'<html lang="zh-CN">',
|
'<head>',
|
'<meta charset="utf-8" />',
|
`<title>${escapeHtml(title)}</title>`,
|
'<style>',
|
`@page { size: ${size}; margin: ${margin}; }`,
|
BASE_CSS,
|
css,
|
'</style>',
|
'</head>',
|
`<body>${body}</body>`,
|
'</html>',
|
].join('\n');
|
}
|