8 小时以前 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
325
326
327
328
329
330
331
332
333
/**
 * 报告渲染引擎
 * <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');
}