9 小时以前 35722562e9e13f0504acc15b740d042ecb810199
feat(mes): 添加质检指标条目类型支持并实现报告生成功能

- 新增 MES 质检指标条目类型枚举 (ENTRY/GROUP)
- 在质检指标表单中添加条目类型、所属分组、公式等字段
- 实现质检指标分组项的层级结构和依赖关系控制
- 为来料、过程、出货、退货检验页面添加报告生成功能入口
- 新增报告生成弹窗组件和相关API接口
- 添加部署脚本文件 CHDL.bat 和 KYZN.bat
- 引入 grapesjs 依赖用于报告模板设计
- 更新字典枚举配置以支持报告相关状态类型
已添加66个文件
已修改14个文件
10414 ■■■■■ 文件已修改
.qc-conformance/assemble-ts.ts 337 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/dump-catalog.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/group-probe-p4.html 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/group-probe-p5.html 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/group-table-degrade.html 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/group-table-render.html 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/grouped-table-render.png 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/render-ts.ts 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/verify-columns.ts 437 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/verify-group-repeat.ts 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/verify-grouped-table.ts 391 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/verify-undecidable.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CHDL.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
KYZN.bat 451 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-workspace.yaml 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/indicator/index.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/report/ai/index.ts 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/report/instance/index.ts 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/report/template/index.ts 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/report/version/index.ts 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/divider.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/heading.ts 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/image.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/spacer.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/table.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/base/text.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/assembler.ts 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/attrs.ts 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/canvas-sync.ts 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/catalog.ts 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/column-spec.ts 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/factory.ts 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/html.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/icons.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/lookup.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/registry.ts 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/serializer.ts 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/trait-binding.ts 161 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/types.ts 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/core/validator.ts 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/binding.ts 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/context.ts 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/page.ts 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/path.ts 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/render.ts 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/report-evaluator.ts 277 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/rule-engine.ts 711 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/header/report-footer.ts 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/header/report-header.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/header/sample-info.ts 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/index.ts 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/inspection/grouped-quality-table.ts 324 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/inspection/inspection-item.ts 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/inspection/quality-table.ts 139 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/result/report-result.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/modules/qcreport.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/components/report-generate-modal.vue 251 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicator/data.ts 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/indicator/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/iqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/oqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/pendinginspect/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/instance/data.ts 165 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/instance/index.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/instance/modules/detail.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/data.ts 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/constants.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/index.vue 637 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue 341 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/modules/rule-editor.vue 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/designer/use-designer.ts 363 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/index.vue 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/report/template/modules/form.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/rqc/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
.qc-conformance/assemble-ts.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,337 @@
/**
 * å¯¹æ‹è„šæœ¬ï¼šAI è‰ç¨¿ â†’ ç”»å¸ƒè£…配器(assembleQualityCanvas)的往返一致性。
 *
 * æ ¸å¿ƒæ–­è¨€æ˜¯ã€Œè£…配器与 collectSchema äº’为逆运算」:
 * è£…配写进画布的属性,经 attributesToProps è¿˜åŽŸåŽï¼Œå¿…é¡»ä¸Ž resolveQualityProps çš„结果完全相等。
 * ä¸ç›¸ç­‰å°±æ„å‘³ç€ã€ŒAI ç”Ÿæˆ â†’ ä¿å­˜ â†’ å†æ‰“开」会掉属性或改变类型。
 *
 * ç”¨æ³•:npx tsx .qc-conformance/assemble-ts.ts
 */
import type {
  QualityComponentDefinition,
  QualityProps,
} from '../src/components/quality/core/types';
import {
  assembleQualityCanvas,
  attributesToProps,
  buildQualityComponentCatalog,
  getQualityComponent,
  listQualityComponents,
  registerAllQualityComponents,
  resolveQualityProps,
} from '../src/components/quality';
import { QUALITY_TYPE_ATTR } from '../src/components/quality/core/attrs';
// æ³¨å†Œè¡¨è¦æ˜¾å¼æ³¨å†Œæ‰æœ‰å†…容(设计器在 init æ—¶åšï¼‰ã€‚这里补上,模拟设计器已初始化的状态。
registerAllQualityComponents();
let failures = 0;
function check(label: string, condition: boolean, detail?: string): void {
  if (condition) {
    // eslint-disable-next-line no-console
    console.log(`  âœ“ ${label}`);
    return;
  }
  failures += 1;
  // eslint-disable-next-line no-console
  console.log(`  âœ— ${label}${detail ? `\n      ${detail}` : ''}`);
}
/** åªæ¯”较声明过的字段;undefined è§†åŒç¼ºå¸­ï¼ˆpropsToAttributes ä¼šè·³è¿‡å®ƒï¼‰ */
function comparable(
  definition: QualityComponentDefinition,
  props: QualityProps,
): string {
  return definition.propertySchema
    .filter((field) => props[field.key] !== undefined)
    .map((field) => `${field.key}=${JSON.stringify(props[field.key])}`)
    .sort()
    .join('|');
}
interface FakeResult {
  grapes: Record<string, unknown>;
}
function nodesOf(result: FakeResult): Record<string, unknown>[] {
  const pages = result.grapes.pages as {
    component: { components: Record<string, unknown>[] };
  }[];
  return pages[0]!.component.components;
}
function attributesOf(node: Record<string, unknown>): Record<string, string> {
  return node.attributes as Record<string, string>;
}
// ---------------------------------------------------------------- 1. å…¨ç»„件装配
const definitions = listQualityComponents();
/** ç»™ columns å¡«çš„合法列定义;headerSpans çš„跨列数合计要与它的列数对齐 */
const FILLER_COLUMNS = '项目={{item.itemName}}|判定={{item.resultText}}';
/** æ¯ä¸ªå­—段都塞一个「与默认值不同」的合法值,确保测的是转换而不是默认值 */
const drafts = definitions.map((definition, index) => {
  const props: Record<string, unknown> = {};
  definition.propertySchema.forEach((field) => {
    if (field.type === 'boolean') {
      props[field.key] = false;
    } else if (field.type === 'number') {
      props[field.key] = field.key === 'level' ? 3 : 1;
    } else if (field.type === 'enum') {
      const option = field.options?.[field.options.length - 1];
      if (option) {
        props[field.key] = option.value;
      }
    } else if (field.key === 'columns') {
      // åˆ—定义是有语法的文本属性(列标题=绑定表达式):塞 `值N-columns` è¿™ç§è‡ªç”±æ–‡æœ¬
      // åªä¼šè®©æ ¡éªŒæŠ¥ã€Œç¼ºå°‘ =」,把「无问题项」这条断言假红
      props[field.key] = FILLER_COLUMNS;
    } else if (field.key === 'fields') {
      // åŒä¸Šï¼Œè¯­æ³•是 å­—段名=绑定表达式
      props[field.key] = '样品编号={{report.sampleNo}}';
    } else if (field.key === 'headerSpans') {
      // æ®µå°¾ ^ åŽçš„æ•°å­—之和必须等于上面 columns çš„列数,随意填会被校验挡下
      props[field.key] = `合计^${FILLER_COLUMNS.split('|').length}`;
    } else {
      props[field.key] = `值${index}-${field.key}`;
    }
  });
  return { type: definition.type, props };
});
const all = assembleQualityCanvas(drafts);
const nodes = nodesOf(all);
// eslint-disable-next-line no-console
console.log('1. å…¨ç»„件装配');
check(
  `全部内置组件装配成功(草稿 ${drafts.length} / èŠ‚ç‚¹ ${nodes.length})`,
  nodes.length === definitions.length && definitions.length === 13,
);
check(`无跳过项`, all.skipped.length === 0, JSON.stringify(all.skipped));
check(`无问题项`, all.problems.length === 0, JSON.stringify(all.problems));
// ---------------------------------------------------------------- 2. collectSchema çš„命中条件
// eslint-disable-next-line no-console
console.log('2. collectSchema çš„命中条件');
nodes.forEach((node, index) => {
  const attributes = attributesOf(node);
  const label = `第 ${index + 1} ä¸ªèŠ‚ç‚¹ï¼ˆ${definitions[index]!.type})`;
  check(`${label} å¸¦ ${QUALITY_TYPE_ATTR}`, Boolean(attributes[QUALITY_TYPE_ATTR]));
  // data-qc-repeat / repeat-row ç”± buildContent ç”Ÿæˆåœ¨åµŒå¥—çš„ tbody/tr ä¸Šï¼›
  // ä¸€æ—¦è½åˆ°é¡¶å±‚节点,属性面板与渲染期的重复展开都会串味
  check(
    `${label} é¡¶å±‚不含 repeat å±žæ€§`,
    !('data-qc-repeat' in attributes) && !('data-qc-repeat-row' in attributes),
  );
});
// ---------------------------------------------------------------- 3. å¾€è¿”一致
// eslint-disable-next-line no-console
console.log('3. å¾€è¿”一致(attributesToProps âˆ˜ assemble == resolveQualityProps)');
nodes.forEach((node, index) => {
  const definition = definitions[index]!;
  const attributes = attributesOf(node);
  const fromCanvas = comparable(definition, attributesToProps(definition, attributes));
  const intended = comparable(
    definition,
    resolveQualityProps(definition, drafts[index]!.props as QualityProps),
  );
  check(`${definition.type} å¾€è¿”无损`, fromCanvas === intended, `画布侧[${fromCanvas}] â‰  æœŸæœ›[${intended}]`);
});
// ---------------------------------------------------------------- 4. å±žæ€§ç¡®å®žå†™è¿›äº†ç”»å¸ƒ
// eslint-disable-next-line no-console
console.log('4. å±žæ€§ç¡®å®žå†™è¿›äº†ç”»å¸ƒï¼ˆé˜²æ­¢ç¬¬ 3 æ­¥é ä¸¤è¾¹åŒæ—¶å–默认值而假绿)');
drafts.forEach((draft, index) => {
  const definition = definitions[index]!;
  const attributes = attributesOf(nodes[index]!);
  const keys = Object.keys(draft.props);
  const missing = keys.filter((key) => !(`data-qc-${key}` in attributes));
  check(`${definition.type}(${keys.length} ä¸ªå­—段)`, missing.length === 0, `未写入:${missing.join(', ')}`);
});
// ---------------------------------------------------------------- 5. ç±»åž‹ç¨³å®šå¾€è¿”
// eslint-disable-next-line no-console
console.log('5. boolean / number / enum çš„字符串往返');
const boolDefinition = getQualityComponent('QualityTable')!;
const boolResult = assembleQualityCanvas([
  { type: 'QualityTable', props: { showIndex: false, showUnit: true, title: '检验项目' } },
]);
{
  const attributes = attributesOf(nodesOf(boolResult)[0]!);
  check('boolean false â†’ å­—符串 "false"', attributes['data-qc-showIndex'] === 'false', attributes['data-qc-showIndex']);
  check('boolean true â†’ å­—符串 "true"', attributes['data-qc-showUnit'] === 'true');
  const back = attributesToProps(boolDefinition, attributes);
  check('boolean false è¿˜åŽŸä¸º false', back.showIndex === false, String(back.showIndex));
  check('boolean true è¿˜åŽŸä¸º true', back.showUnit === true, String(back.showUnit));
}
const headingDefinition = getQualityComponent('Heading')!;
const numResult = assembleQualityCanvas([
  { type: 'Heading', props: { level: 3, align: 'left', text: '报告标题' } },
]);
{
  const attributes = attributesOf(nodesOf(numResult)[0]!);
  const back = attributesToProps(headingDefinition, attributes);
  check('enum(字符串值)原样往返', back.align === 'left', String(back.align));
  // level æ˜¯ã€Œå–值为数字的枚举」,不是 number å­—段:往返只能保证字面量一致,
  // ç±»åž‹ä¸€å¾‹æ”¶æ•›æˆå­—符串,校验与 buildContent éƒ½æŒ‰ String()/Number() æ¯”较,不影响渲染
  check('enum(数字值)往返为等价字面量', String(back.level) === '3', `${typeof back.level} ${back.level}`);
}
const dividerDefinition = getQualityComponent('Divider')!;
const numberResult = assembleQualityCanvas([{ type: 'Divider', props: { thickness: 3 } }]);
{
  const attributes = attributesOf(nodesOf(numberResult)[0]!);
  check('number 3 â†’ å­—符串 "3"', attributes['data-qc-thickness'] === '3', attributes['data-qc-thickness']);
  const back = attributesToProps(dividerDefinition, attributes);
  check(
    'number 3 è¿˜åŽŸä¸ºæ•°å­— 3',
    back.thickness === 3 && typeof back.thickness === 'number',
    `${typeof back.thickness} ${back.thickness}`,
  );
}
// ---------------------------------------------------------------- 6. åæ•°æ®éš”离
// eslint-disable-next-line no-console
console.log('6. æœªçŸ¥ç±»åž‹ä¸Žç»“构化属性');
const messy = assembleQualityCanvas([
  { type: 'QualityTable', props: { title: '检验项目' } },
  { type: 'CustomHtml', props: { html: '<script>alert(1)</script>' } },
  { type: 'Heading', props: { text: { nested: true }, level: [1, 2] } },
  { type: '', props: {} },
]);
{
  const messyNodes = nodesOf(messy);
  check('坏元素不影响其余:4 é¡¹ä¸­è£…è¿› 2 é¡¹', messyNodes.length === 2, `实际 ${messyNodes.length}`);
  check(
    '未知类型与空类型进 skipped',
    messy.skipped.length === 2,
    JSON.stringify(messy.skipped.map((item) => item.type)),
  );
  check(
    '未注册类型没有产出任何节点',
    !messyNodes.some((node) => attributesOf(node)[QUALITY_TYPE_ATTR] === 'CustomHtml'),
  );
  check('script å†…容没有渗进画布数据', !JSON.stringify(messy.grapes).includes('alert(1)'));
  check('结构化属性各产一条 problem', messy.problems.length === 2, JSON.stringify(messy.problems));
  const headingAttrs = attributesOf(messyNodes[1]!);
  check('对象属性回落到默认值', headingAttrs['data-qc-text'] === '报告标题', headingAttrs['data-qc-text']);
  check('数组属性回落到默认值', headingAttrs['data-qc-level'] === '2', headingAttrs['data-qc-level']);
  check(
    'problem æ–‡æ¡ˆæŒ‡æ˜Žç»„件与字段',
    messy.problems.every((message) => message.includes('标题') && message.includes('结构化数据')),
    JSON.stringify(messy.problems),
  );
}
// ---------------------------------------------------------------- 7. å¿…填缺失可被提示
// eslint-disable-next-line no-console
console.log('7. å¿…填缺失');
// itemsPath æ˜¯ QualityTable å”¯ä¸€çš„必填字段(title ä¸æ˜¯ï¼‰ï¼Œç”¨å®ƒéªŒè¯æ ¡éªŒç¡®å®žè¢«è§¦å‘
const missing = assembleQualityCanvas([{ type: 'QualityTable', props: { itemsPath: '' } }]);
check('必填缺失产 problem', missing.problems.length > 0, JSON.stringify(missing.problems));
check(
  'problem æ–‡æ¡ˆæŒ‡æ˜Žç»„件与字段',
  missing.problems.some((message) => message.includes('检验') && message.includes('数据源')),
  JSON.stringify(missing.problems),
);
check('组件本身仍装进画布', nodesOf(missing).length === 1);
const nonRequired = assembleQualityCanvas([{ type: 'QualityTable', props: { title: '' } }]);
check('非必填留空不产 problem', nonRequired.problems.length === 0, JSON.stringify(nonRequired.problems));
// ---------------------------------------------------------------- 8. ç©ºè¾“å…¥
// eslint-disable-next-line no-console
console.log('8. ç©ºè¾“å…¥');
const empty = assembleQualityCanvas([]);
check('空输入仍返回非空 pages', (empty.grapes.pages as unknown[]).length === 1);
check('空输入节点为空', nodesOf(empty).length === 0);
check('undefined è¾“入不抛错', nodesOf(assembleQualityCanvas()).length === 0);
// ---------------------------------------------------------------- 9. ç§¯æœ¨æ¸…单
// eslint-disable-next-line no-console
console.log('9. ç§¯æœ¨æ¸…单');
const catalog = buildQualityComponentCatalog();
check('清单含全部 13 ä¸ªç»„ä»¶', catalog.length === 13, `实际 ${catalog.length}`);
check(
  '清单项只含 category/fields/hint/label/type',
  catalog.every((item) => Object.keys(item).sort().join(',') === 'category,fields,hint,label,type'),
  JSON.stringify(Object.keys(catalog[0]!)),
);
// é€‰åž‹æŒ‡å¼•是 AI å¯¼å…¥è´¨é‡çš„地基:少写一条,模型就在那个组件上退回瞎猜
const missingHint = listQualityComponents()
  .filter((definition) => !definition.aiHint?.trim())
  .map((definition) => definition.type);
check(
  '每个组件都写了非空 aiHint',
  missingHint.length === 0,
  `缺 hint çš„组件:${missingHint.join(',') || '(无)'}`,
);
const serialized = JSON.stringify(catalog);
check(
  '无 buildContent / icon / validate / defaults æ³„漏',
  !serialized.includes('buildContent') &&
    !serialized.includes('"icon"') &&
    !serialized.includes('validate') &&
    !serialized.includes('defaults'),
);
const tableEntry = catalog.find((item) => item.type === 'QualityTable')!;
const itemsPathField = tableEntry.fields.find((field) => field.key === 'itemsPath')!;
check(
  'itemsPath æ ‡è®°ä¸ºå¿…填且可绑定',
  itemsPathField.required === true && itemsPathField.bindable === true,
);
check('非枚举字段不带 enumOptions', itemsPathField.enumOptions === undefined);
const alignField = catalog
  .find((item) => item.type === 'Heading')!
  .fields.find((field) => field.key === 'align')!;
check(
  '枚举项同时给出 label ä¸Ž value',
  Boolean(alignField.enumOptions?.every((option) => option.label && option.value !== undefined)),
  JSON.stringify(alignField.enumOptions),
);
// æ¸…单是派生视图,条数与注册表一致即可;类型必须都能反查回注册表
check(
  '清单里每个 type éƒ½èƒ½åæŸ¥å›žæ³¨å†Œè¡¨',
  catalog.every((item) => Boolean(getQualityComponent(item.type))),
);
// ---------------------------------------------------------------- 10. å¤šè¡Œè½æ¬¾
// eslint-disable-next-line no-console
console.log('10. å¤šè¡Œè½æ¬¾ä¸è¢«æŠ˜æˆä¸€è¡Œ');
// AI ä¼šæŠŠåŽŸä»¶é¡µè„šä¸Žè½æ¬¾ç­¾ç½²è¡Œåˆå¹¶æˆä¸€æ®µå¤šè¡Œæ–‡æœ¬ã€‚åªè½¬ä¹‰ä¸æŠ˜è¡Œçš„è¯ï¼Œ
// æ¸²æŸ“时换行符会被 HTML çš„空白折叠吃掉,页脚与制造商信息糊成一片。
const MULTILINE = '检验员:__________\n制造商:新疆大罗素马铃薯制品有限公司';
[
  { type: 'ReportFooter', props: { text: MULTILINE } },
  { type: 'Text', props: { text: MULTILINE } },
].forEach((draft) => {
  const node = nodesOf(assembleQualityCanvas([draft]))[0]!;
  const attributes = attributesOf(node);
  const content = String(node.content ?? '');
  check(
    `${draft.type} çš„ content æŠŠæ¢è¡ŒæŠ˜æˆ <br>`,
    content.includes('<br>') && !content.includes('&lt;br&gt;'),
    content,
  );
  // å±žæ€§é‡Œä¿ç•™åŽŸå§‹æ¢è¡Œï¼šå±žæ€§é¢æ¿ä¸Žç»‘å®šéƒ½æŒ‰çº¯æ–‡æœ¬å¤„ç†ï¼ŒæŠ˜è¡Œåªå‘ç”Ÿåœ¨æ¸²æŸ“äº§ç‰©è¿™ä¸€ä¾§
  check(`${draft.type} çš„属性保留原始换行`, attributes['data-qc-text'] === MULTILINE, attributes['data-qc-text']);
});
// eslint-disable-next-line no-console
console.log(failures === 0 ? '\n全部通过' : `\n${failures} é¡¹å¤±è´¥`);
process.exit(failures === 0 ? 0 : 1);
.qc-conformance/dump-catalog.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
/**
 * ä¸´æ—¶è„šæœ¬ï¼šæŠŠçœŸå®žçš„ç§¯æœ¨æ¸…å•ï¼ˆå« aiHint)导出成 JSON æ–‡ä»¶ã€‚
 *
 * å­˜åœ¨çš„理由是「联调时发给后端的清单必须是真货」:手敲一份清单去测,测的是我敲的那份,
 * è€Œä¸æ˜¯å‰ç«¯çœŸæ­£ä¼šå‘出去的那份——hint æœ‰æ²¡æœ‰çœŸçš„被 catalog å¸¦å‡ºæ¥ï¼Œåªæœ‰è¿™æ¡è·¯å¾„能证明。
 * ç”¨æ³•:npx tsx .qc-conformance/dump-catalog.ts <输出路径>
 */
import { writeFileSync } from 'node:fs';
import {
  buildQualityComponentCatalog,
  registerAllQualityComponents,
} from '../src/components/quality';
registerAllQualityComponents();
const target = process.argv[2];
if (!target) {
  throw new Error('用法:npx tsx .qc-conformance/dump-catalog.ts <输出路径>');
}
const catalog = buildQualityComponentCatalog();
writeFileSync(target, JSON.stringify(catalog), 'utf8');
// eslint-disable-next-line no-console
console.log(`组件 ${catalog.length} é¡¹ï¼Œå¸¦ hint çš„ ${catalog.filter((c) => c.hint).length} é¡¹ â†’ ${target}`);
.qc-conformance/group-probe-p4.html
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>来料检验报告</title>
<style>
@page { size: 210mm 297mm; margin: 10mm 10mm 10mm 10mm; }
* { 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; }
</style>
</head>
<body><div><table><thead><tr><td>检验项目</td><td>检测要求</td><td>标准值</td><td>实测值</td><td>单位</td><td>判定</td><td>备注</td></tr></thead><tbody><tbody><tr><td rowspan="5">水分</td><td rowspan="5">w = (m1 - m0) / m Ã— 100%</td></tr><tbody><tr><td>试样质量 m</td><td></td><td>50.2</td><td>g</td><td></td><td></td></tr><tr><td>试样+称量瓶 m1</td><td></td><td>78.9</td><td>g</td><td></td><td></td></tr><tr><td>称量瓶 m0</td><td></td><td>28.7</td><td>g</td><td></td><td></td></tr><tr><td>水分结果 w</td><td>≤0.5</td><td>0.32</td><td>%</td><td></td><td></td></tr></tbody></tbody><tbody><tr><td rowspan="2">正丁醇含量</td><td rowspan="2">纯度 = 100% - æ°´åˆ† - é…¸å«é‡ - å…¶å®ƒæ‚è´¨</td></tr><tbody><tr><td>正丁醇含量 /%</td><td>≥99.5</td><td>99.7</td><td>%</td><td></td><td></td></tr></tbody></tbody></tbody></table></div></body>
</html>
.qc-conformance/group-probe-p5.html
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>来料检验报告</title>
<style>
@page { size: 210mm 297mm; margin: 10mm 10mm 10mm 10mm; }
* { 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; }
</style>
</head>
<body><div><table><thead><tr><td>检验项目</td><td>检测要求</td><td>子项</td><td>标准值</td><td>实测值</td><td>单位</td><td>判定</td></tr></thead><tbody><tr><td rowspan="4">水分</td><td rowspan="4">w = (m1 - m0) / m Ã— 100%</td><td>试样质量 m</td><td></td><td>50.2</td><td>g</td><td>无判定规则</td></tr><tr><td rowspan="1" style="display:none"></td><td rowspan="1" style="display:none"></td><td>试样+称量瓶 m1</td><td></td><td>78.9</td><td>g</td><td>无判定规则</td></tr><tr><td rowspan="1" style="display:none"></td><td rowspan="1" style="display:none"></td><td>称量瓶 m0</td><td></td><td>28.7</td><td>g</td><td>无判定规则</td></tr><tr><td rowspan="1" style="display:none"></td><td rowspan="1" style="display:none"></td><td>水分结果 w</td><td>≤0.5</td><td>0.32</td><td>%</td><td>无判定规则</td></tr><tr><td rowspan="1">正丁醇含量</td><td rowspan="1">纯度 = 100% - æ°´åˆ† - é…¸å«é‡ - å…¶å®ƒæ‚è´¨</td><td>正丁醇含量 /%</td><td>≥99.5</td><td>99.7</td><td>%</td><td>无判定规则</td></tr></tbody></table></div></body>
</html>
.qc-conformance/group-table-degrade.html
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>来料检验报告</title>
<style>
@page { size: 210mm 297mm; margin: 10mm 10mm 10mm 10mm; }
* { 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; }
</style>
</head>
<body><table><thead><tr><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">序号</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">检验项目</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">子项</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">检测要求</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">实测值</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">单位</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">判定</th></tr></thead><tbody><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">1</td><td style="border:1px solid #d9d9d9;padding:6px 8px">外观</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">无划痕、无异物</td><td style="border:1px solid #d9d9d9;padding:6px 8px">无划痕</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">无判定规则</td></tr><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">2</td><td style="border:1px solid #d9d9d9;padding:6px 8px">长度</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">10±0.5</td><td style="border:1px solid #d9d9d9;padding:6px 8px">10.2</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">合格</td></tr></tbody></table></body>
</html>
.qc-conformance/group-table-render.html
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>来料检验报告</title>
<style>
@page { size: 210mm 297mm; margin: 10mm 10mm 10mm 10mm; }
* { 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; }
</style>
</head>
<body><table><thead><tr><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">序号</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">检验项目</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">子项</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">检测要求</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">实测值</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">单位</th><th style="border:1px solid #d9d9d9;padding:6px 8px;background:#fafafa;font-weight:600">判定</th></tr></thead><tbody><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">1</td><td rowspan="3" style="border:1px solid #d9d9d9;padding:6px 8px">水分</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">w = (m1 - m0) / m Ã— 100%</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">无判定规则</td></tr><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">2</td><td rowspan="1" hidden="hidden" style="border:1px solid #d9d9d9;padding:6px 8px">试样质量 m</td><td style="border:1px solid #d9d9d9;padding:6px 8px">试样质量 m</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">50.2</td><td style="border:1px solid #d9d9d9;padding:6px 8px">g</td><td style="border:1px solid #d9d9d9;padding:6px 8px">无判定规则</td></tr><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">3</td><td rowspan="1" hidden="hidden" style="border:1px solid #d9d9d9;padding:6px 8px">水分结果 w</td><td style="border:1px solid #d9d9d9;padding:6px 8px">水分结果 w</td><td style="border:1px solid #d9d9d9;padding:6px 8px">≤0.5</td><td style="border:1px solid #d9d9d9;padding:6px 8px">0.32</td><td style="border:1px solid #d9d9d9;padding:6px 8px">%</td><td style="border:1px solid #d9d9d9;padding:6px 8px">合格</td></tr><tr><td style="border:1px solid #d9d9d9;padding:6px 8px">4</td><td rowspan="1" style="border:1px solid #d9d9d9;padding:6px 8px">外观</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">无划痕、无异物</td><td style="border:1px solid #d9d9d9;padding:6px 8px">无划痕</td><td style="border:1px solid #d9d9d9;padding:6px 8px"></td><td style="border:1px solid #d9d9d9;padding:6px 8px">无判定规则</td></tr></tbody></table></body>
</html>
.qc-conformance/grouped-table-render.png
.qc-conformance/render-ts.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,111 @@
/**
 * ä¸´æ—¶å¯¹æ‹è„šæœ¬ï¼šç”¨å‰ç«¯æ¸²æŸ“/规则引擎跑同一份 fixture,产出期望产物,
 * ä¾›åŽç«¯ Java å¼•擎的一致性测试逐字比对。用完即删,不入库。
 *
 * ç”¨æ³•:npx tsx .qc-conformance/render-ts.ts <fixtureDir> <outputDir>
 */
import { readFileSync, writeFileSync } from 'node:fs';
import { createContext } from '../src/components/quality/engine/context';
import { evaluateReport } from '../src/components/quality/engine/report-evaluator';
import { renderReport } from '../src/components/quality/engine/render';
import { evaluateRule } from '../src/components/quality/engine/rule-engine';
interface ExpressionCase {
  expression: string;
  scope: 'item2' | 'item3' | 'report';
}
const EXPRESSIONS: ExpressionCase[] = [
  { expression: 'report.reportNo', scope: 'report' },
  { expression: "report.inspectType IN ('IQC', 'IPQC')", scope: 'report' },
  { expression: "report.inspectType NOT IN ('OQC')", scope: 'report' },
  { expression: 'PASS_RATE(inspectionItems.result)', scope: 'report' },
  { expression: 'FAIL_RATE(inspectionItems.result)', scope: 'report' },
  { expression: 'COUNT(inspectionItems)', scope: 'report' },
  { expression: 'AVG(inspectionItems.actualValue)', scope: 'report' },
  { expression: 'ROUND(AVG(inspectionItems.actualValue), 1)', scope: 'report' },
  { expression: 'STDDEV(inspectionItems.actualValue)', scope: 'report' },
  { expression: 'SUM(inspectionItems.actualValue)', scope: 'report' },
  { expression: 'MAX(inspectionItems.actualValue)', scope: 'report' },
  { expression: 'MIN(inspectionItems.actualValue)', scope: 'report' },
  { expression: 'ABS(-3.5)', scope: 'report' },
  { expression: 'CP(inspectionItems.actualValue, 70, 30)', scope: 'report' },
  { expression: 'CPK(inspectionItems.actualValue, 70, 30)', scope: 'report' },
  { expression: 'ROUND(10 / 3, 4)', scope: 'report' },
  { expression: '3 * 2 - 1', scope: 'report' },
  { expression: "report.reportNo + '-' + report.spec", scope: 'report' },
  { expression: 'report.total = 3 AND report.passCount = 1', scope: 'report' },
  { expression: "report.passRate = '33.33%'", scope: 'report' },
  { expression: 'report.supplierCode = NULL', scope: 'report' },
  { expression: 'TRUE AND NOT FALSE', scope: 'report' },
  { expression: '!report.total', scope: 'report' },
  { expression: '1 / 0', scope: 'report' },
  { expression: 'FOO(1)', scope: 'report' },
  { expression: 'report.reportNo >= ', scope: 'report' },
  { expression: "AVG('abc')", scope: 'report' },
  { expression: 'item.actualValue >= item.lowerLimit AND item.actualValue <= item.upperLimit', scope: 'item2' },
  { expression: "item.itemName IN ('长度', '硬度')", scope: 'item2' },
  { expression: "item.itemName NOT IN ('外观')", scope: 'item2' },
  { expression: '-item.index', scope: 'item2' },
  { expression: 'item.actualValue = 10.2', scope: 'item2' },
  { expression: 'item.result', scope: 'item2' },
  { expression: 'item.actualValue BETWEEN item.lowerLimit AND item.upperLimit', scope: 'item3' },
  { expression: 'item.actualValue NOT BETWEEN item.lowerLimit AND item.upperLimit', scope: 'item3' },
  // çœŸå€¼å£å¾„:能当数字看的一律按 !== 0 åˆ¤çœŸï¼Œå­—符串 "0" æ˜¯å‡å€¼ï¼ˆä¸æ˜¯ JS Boolean() çš„口径)
  { expression: '!0', scope: 'report' },
  { expression: '!1', scope: 'report' },
  { expression: '!"0"', scope: 'report' },
  { expression: '!"abc"', scope: 'report' },
  { expression: '!""', scope: 'report' },
  { expression: '-0', scope: 'report' },
];
const fixtureDir = process.argv[2]!;
const outputDir = process.argv[3]!;
const schema = JSON.parse(readFileSync(`${fixtureDir}/schema.json`, 'utf8'));
const rawContext = JSON.parse(readFileSync(`${fixtureDir}/context.json`, 'utf8'));
const rules = schema.rules ?? [];
const context = createContext(rawContext);
const outcome = renderReport({ context, rules, schema });
writeFileSync(`${outputDir}/expected-html.html`, outcome.html, 'utf8');
const evaluated = evaluateReport(context, rules).context;
const scopes: Record<string, unknown> = {
  report: { ...evaluated, index: 0 },
  item2: { ...evaluated, item: evaluated.inspectionItems[1], index: 2 },
  item3: { ...evaluated, item: evaluated.inspectionItems[2], index: 3 },
};
const results = EXPRESSIONS.map(({ expression, scope }) => {
  try {
    const value = evaluateRule(expression, scopes[scope]);
    const kind =
      value === null || value === undefined ? 'nil' : typeof value === 'object' ? 'object' : typeof value;
    return { expression, scope, kind, text: String(value) };
  } catch (error) {
    return { expression, scope, error: (error as Error).message };
  }
});
writeFileSync(
  `${outputDir}/expected-rules.json`,
  `${JSON.stringify({ errors: outcome.errors, results }, null, 2)}\n`,
  'utf8',
);
// eslint-disable-next-line no-console
console.log(
  `判定后的报告字段:${JSON.stringify({
    result: evaluated.report.result,
    resultText: evaluated.report.resultText,
    total: evaluated.report.total,
    passCount: evaluated.report.passCount,
    failCount: evaluated.report.failCount,
    passRate: evaluated.report.passRate,
  })}`,
);
// eslint-disable-next-line no-console
console.log(`各项判定:${evaluated.inspectionItems.map((item) => `${item.itemName}=${item.result}`).join(', ')}`);
.qc-conformance/verify-columns.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,437 @@
/**
 * æŽ¢é’ˆï¼šå¯è‡ªå®šåˆ—的检验表(GroupedQualityTable.columns / headerSpans)与可配字段的样品信息
 * ï¼ˆSampleInfo.fields)。
 *
 * åªè¯»ï¼Œä¸æ”¹ä»£ç ã€ä¸ç¢°æ•°æ®åº“。用法:npx tsx .qc-conformance/verify-columns.ts
 *
 * å®ˆä¸‰ä»¶äº‹ï¼š
 * 1. **属性缺席时产物逐字不变**——这是本轮的第一优先级:存量和已发布模板一个字节都不该动。
 *    è¿™é‡Œæ¯”对的是整段产物字符串的 sha256,而不是「列数还是 7」这种弱断言:
 *    åˆ—数不变但 style é¡ºåºå˜äº†ã€å±žæ€§æ¬å®¶äº†ï¼Œä¸€æ ·ä¼šè®©å·²å‘布报告重出时变样。
 * 2. å±žæ€§åœ¨åœºæ—¶åˆ—数、列序、合并属性落点、两级表头跨列都对得上;
 * 3. å†™é”™æ—¶ validate æŠ¥å¾—出、且报的是能改的那一处(不是笼统一句「格式错误」)。
 *
 * ç›´æŽ¥æ‰“在真实 buildContent / validate çš„产物上:结构一改这里就红,不必等浏览器。
 */
import type { QualityProps } from '../src/components/quality/core/types';
import { createHash } from 'node:crypto';
import { resolveQualityProps, validateQualityProps } from '../src/components/quality/core/validator';
import { buildQualityComponentCatalog } from '../src/components/quality/core/catalog';
import { parseColumnSpec } from '../src/components/quality/core/column-spec';
import { sampleInfoDefinition } from '../src/components/quality/header/sample-info';
import { groupedQualityTableDefinition } from '../src/components/quality/inspection/grouped-quality-table';
import { registerAllQualityComponents } from '../src/components/quality';
const failures: string[] = [];
function expect(label: string, actual: unknown, wanted: unknown): void {
  const ok = actual === wanted;
  if (!ok) {
    failures.push(`${label}:期望 ${JSON.stringify(wanted)},实际 ${JSON.stringify(actual)}`);
  }
  // eslint-disable-next-line no-console
  console.log(`${ok ? '  ok  ' : ' FAIL '} ${label} = ${JSON.stringify(actual)}`);
}
function expectTrue(label: string, actual: boolean, detail?: string): void {
  if (!actual) {
    failures.push(`${label}${detail ? `:${detail}` : ''}`);
  }
  // eslint-disable-next-line no-console
  console.log(`${actual ? '  ok  ' : ' FAIL '} ${label}`);
}
function sha(text: string): string {
  return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 16);
}
/** çœŸå®ž buildContent çš„产物(组件产出的是 HTML å­—符串) */
function build(
  definition: typeof groupedQualityTableDefinition | typeof sampleInfoDefinition,
  props?: QualityProps,
): string {
  const resolved = resolveQualityProps(definition, props);
  return String(definition.buildContent(resolved).components);
}
function validate(
  definition: typeof groupedQualityTableDefinition | typeof sampleInfoDefinition,
  props?: QualityProps,
): string[] {
  return validateQualityProps(definition, props);
}
/** è¡¨å¤´æ ¼æ•°ï¼š`<th` ä¼šæŠŠ `<thead` ä¹Ÿç®—进去,所以数闭合标签 */
function headCellCount(html: string): number {
  return (html.match(/<\/th>/g) ?? []).length;
}
function theadRowCount(html: string): number {
  const thead = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'));
  return (thead.match(/<tr>/g) ?? []).length;
}
const MERGE_ATTRS = ' rowspan="{{item.group.span}}" hidden="{{item.group.hidden}}"';
// ------------------------------------------------- 1. ç¼ºå¸­ = é€å­—不变(第一优先级)
// æ”¹è¿™è½®ä¹‹å‰çš„产物指纹。列数、style é¡ºåºã€åˆå¹¶å±žæ€§è½ç‚¹ï¼Œä»»ä½•一处动了这个 hash éƒ½ä¼šå˜ã€‚
// ä¹‹æ‰€ä»¥å†»ç»“ hash è€Œä¸æ˜¯å†»ç»“整段 HTML:整段太长,diff æ—¶çœ‹ä¸å‡ºæ”¹çš„æ˜¯å“ªä¸€æ ¼ã€‚
//
// è¿™ä¸¤ä¸ªå€¼ä¸æ˜¯ã€Œç…§ç€çŽ°åœ¨çš„å®žçŽ°æŠ„ä¸€éã€ï¼Œè€Œæ˜¯æŠŠæ”¹å‰çš„å®žçŽ°ï¼ˆä»Žå·¥ä½œåŒºé‡Œè¿˜åŽŸå‡ºæ¥çš„æ—§ç‰ˆæœ¬ï¼‰
// ä¸Žæ”¹åŽçš„实现跑同一批属性、逐字比对之后取下来的:12 ç»„属性组合(默认 / title ç©º /
// å„显示开关单独与组合关闭 / æ¢æ•°æ®æºè·¯å¾„ / æ¢æ ‡é¢˜ / æ¯è¡Œ 1、3 ç»„ / å…³è¾¹æ¡†ï¼‰å…¨éƒ¨ same。
const GROUPED_TABLE_BASELINE = '8e803b882a62ba70';
const SAMPLE_INFO_BASELINE = 'a07da23e176df130';
// eslint-disable-next-line no-console
console.log('\n===== 1. ä¸¤ä¸ªæ–°å±žæ€§ç¼ºå¸­ï¼šäº§ç‰©å¿…须与改这轮之前逐字一致 =====');
{
  // é»˜è®¤ props(defaults é‡Œåˆ»æ„ä¸æ”¾ columns / headerSpans / fields)
  const table = build(groupedQualityTableDefinition);
  const sample = build(sampleInfoDefinition);
  expect('分组表:defaults é‡Œæ²¡æœ‰ columns', 'columns' in groupedQualityTableDefinition.defaults, false);
  expect('分组表:defaults é‡Œæ²¡æœ‰ headerSpans', 'headerSpans' in groupedQualityTableDefinition.defaults, false);
  expect('样品信息:defaults é‡Œæ²¡æœ‰ fields', 'fields' in sampleInfoDefinition.defaults, false);
  expect('分组表默认产物指纹', sha(table), GROUPED_TABLE_BASELINE);
  expect('样品信息默认产物指纹', sha(sample), SAMPLE_INFO_BASELINE);
  // å±žæ€§å³ä½¿æ˜¾å¼ä¼ æˆç©ºä¸²ä¹Ÿå¿…须回落:属性面板清空一格是常事,
  // ç©ºä¸²è‹¥è¢«å½“成「0 åˆ—」就会渲染出一张没有列的残表
  expect('分组表:columns ä¼ ç©ºä¸²ä»å‡ºé»˜è®¤ 7 åˆ—', headCellCount(build(groupedQualityTableDefinition, { columns: '' })), 7);
  expect('分组表:headerSpans ä¼ ç©ºä¸²ä»å‡ºå•层表头', theadRowCount(build(groupedQualityTableDefinition, { headerSpans: '' })), 1);
  expect('样品信息:fields ä¼ ç©ºä¸²ä»å‡ºé»˜è®¤ 6 é¡¹', (build(sampleInfoDefinition, { fields: '' }).match(/<td/g) ?? []).length, 12);
  // é»˜è®¤åˆ—的合并属性仍只挂在「检验项目」那一格上
  const cells = table.split('<td');
  expectTrue('默认列含「子项」列', table.includes('>子项</th>'));
  expectTrue(
    '默认列:带 rowspan çš„æ ¼åªæœ‰ 1 ä¸ª',
    cells.filter((cell) => cell.includes('rowspan=')).length === 1,
  );
  expectTrue('默认列:合并属性在「检验项目」格上', Boolean(cells.find((cell) => cell.includes('{{item.itemName}}'))?.includes(MERGE_ATTRS)));
}
// ------------------------------------------------- 2. columns åœ¨åœºï¼šåˆ—数、列序、合并落点
// eslint-disable-next-line no-console
console.log('\n===== 2. columns åœ¨åœºï¼šæ•´è¡¨æŒ‰å®ƒæ¥ï¼Œæ˜¾ç¤ºå¼€å…³ä¸å†ç”Ÿæ•ˆ =====');
{
  const custom =
    '序号={{index}}|#检验项目={{item.itemName}}|子项={{item.group.childName}}|' +
    '检测方法={{item.checkMethod}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|' +
    '单位={{item.unit}}|判定={{item.resultText}}';
  const html = build(groupedQualityTableDefinition, { columns: custom });
  expect('表头 8 åˆ—', headCellCount(html), 8);
  expect('行模板 8 æ ¼', (html.match(/<td/g) ?? []).length, 8);
  expectTrue('新增的「检测方法」列表头在场', html.includes('>检测方法</th>'));
  expectTrue('「检测方法」列取 item.checkMethod', html.includes('{{item.checkMethod}}'));
  // åˆ—序 = ä¸²é‡Œçš„顺序:检测方法必须夹在「子项」与「检测要求」之间
  const heads = [...html.matchAll(/<th[^>]*>([^<]*)<\/th>/g)].map((match) => match[1]);
  expect('表头顺序', heads.join(','), '序号,检验项目,子项,检测方法,检测要求,实测值,单位,判定');
  // åˆå¹¶å±žæ€§è·Ÿç€ `#` èµ°ï¼šåŠ äº†ä¸€åˆ—ä¹‹åŽå®ƒä»å¿…é¡»è½åœ¨ã€Œæ£€éªŒé¡¹ç›®ã€é‚£ä¸€æ ¼ï¼Œä¸”åªæœ‰ä¸€æ ¼
  const cells = html.split('<td');
  expectTrue('合并属性落在 # æ ‡è®°çš„那一列', Boolean(cells.find((cell) => cell.includes('{{item.itemName}}'))?.includes(MERGE_ATTRS)));
  expect(
    '带合并属性的格数',
    cells.filter((cell) => cell.includes('rowspan=')).length,
    1,
  );
  // æ˜¾ç¤ºå¼€å…³è¢« columns è¦†ç›–:把序号关掉、又自己写回序号列,就得出序号(两面打架时以 columns ä¸ºå‡†ï¼‰
  const withToggleOff = build(groupedQualityTableDefinition, { columns: custom, showIndex: false });
  expect('关掉「显示序号」也不影响自定义列', headCellCount(withToggleOff), 8);
  // é»˜è®¤åˆ— 7 åˆ—、这里 8 åˆ—:若解析被忽略,一定是 7
  expectTrue('确实没有回落到默认列', headCellCount(html) !== 7);
}
// ------------------------------------------------- 3. headerSpans åœ¨åœºï¼šä¸¤çº§è¡¨å¤´
// eslint-disable-next-line no-console
console.log('\n===== 3. headerSpans åœ¨åœºï¼šä¸Šå±‚表头 + ä¸‹å±‚列名 =====');
{
  const html = build(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}',
    headerSpans: '检验结果^5|结论^2',
  });
  expect('thead å˜æˆä¸¤è¡Œ', theadRowCount(html), 2);
  expectTrue('上层第一格跨 5 åˆ—', html.includes('colspan="5"'));
  expectTrue('上层第二格跨 2 åˆ—', html.includes('colspan="2"'));
  expectTrue('上层表头文字在场', html.includes('>检验结果<') && html.includes('>结论<'));
  // ä¸Šå±‚ 2 æ ¼ã€ä¸‹å±‚ 7 æ ¼ï¼šä¸Šå±‚只加跨列标题,绝不能把下层的列名顶掉
  const theadRows = html
    .slice(html.indexOf('<thead'), html.indexOf('</thead>'))
    .split('<tr>')
    .slice(1);
  expect('上层表头 2 æ ¼', (theadRows[0]?.match(/<\/th>/g) ?? []).length, 2);
  expect('下层表头 7 æ ¼', (theadRows[1]?.match(/<\/th>/g) ?? []).length, 7);
  // åªæœ‰ä¸€å±‚时不要凭空多出一行
  expect('没填 headerSpans ä»æ˜¯å•层', theadRowCount(build(groupedQualityTableDefinition)), 1);
  // è·¨åˆ—数写不出 ^N æ—¶æŒ‰ 1 åˆ—算,靠总数兜底(parser çš„æ—¢æœ‰çº¦å®šï¼‰
  const implicit = build(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}|单位={{item.unit}}|判定={{item.resultText}}',
    headerSpans: '检验结果|结论',
  });
  expect('七段各按 1 åˆ—算,合计 2 â‰  7 â‡’ æ•´å±‚丢弃退回单层', theadRowCount(implicit), 1);
}
// --------------------------------- 3b. çºµå‘合并格:一列自己既是列名又是上格
// eslint-disable-next-line no-console
console.log('\n===== 3b. çºµå‘合并格(结论这类列)合成跨两行的一格 =====');
{
  // ç™¾äº‹åŽŸä»¶é‡Œæœ€å³ä¸€åˆ—ã€Œç»“è®ºã€æ˜¯è‡ªå·±å æ»¡ä¸Šä¸‹ä¸¤å±‚è¡¨å¤´çš„ä¸€æ ¼ï¼ˆæå–æ–‡æœ¬é‡Œä¸‹å±‚é‚£æ ¼å†™ç€ã€Œâ†‘åŒä¸Šã€ï¼‰ã€‚
  // çœŸæ¨¡åž‹ä¸€ç›´ç…§å†™ `结论^1`,若不认这个形态,「结论」二字会在上下两层各印一次。
  const vertical = build(groupedQualityTableDefinition, {
    columns:
      '检验项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
    headerSpans: '检验结果^5|结论^1',
  });
  expectTrue('纵向合并格出 rowspan="2"', vertical.includes('rowspan="2"'));
  expectTrue('不再出一格跨一列的上格', !vertical.includes('colspan="1"'));
  expect('「结论」全文只印一次', (vertical.match(/结论/g) ?? []).length, 1);
  expect('上层 2 æ ¼ + ä¸‹å±‚ 5 æ ¼ = 7', headCellCount(vertical), 7);
  expect('仍然是两行表头', theadRowCount(vertical), 2);
  // åˆ¤æ®æ˜¯ã€Œæ ‡é¢˜ä¸Žè¯¥åˆ—列名一字不差」,不是「排在最后」:不同字就老老实实按分组跨列走
  const notVertical = build(groupedQualityTableDefinition, {
    columns: '检验项目={{item.itemName}}|判定={{item.resultText}}',
    headerSpans: '检验结果^1|结论^1',
  });
  expectTrue('上格与列名不同字 â‡’ ä¸å‡º rowspan', !notVertical.includes('rowspan="2"'));
  expect('上格与列名不同字 â‡’ ä¸¤å±‚各 2 æ ¼', headCellCount(notVertical), 4);
}
// ------------------------------------------------- 3c. è‡ªå®šåˆ—不许把表格撑出纸张
// eslint-disable-next-line no-console
console.log('\n===== 3c. è‡ªå®šåˆ—的单元格允许长串折行(否则整表溢出纸张)=====');
{
  // è‡ªå®šåˆ—拿不到任何宽度声明,列宽只能由最小内容宽度倒推;而值是 {{item.standardValue}}
  // è¿™ç±»ä¸å«ç©ºæ ¼çš„长串,最小内容宽度就是整串长度。实测 6 åˆ—时整表 810px,
  // è€Œ A4 æ­£æ–‡å®½åªæœ‰ 672px:表格溢出 138px,最右一列被挤成 29px çš„竖条
  // ï¼ˆè®¾è®¡å™¨é‡Œå°±æ˜¯ã€Œç»“论」两个字竖着排在纸张外面)。加 overflow-wrap:anywhere åŽæ•´è¡¨ 672px。
  const custom = build(groupedQualityTableDefinition, {
    columns:
      '#项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|' +
      '标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
    headerSpans: '检验结果^5|结论^1',
  });
  const customCells = custom.split('<td').slice(1);
  expect('自定列出 6 ä¸ªå•元格', customCells.length, 6);
  expect(
    '自定列的格都带 overflow-wrap:anywhere',
    customCells.filter((cell) => cell.includes('overflow-wrap:anywhere')).length,
    6,
  );
  // é»˜è®¤åˆ—反过来:它自带手工调过的宽度,再叠一条折行会让没写宽度的「检验项目」「子项」
  // è¢«æŒ¤åˆ° 30 ä½™åƒç´ ã€è¡Œé«˜ä»Ž 59px æ¶¨åˆ° 283px——实测过,不加比加好
  const fallback = build(groupedQualityTableDefinition);
  expectTrue('默认列的格不带 overflow-wrap', !fallback.includes('overflow-wrap'));
}
// ------------------------------------------------- 4. å†™é”™æ—¶æŠ¥å¾—出、报得准
// eslint-disable-next-line no-console
console.log('\n===== 4. å†™é”™æ—¶ validate æŠ¥çš„æ˜¯èƒ½æ”¹çš„那一处 =====');
{
  const badSum = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目={{item.itemName}}|子项={{item.group.childName}}|检测要求={{item.requirement}}|实测值={{item.actualValue}}',
    headerSpans: '检验结果^5|结论^2',
  });
  expect('跨列数不匹配报 1 æ¡', badSum.length, 1);
  expectTrue(
    '文案同时给出合计与实际列数',
    badSum[0]?.includes('合计 7') && badSum[0]?.includes('实际列数为 5'),
    badSum[0],
  );
  expectTrue('文案给出可执行动作', Boolean(badSum[0]?.includes('^')), badSum[0]);
  const missingEqual = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目',
  });
  expect('缺 = æŠ¥ 1 æ¡', missingEqual.length, 1);
  expectTrue('文案按段号定位', Boolean(missingEqual[0]?.includes('第 2 æ®µ')), missingEqual[0]);
  const emptyExpr = validate(groupedQualityTableDefinition, { columns: '序号={{index}}|检测方法=' });
  expectTrue('空的绑定表达式被拦下', emptyExpr.some((line) => line.includes('第 2 æ®µ')), JSON.stringify(emptyExpr));
  // åˆ—定义有错时不再叠加跨列数校验:那时列数不可信,报了只会把人往错的方向带
  const both = validate(groupedQualityTableDefinition, {
    columns: '序号={{index}}|检验项目',
    headerSpans: '检验结果^5',
  });
  expect('列定义错 + è¡¨å¤´è·¨åˆ—也错 â‡’ åªæŠ¥åˆ—定义', both.length, 1);
  expectTrue('报的是列定义那条', Boolean(both[0]?.includes('第 2 æ®µ')), both[0]);
  // ç”¨é»˜è®¤åˆ— + ä¸¤å±‚表头:跨列数按默认的 7 åˆ—校验
  const defaultOk = validate(groupedQualityTableDefinition, { headerSpans: '检验结果^5|结论^2' });
  expect('默认 7 åˆ— + æ£€éªŒç»“æžœ^5|结论^2 é€šè¿‡', defaultOk.length, 0);
  // éšè—äº†æ£€æµ‹è¦æ±‚却留着判定:只有在用默认列时才算数(填了 columns æ•´è¡¨æŒ‰ columns æ¥ï¼‰
  const toggleClash = validate(groupedQualityTableDefinition, { showRequirement: false });
  expectTrue('默认列下才报「隐藏检测要求却保留判定」', toggleClash.length === 1, JSON.stringify(toggleClash));
  const toggleClashCustom = validate(groupedQualityTableDefinition, {
    showRequirement: false,
    columns: '序号={{index}}|判定={{item.resultText}}',
  });
  expect('填了 columns å°±ä¸å†æŠ¥é‚£æ¡è¯¯æŠ¥', toggleClashCustom.length, 0);
}
// ------------------------------------------------- 5. æ ·å“ä¿¡æ¯å­—段可配
// eslint-disable-next-line no-console
console.log('\n===== 5. SampleInfo.fields =====');
{
  const html = build(sampleInfoDefinition, {
    fields:
      '样品编号={{report.sampleNo}}|产品名称={{report.productName}}|规格={{report.spec}}|' +
      '生产日期={{report.produceDate}}|批号={{report.batchNo}}|土豆品种={{report.potatoVariety}}|有效日期={{report.expireDate}}',
    pairsPerRow: 2,
  });
  expect('7 é¡¹ Ã— 2 æ ¼ = 14 ä¸ª td', (html.match(/<td/g) ?? []).length, 14);
  expect('7 é¡¹æ¯è¡Œ 2 ç»„ â‡’ 4 è¡Œ', (html.match(/<tr>/g) ?? []).length, 4);
  expectTrue('自定义字段名在场', html.includes('>土豆品种<'));
  expectTrue('自定义绑定在场', html.includes('{{report.potatoVariety}}'));
  // æ¯è¡Œæ”¾å‡ ç»„仍由 pairsPerRow æŽ§åˆ¶
  const oneRow = build(sampleInfoDefinition, { fields: 'A={{report.a}}|B={{report.b}}|C={{report.c}}', pairsPerRow: 3 });
  expect('pairsPerRow=3 â‡’ 1 è¡Œ', (oneRow.match(/<tr>/g) ?? []).length, 1);
  expect('pairsPerRow=3 â‡’ 6 æ ¼', (oneRow.match(/<td/g) ?? []).length, 6);
  // æœ«è¡Œå‡‘不满一组时,最后一格要横向吃掉余下的列。
  // ä¸è¡¥çš„话右侧秃出几个只有边框的空格子——字段数不是「每行组数」整数倍时必然出现,
  // è€Œ AI å¯¼å…¥æŒ‰åŽŸä»¶å­—æ®µå¡« fields,奇偶根本不由我们定(原件 5 é¡¹ã€æ¯è¡Œ 2 ç»„是最典型的一种)。
  const ragged = build(sampleInfoDefinition, {
    fields:
      '产品名称={{report.productName}}|规格={{report.spec}}|批号={{report.batchNo}}|' +
      '检验员={{report.inspector}}|检验日期={{report.inspectDate}}',
    pairsPerRow: 2,
  });
  expect('5 é¡¹ Ã· 2 â‡’ 3 è¡Œ', (ragged.match(/<tr>/g) ?? []).length, 3);
  expect('补齐不增减格数(仍是 5 å¯¹ = 10 æ ¼ï¼‰', (ragged.match(/<td/g) ?? []).length, 10);
  expect('只多出 1 ä¸ª colspan', (ragged.match(/colspan=/g) ?? []).length, 1);
  expectTrue('补齐落在末行最后一格,跨 3 åˆ—(2 åˆ—空位 + è‡ªå·±ï¼‰', ragged.includes('colspan="3"'), ragged.slice(-300));
  const raggedTail = ragged.split('<tr>').pop() ?? '';
  const tailCells = raggedTail.split('<td').slice(1);
  expect('末行还是 2 æ ¼ï¼ˆå­—段名 + å€¼ï¼‰', tailCells.length, 2);
  expectTrue(
    '补的是「值」格而不是「字段名」格:补到字段名上这一列的宽度就跟着变,各行左边缘会错开',
    tailCells[0]?.includes('width:90px') && !tailCells[0]?.includes('colspan'),
    tailCells[0],
  );
  expectTrue(
    '末行仍是「检验日期 + å®ƒçš„值」这一对',
    raggedTail.includes('>检验日期<') && raggedTail.includes('{{report.inspectDate}}'),
    raggedTail,
  );
  // æ•´è¡Œå‡‘满时一格都不许补:默认字段正是 6 é¡¹ï¼ˆÃ·2 æ•´è¡Œï¼‰ï¼Œå¤šè¡¥ä¸€æ ¼å°±ä¼šåŠ¨åˆ°ä¸Šé¢å†»ç»“çš„é€å­—æŒ‡çº¹
  expectTrue('默认 6 é¡¹ Ã· 2 å…¨æ˜¯æ•´è¡Œï¼Œæ—  colspan', !build(sampleInfoDefinition).includes('colspan='));
  expectTrue(
    'pairsPerRow=1 æ—¶æ¯è¡Œè‡ªæˆä¸€ç»„,永远不补',
    !build(sampleInfoDefinition, { pairsPerRow: 1 }).includes('colspan='),
  );
  const raggedThree = build(sampleInfoDefinition, {
    fields: 'A={{report.a}}|B={{report.b}}|C={{report.c}}|D={{report.d}}',
    pairsPerRow: 3,
  });
  expect('4 é¡¹ Ã· 3 â‡’ æœ«è¡Œ 1 é¡¹ï¼Œè·¨ 5 åˆ—补齐(3 åˆ—空位 + è‡ªå·±ï¼‰', (raggedThree.match(/colspan="\d+"/g) ?? []).join(','), 'colspan="5"');
  // åˆå¹¶æ ‡è®°åœ¨æ ·å“ä¿¡æ¯é‡Œæ²¡æœ‰æ„ä¹‰ï¼Œå¿…须明确拒绝而不是静默丢掉
  const mergeRejected = validate(sampleInfoDefinition, { fields: '#样品编号={{report.sampleNo}}' });
  expect('字段名带 # æŠ¥ 1 æ¡', mergeRejected.length, 1);
  expectTrue('文案说明 # æ˜¯åˆ†ç»„表的用法', Boolean(mergeRejected[0]?.includes('分组检验项表')), mergeRejected[0]);
  expect('没有 # åˆ™é€šè¿‡', validate(sampleInfoDefinition, { fields: '样品编号={{report.sampleNo}}' }).length, 0);
}
// ------------------------------------------------- 6. æ³¨å…¥ï¼šåˆ—标题/表达式先转义再进 HTML
// eslint-disable-next-line no-console
console.log('\n===== 6. æ‰‹å†™çš„列标题与字段名不能成为注入点 =====');
{
  // æ³¨æ„æ ‡é¢˜é‡Œä¸èƒ½å¸¦ `=`:`=` æ˜¯ã€Œæ ‡é¢˜=表达式」的分隔符,带 `=` çš„æ ‡é¢˜åœ¨è¯­æ³•上就是非法的
  // ï¼ˆè§ parseColumnSpec çš„约定),所以这里用不含 `=` çš„æ ‡ç­¾æ¥éªŒè½¬ä¹‰
  const table = build(groupedQualityTableDefinition, {
    columns: '检验项目={{item.itemName}}|"><script>alert(1)</script>={{item.actualValue}}',
  });
  expectTrue(
    '列标题里的标签被转义',
    table.includes('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;'),
    table.slice(0, 400),
  );
  expectTrue('产物里没有活的 script', !table.includes('<script'), table.slice(0, 400));
  const sample = build(sampleInfoDefinition, { fields: '"><script>alert(1)</script>={{report.sampleNo}}' });
  expectTrue('字段名里的引号与标签被转义', sample.includes('&quot;&gt;&lt;script&gt;'), sample.slice(0, 400));
  expectTrue('没有逃出属性外的 script', !sample.includes('<script>'), sample.slice(0, 400));
}
// ------------------------------------------------- 7. è§£æžå™¨è¾¹ç•Œï¼ˆä¸¤ä¸ªç»„件共用的那一份)
// eslint-disable-next-line no-console
console.log('\n===== 7. å…±ç”¨è§£æžå™¨ï¼šç©ºæ®µè·³è¿‡ã€= åªåˆ‡ç¬¬ä¸€ä¸ªã€^ ä»Žæœ€åŽåˆ‡ =====');
{
  expect('首尾多写 | ä¸æŠ¥é”™', parseColumnSpec('|A={{a}}|B={{b}}|').errors.length, 0);
  expect('首尾多写 | å‡º 2 åˆ—', parseColumnSpec('|A={{a}}|B={{b}}|').items.length, 2);
  expect('空串出 0 åˆ—', parseColumnSpec('   ').items.length, 0);
  expect('undefined å‡º 0 åˆ—', parseColumnSpec(undefined).items.length, 0);
  // è¡¨è¾¾å¼é‡Œå†å‡ºçް = æ—¶ä¸èƒ½è¢«åˆ‡æˆç¬¬ä¸‰æ®µ
  expect('= åªåˆ‡ç¬¬ä¸€ä¸ª', parseColumnSpec('A=1=2').items[0]?.expr, '1=2');
  expect('# è¢«å‰¥æŽ‰å¹¶è®°ä¸ºåˆå¹¶åˆ—', parseColumnSpec('#检验项目={{item.itemName}}').items[0]?.label, '检验项目');
  expect('没写 # å°±ä¸æ˜¯åˆå¹¶åˆ—', parseColumnSpec('检验项目={{item.itemName}}').items[0]?.merge, false);
}
// ------------------------------------------------- 8. aiHint / ç§¯æœ¨æ¸…单:模型得知道能填这两个属性
// eslint-disable-next-line no-console
console.log('\n===== 8. aiHint è¦è¯´ã€Œåˆ—不一样就用 columns」,而不是「换组件」 =====');
{
  const hint = groupedQualityTableDefinition.aiHint ?? '';
  expectTrue('hint ç‚¹åäº† columns', hint.includes('columns'), hint);
  expectTrue('hint ç‚¹åäº† headerSpans', hint.includes('headerSpans'), hint);
  expectTrue(
    'hint è¯´æ¸…「既有列名又是上格」的列怎么报',
    hint.includes('↑同上') && hint.includes('跨两行'),
    hint,
  );
  // è¿™ä¸€æ¡æ˜¯æœ¬è½®æœ€è¦ç´§çš„æŽªè¾žï¼šä¸Šä¸€è½®é€‰åž‹ä¸ç¨³çš„æ ¹å› å°±æ˜¯æç¤ºè¯é‡Œå¤šäº†ä¸€å¤„指向别处的描述,
  // hint è‹¥æš—示「列不一致 â‡’ æ¢ä¸ªç»„件」,等于把刚修好的稳定性再推回去
  expectTrue('hint æ˜Žç¡®ã€Œä¸è¦æ”¹ç”¨åˆ«çš„组件」', hint.includes('不要改用别的组件'), hint);
  expectTrue('hint ä»ä¿ç•™ã€Œå¹³é“ºä¸€å±‚时用 QualityTable」这条边界', hint.includes('QualityTable'), hint);
  // æ¸…单是 aiHint ä¸Žæ–°å±žæ€§è¿›æç¤ºè¯çš„唯一出口:不在这里出现,模型就不可能填出来
  registerAllQualityComponents();
  const entry = buildQualityComponentCatalog().find((item) => item.type === 'GroupedQualityTable');
  const keys = (entry?.fields ?? []).map((field) => field.key).join(',');
  expectTrue('columns éš propertySchema è‡ªåŠ¨è¿›æ¸…å•', keys.includes('columns'), keys);
  expectTrue('headerSpans éš propertySchema è‡ªåŠ¨è¿›æ¸…å•', keys.includes('headerSpans'), keys);
  expectTrue('columns å£°æ˜Žä¸º text(属性面板出多行框)',
    (entry?.fields ?? []).find((field) => field.key === 'columns')?.type === 'text', keys);
  expectTrue('columns ä¸å¯ç»‘定(手写整串,不走选数据字段)',
    !(entry?.fields ?? []).find((field) => field.key === 'columns')?.bindable, keys);
  expectTrue('清单带上了 hint', Boolean(entry?.hint?.includes('columns')));
  const sample = buildQualityComponentCatalog().find((item) => item.type === 'SampleInfo');
  expectTrue('SampleInfo çš„ fields è¿›äº†æ¸…单',
    (sample?.fields ?? []).some((field) => field.key === 'fields'));
}
// eslint-disable-next-line no-console
console.log(
  failures.length === 0
    ? '\n全部通过:缺席时逐字不变,在场时列数/列序/合并/跨列都对,写错能定位到段,手写文本已转义,清单里带得到新属性。'
    : `\n有 ${failures.length} å¤„不符:\n${failures.map((line) => `  - ${line}`).join('\n')}`,
);
process.exitCode = failures.length === 0 ? 0 : 1;
.qc-conformance/verify-group-repeat.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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=')}`,
  );
});
.qc-conformance/verify-grouped-table.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,391 @@
/**
 * æŽ¢é’ˆï¼šåˆ†ç»„检验项表(GroupedQualityTable)的真实渲染产物验证。
 *
 * åªè¯»ï¼Œä¸æ”¹ä»£ç ã€ä¸ç¢°æ•°æ®åº“。用法:npx tsx .qc-conformance/verify-grouped-table.ts
 *
 * å®ˆä¸‰ä»¶äº‹ï¼š
 * 1. ç»„名格「合并几行」与「子项行让位」都由数据下发(rowspan / hidden)——
 *    æ¸²æŸ“期算不出来:路径在数组上继续取属性会按元素 pluck,`group.children.length` å–不到值;
 * 2. è®©ä½åŽçš„行仍要占满 7 åˆ—、不串位。这是「隐藏一格会不会把整行剩余格子左移一列」的真问题,
 *    HTML ç»“构层面只能验到「td æ•°ã€ï¼ŒçœŸæ­£çš„表格布局由浏览器定,所以脚本把渲染产物写到
 *    .qc-conformance/group-table-render.html,交给 Playwright å®žæµ‹æ¯ä¸€æ ¼çš„列位置;
 * 3. ç»„头锚点行与过程参数(无判定规则)不能把整份报告的结论压下去。
 *
 * ç»„件结构断言直接打在真实 buildContent çš„产物上:结构一改(列数、绑定路径、合并属性搬家),
 * è¿™é‡Œå°±çº¢ï¼Œä¸å¿…等浏览器。
 */
import type { ReportContextItem } from '../src/components/quality/engine/context';
import type { RenderNode, RenderSchema } from '../src/components/quality/engine/render';
import { writeFileSync } from 'node:fs';
import { registerAllQualityComponents } from '../src/components/quality';
import { assembleQualityCanvas } from '../src/components/quality/core/assembler';
import { resolveQualityProps } from '../src/components/quality/core/validator';
import { createContext } from '../src/components/quality/engine/context';
import { renderReport } from '../src/components/quality/engine/render';
import { groupedQualityTableDefinition } from '../src/components/quality/inspection/grouped-quality-table';
const failures: string[] = [];
// ç¬¬ 4 èŠ‚æŒ‰è‰ç¨¿ç±»åž‹æŸ¥æ³¨å†Œè¡¨è£…é…ï¼Œæ³¨å†Œè¡¨è¦æ˜¾å¼æ³¨å†Œæ‰æœ‰å†…å®¹ï¼ˆè®¾è®¡å™¨åœ¨ init æ—¶åšï¼‰
registerAllQualityComponents();
function expect(label: string, actual: unknown, wanted: unknown): void {
  const ok = actual === wanted;
  if (!ok) {
    failures.push(`${label}:期望 ${JSON.stringify(wanted)},实际 ${JSON.stringify(actual)}`);
  }
  // eslint-disable-next-line no-console
  console.log(`${ok ? '  ok  ' : ' FAIL '} ${label} = ${JSON.stringify(actual)}`);
}
function expectTrue(label: string, actual: boolean, detail?: string): void {
  if (!actual) {
    failures.push(`${label}${detail ? `:${detail}` : ''}`);
  }
  // eslint-disable-next-line no-console
  console.log(`${actual ? '  ok  ' : ' FAIL '} ${label}`);
}
/** ç»‘定路径与合并属性都必须落在一起,分散到别的列就是错版 */
const GROUP_CELL_ATTRS = ' rowspan="{{item.group.span}}" hidden="{{item.group.hidden}}"';
/** ä¸Žç»„件里的 CELL_BORDER åŒå€¼ï¼šè¿™é‡Œåªæ±‚浏览器量出来的排版与实际产物同形 */
const CELL_STYLE = { border: '1px solid #d9d9d9', padding: '6px 8px' };
const HEAD_CELL_STYLE = { ...CELL_STYLE, background: '#fafafa', 'font-weight': 600 };
// ---------------------------------------------------------------- 1. ç»„件结构(真实 buildContent)
// eslint-disable-next-line no-console
console.log('\n===== 1. ç»„件结构:7 åˆ—、第 2 æ ¼å¸¦åˆå¹¶å±žæ€§ã€é‡å¤æ ‡è®°è½åœ¨ tbody/tr ä¸Š =====');
{
  const content = groupedQualityTableDefinition.buildContent(
    resolveQualityProps(groupedQualityTableDefinition, {}),
  );
  const html = String(content.components);
  // æ•°é—­åˆæ ‡ç­¾ï¼š`<th` ä¼šæŠŠ `<thead` ä¹Ÿç®—进去
  const headCells = html.match(/<\/th>/g) ?? [];
  const rowCells = html.match(/<td/g) ?? [];
  expect('表头 7 åˆ—', headCells.length, 7);
  expect('行模板 7 åˆ—', rowCells.length, 7);
  expectTrue('表头含「子项」列', html.includes('>子项</th>'));
  expectTrue('表头含「检测要求」列', html.includes('>检测要求</th>'));
  // åˆå¹¶å±žæ€§åªèƒ½æŒ‚在「检验项目」这一格上:挂错格 = ç»„名跨错列
  const cells = html.split('<td');
  const itemNameCell = cells.find((cell) => cell.includes('{{item.itemName}}'));
  expectTrue('「检验项目」格带 rowspan ä¸Ž hidden', Boolean(itemNameCell?.includes(GROUP_CELL_ATTRS)),
    itemNameCell?.slice(0, 200));
  expectTrue(
    '合并属性没有跑到别的格上',
    cells.filter((cell) => cell.includes('rowspan=')).length === 1,
    `带 rowspan çš„æ ¼æ•°=${cells.filter((cell) => cell.includes('rowspan=')).length}`,
  );
  expectTrue('「子项」格取 item.group.childName', html.includes('{{item.group.childName}}'));
  expectTrue('「检测要求」格取 item.requirement', html.includes('{{item.requirement}}'));
  expectTrue(
    '重复标记落在 tbody/tr ä¸Š',
    html.includes('data-qc-repeat="inspectionItems"') && html.includes('data-qc-repeat-row="item"'),
  );
}
// ---------------------------------------------------------------- 2. çœŸå®žå¼•擎渲染
const cell = (text: string, attributes?: Record<string, string>): RenderNode => ({
  type: 'cell',
  attributes,
  style: CELL_STYLE,
  content: text,
});
/** è¡¨å¤´æ ¼ï¼šç»„件产出的是 <th>,GrapesJS è§£æžæˆå¸¦ tagName çš„ cell */
const headCell = (text: string): RenderNode => ({
  type: 'cell',
  tagName: 'th',
  style: HEAD_CELL_STYLE,
  content: text,
});
const row = (cells: RenderNode[], attributes?: Record<string, string>): RenderNode => ({
  type: 'row',
  attributes,
  components: cells,
});
/** ä¸Ž buildContent äº§ç‰©é€æ ¼åŒæž„的行模板(GrapesJS æŠŠé‚£æ®µ HTML è§£æžæˆçš„æ ‘就是这个形状) */
function tableSchema(): RenderSchema {
  const headers = ['序号', '检验项目', '子项', '检测要求', '实测值', '单位', '判定'];
  return {
    grapes: {
      pages: [
        {
          frames: [
            {
              component: {
                type: 'table',
                components: [
                  {
                    type: 'thead',
                    components: [row(headers.map((text) => headCell(text)))],
                  },
                  {
                    type: 'tbody',
                    attributes: { 'data-qc-repeat': 'inspectionItems' },
                    components: [
                      row(
                        [
                          cell('{{index}}'),
                          cell('{{item.itemName}}', {
                            rowspan: '{{item.group.span}}',
                            hidden: '{{item.group.hidden}}',
                          }),
                          cell('{{item.group.childName}}'),
                          cell('{{item.requirement}}'),
                          cell('{{item.actualValue}}'),
                          cell('{{item.unit}}'),
                          cell('{{item.resultText}}'),
                        ],
                        { 'data-qc-repeat-row': 'item' },
                      ),
                    ],
                  },
                ],
              },
            },
          ],
        },
      ],
    },
  } as unknown as RenderSchema;
}
type Item = Partial<ReportContextItem> & { itemName: string };
/** ç»„头锚点行:按定义没有实测值,检测要求放组公式 */
const anchor = (): Item => ({
  itemName: '水分',
  requirement: 'w = (m1 - m0) / m Ã— 100%',
  actualValue: '',
  // æ¯ä¸ªæ£€éªŒé¡¹çš„字段都要齐(哪怕是空串):字段缺席时绑定的属性会被跳过,合并格反而露出来
  unit: '',
  group: { childName: '', span: 3, hidden: '' },
});
/** ç»„内子项行:自己的名字进「子项」列,合并格让给组头那一行 */
const child = (name: string, actual: string, unit: string): Item => ({
  itemName: name,
  requirement: '',
  actualValue: actual,
  unit,
  group: { childName: name, span: 1, hidden: 'hidden' },
});
/** ç»„内子项里真正带规格、能判定的一条 */
const judgedChild = (name: string, actual: string, upper: number, lower: number): Item => ({
  ...child(name, actual, '%'),
  requirement: `≤${upper}`,
  upperLimit: upper,
  lowerLimit: lower,
});
/** ç‹¬ç«‹é¡¹ï¼šæ²¡æœ‰åˆ†ç»„,一格都不合并 */
const standalone = (): Item => ({
  itemName: '外观',
  requirement: '无划痕、无异物',
  actualValue: '无划痕',
  unit: '',
  group: { childName: '', span: 1, hidden: '' },
});
function render(items: Item[], label: string) {
  const result = renderReport({
    context: createContext({
      report: { reportNo: 'QC-2026-0001', reportName: '来料检验报告' },
      inspectionItems: items as ReportContextItem[],
    }),
    rules: [],
    schema: tableSchema(),
  });
  writeFileSync(`.qc-conformance/group-table-${label}.html`, result.html, 'utf8');
  const tbody = result.body.slice(result.body.indexOf('<tbody'));
  const rows = tbody.split('<tr').slice(1);
  return { result, rows, tds: rows.map((html) => html.split('<td').slice(1)) };
}
// eslint-disable-next-line no-console
console.log('\n===== 2. ç»„头 + å­é¡¹ + ç‹¬ç«‹é¡¹æ··æŽ’:合并格、让位格、列数 =====');
{
  // ç»„头 3 è¡Œï¼ˆé”šç‚¹ + 2 å­é¡¹ï¼‰ã€ç‹¬ç«‹é¡¹ 1 è¡Œ
  const { result, rows, tds } = render(
    [anchor(), child('试样质量 m', '50.2', 'g'), judgedChild('水分结果 w', '0.32', 0.5, 0), standalone()],
    'render',
  );
  expect('展开 4 è¡Œ', rows.length, 4);
  expectTrue('每行都是 7 æ ¼', tds.every((cells) => cells.length === 7),
    tds.map((cells) => cells.length).join(','));
  // é—®é¢˜æ¸…单里只允许出现「无判定规则」这类如实说明;出现「绑定…取不到值」就说明有绑定没喂上
  expectTrue('没有取不到值的绑定', result.errors.every((message) => !message.includes('绑定')),
    JSON.stringify(result.errors));
  expectTrue('无判定规则照实上报(不静默)',
    result.errors.filter((message) => message.includes('没有规格上下限也没有判定规则')).length === 3,
    JSON.stringify(result.errors));
  // ç»„头行:合并 3 è¡Œã€è‡ªå·±å¯è§ã€å…¬å¼åœ¨è‡ªå·±çš„「检测要求」格
  expectTrue('组头行 rowspan=3', tds[0]![1]!.includes('rowspan="3"'), tds[0]![1]);
  expectTrue('组头行不隐藏合并格', !tds[0]![1]!.includes('hidden='), tds[0]![1]);
  expectTrue('组头「检测要求」放公式', tds[0]![3]!.includes('w = (m1 - m0) / m Ã— 100%'), tds[0]![3]);
  expectTrue('组头「子项」格为空', tds[0]![2]!.startsWith(' style='), tds[0]![2]);
  expectTrue('组头序号为 1', tds[0]![0]!.includes('>1<'), tds[0]![0]);
  // å­é¡¹è¡Œï¼šè®©å‡ºåˆå¹¶æ ¼ï¼ˆhidden),合并行数回落 1
  tds.slice(1, 3).forEach((cells, offset) => {
    expectTrue(`子项第 ${offset + 1} è¡Œ rowspan=1`, cells[1]!.includes('rowspan="1"'), cells[1]);
    expectTrue(`子项第 ${offset + 1} è¡Œè®©å‡ºåˆå¹¶æ ¼`, cells[1]!.includes('hidden="hidden"'), cells[1]);
  });
  expectTrue('子项自己的名字进「子项」列', tds[1]![2]!.includes('试样质量 m'), tds[1]![2]);
  // å­é¡¹çš„æ£€æµ‹è¦æ±‚逐行给:组公式只占组头那一格,子项各有各的规格
  expectTrue('子项「检测要求」仍是自己的值', tds[2]![3]!.includes('≤0.5'), tds[2]![3]);
  // ç‹¬ç«‹é¡¹ï¼šå¯è§ã€ä¸åˆå¹¶ã€ã€Œå­é¡¹ã€åˆ—空
  expectTrue('独立项不隐藏', !tds[3]![1]!.includes('hidden='), tds[3]![1]);
  expectTrue('独立项「子项」格为空', !tds[3]![2]!.includes('样品') && tds[3]![2]!.includes('</td>'), tds[3]![2]);
  expectTrue('独立项自己的检测要求在场', tds[3]![3]!.includes('无划痕、无异物'), tds[3]![3]);
  // åˆ¤å®šåˆ—:「无判定规则」照样渲染出来,不被分组结构吃掉
  expectTrue('组头锚点行判为「无判定规则」', tds[0]![6]!.includes('无判定规则'), tds[0]![6]);
  expectTrue('过程参数判为「无判定规则」', tds[1]![6]!.includes('无判定规则'), tds[1]![6]);
  expectTrue('带规格的子项判为「合格」', tds[2]![6]!.includes('合格'), tds[2]![6]);
  // ç»“论不被锚点行与过程参数压下去(它们不计入分母)
  const report = result.context.report;
  expect('report.total', report.total, 4);
  expect('report.passCount', report.passCount, 1);
  expect('report.passRate(分母只算可判定项)', report.passRate, '100.00%');
  expect('report.conclusion', report.conclusion, '合格');
}
// eslint-disable-next-line no-console
console.log('\n===== 3. é™çº§ï¼šæ•´ä»½æ•°æ®éƒ½æ²¡æœ‰åˆ†ç»„信息,退化成普通表而不是塌掉 =====');
{
  // çœŸå®žåŽç«¯ä¸ä¼šäº§å‡ºè¿™ç§å½¢çŠ¶ï¼ˆæœ‰åˆ†ç»„æ—¶æ¯ä¸€é¡¹éƒ½å¸¦ group),但模板可能被别处喂数据;
  // å­—段缺席时绑定的属性会被跳过 â‡’ åˆå¹¶æ ¼ä¸Žè®©ä½æ ¼éƒ½ä¸å‡ºçŽ°ï¼Œè¡Œç…§å¸¸å æ»¡ 7 åˆ—、内容照常渲染
  const { result, rows, tds } = render(
    [
      { itemName: '外观', requirement: '无划痕、无异物', actualValue: '无划痕', unit: '' },
      {
        itemName: '长度',
        requirement: '10±0.5',
        actualValue: '10.2',
        unit: '',
        upperLimit: 10.5,
        lowerLimit: 9.5,
      },
    ],
    'degrade',
  );
  expect('展开 2 è¡Œ', rows.length, 2);
  expectTrue('每行仍是 7 æ ¼', tds.every((cells) => cells.length === 7),
    tds.map((cells) => cells.length).join(','));
  expectTrue('合并属性整个不出现', rows.every((html) => !html.includes('rowspan=') && !html.includes('hidden=')),
    rows.join('\n'));
  expectTrue('行内容照常渲染', tds[1]![1]!.includes('长度') && tds[1]![3]!.includes('10±0.5'), tds[1]!.join('|'));
  expectTrue('判定照常算出', tds[1]![6]!.includes('合格'), tds[1]![6]);
  expectTrue(
    '缺口以问题清单暴露(不静默)',
    result.errors.some((message) => message.includes('item.group.span')),
    JSON.stringify(result.errors),
  );
}
// ------------------------------------------------- 4. AI å¯¼å…¥èµ°çš„自定列
// ------------------------------------------------- 4. AI å¯¼å…¥èµ°çš„自定列
// eslint-disable-next-line no-console
console.log('\n===== 4. AI å¯¼å…¥èµ°è‡ªå®šåˆ—(columns éžç©ºï¼‰ï¼šé¡¹ç›®åˆ—仍要按组纵向合并 =====');
{
  // ç¬¬ 1~3 èŠ‚æ‰“çš„æ˜¯**默认列**分支(tableSchema æ‰‹å·¥åŒæž„),而 AI å¯¼å…¥ä¸€å¾‹å¡« columns,
  // èµ°çš„æ˜¯ effectiveColumns çš„另一条分支——两条分支各自拼 row,合并属性有没有跟着走,
  // é»˜è®¤åˆ—绿不代表自定列绿。这里用模型真实产出(模板 33 é‚£ä»½è‰ç¨¿çš„æœ€åŽä¸€è½®ï¼‰é€å­—复现。
  //
  // åªéªŒåˆ°ç»“构为止:装配产物交给 GrapesJS è§£æžå‰åŽçš„属性是一字不差的,而「span=N + hidden=''
  // ä¼šæ¸²æˆå¯è§çš„ rowspan=N、span=1 + hidden='hidden' ä¼šè®©ä½ã€è¿™å±‚语义由第 2 èŠ‚è´Ÿè´£â€”â€”
  // é‚£å¥—属性契约与它落在哪一列无关,不必在自定列上重跑一遍渲染。
  const AI_PROPS = {
    title: '检  éªŒ  ç»“  æžœ',
    itemsPath: 'inspectionItems',
    showRequirement: true,
    showActual: true,
    showUnit: false,
    showResult: true,
    columns:
      '#项目={{item.itemName}}|子项={{item.group.childName}}|检测方法={{item.checkMethod}}|' +
      '标准要求={{item.standardValue}}|结果={{item.actualValue}}|结论={{item.resultText}}',
    headerSpans: '检验结果^5|结论^1',
  };
  /** è£…配产物的 content åœ¨ node ä¸‹æ˜¯æœªè§£æžçš„ HTML ä¸²ï¼ˆGrapesJS æ‰æŠŠå®ƒæ‹†æˆèŠ‚ç‚¹æ ‘ï¼‰ï¼Œç›´æŽ¥æŒ‰ä¸²æ–­è¨€ */
  function assembleToHtml(props: Record<string, unknown>): {
    html: string;
    skipped: unknown[];
    problems: string[];
  } {
    const assembled = assembleQualityCanvas([{ type: 'GroupedQualityTable', props }]);
    const page = (assembled.grapes.pages as Array<{ component: { components: unknown[] } }>)[0]!;
    const node = page.component.components[0] as { components: string };
    return { html: node.components, skipped: assembled.skipped, problems: assembled.problems };
  }
  const { html, skipped, problems } = assembleToHtml(AI_PROPS);
  expectTrue('草稿装配无跳过', skipped.length === 0, JSON.stringify(skipped));
  expectTrue('草稿装配无问题项', problems.length === 0, JSON.stringify(problems));
  expectTrue('重复标记落在 tbody/tr ä¸Š', html.includes('<tbody data-qc-repeat="inspectionItems">') &&
    html.includes('<tr data-qc-repeat-row="item">'), html.slice(0, 200));
  // è¡Œæ¨¡æ¿æŒ‰ columns å‡º 6 æ ¼ï¼Œä¸”第一格就是「项目」列
  const cells = html.split('<td').slice(1);
  expect('行模板 6 æ ¼ï¼ˆç…§ columns æ¥ï¼‰', cells.length, 6);
  expectTrue('第 1 æ ¼ç»‘ item.itemName', cells[0]!.includes('{{item.itemName}}'), cells[0]);
  // ã€Œ#」那一条才是合并列:组名格自带 span ä¸Ž hidden ä¸¤ä¸ªç»‘定,组内其余行靠它让位。
  // è¿™æ­£æ˜¯ã€Œå‡ºä»¶æ—¶å±‚级合并」的全部机制——少了这行属性,项目列就会每行重复打一遍组名
  expectTrue(
    '「#项目」格带 rowspan ä¸Ž hidden ç»‘定',
    cells[0]!.includes('rowspan="{{item.group.span}}"') && cells[0]!.includes('hidden="{{item.group.hidden}}"'),
    cells[0],
  );
  expect(
    '合并属性只落在这一格',
    cells.filter((cell) => cell.includes('rowspan=')).length,
    1,
  );
  // è¡¨å¤´ï¼šä¸Šå±‚「检验结果」横跨 5 åˆ—,「结论」自己跨住上下两层 â‡’ ä¸‹å±‚不再重复印一遍
  expectTrue('上层「检验结果」colspan=5', html.includes('colspan="5"') && html.includes('>检验结果</th>'), html.slice(0, 400));
  expectTrue('「结论」纵向跨两层', /<th rowspan="2"[^>]*>结论<\/th>/.test(html), html.slice(0, 400));
  const lowerHeads = html.split('<tr>')[2]?.split('<th').length ?? 0;
  expect('下层表头 5 åˆ—(结论已被上层并走)', lowerHeads - 1, 5);
  // åŽ»æŽ‰ # å°±è¯¥ä¸åˆå¹¶ â€”— é¡¹ç›®åˆ—每行都重复打一遍组名,正是「有点丑」的那种版式。
  // æ–­è¨€å®ƒç¡®å®žå˜äº†ï¼Œé˜²æ­¢ # è¢«å½“成装饰、有无都不影响结果
  const plain = assembleToHtml({ ...AI_PROPS, columns: AI_PROPS.columns.replace('#项目=', '项目=') });
  expectTrue('去掉 # åŽä¸å†æœ‰ rowspan / hidden ç»‘定',
    !plain.html.includes('rowspan="{{') && !plain.html.includes('hidden="{{'), plain.html.slice(0, 400));
  expectTrue('去掉 # åŽåˆ—数与绑定都不变(只少了合并)',
    plain.html.split('<td').length === 7 && plain.html.includes('{{item.itemName}}'), plain.html.slice(0, 200));
}
// eslint-disable-next-line no-console
console.log(
  failures.length === 0
    ? '\n全部通过:组名合并与子项让位都由数据下发,列数稳定在 7,无判定规则不影响结论。'
    : `\n有 ${failures.length} å¤„不符:\n${failures.map((line) => `  - ${line}`).join('\n')}`,
);
process.exitCode = failures.length === 0 ? 0 : 1;
.qc-conformance/verify-undecidable.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
/**
 * æŽ¢é’ˆï¼šç¡®è®¤å‰ç«¯åˆ¤å®šå™¨æŠŠã€Œæ— åˆ¤å®šè§„则」和「待判定」分开,且前者不计入合格率与报告结论。
 *
 * èƒŒæ™¯ï¼šæŠ¥å‘Šé‡Œå¸¸æœ‰ã€Œè¯•样质量 m」「称量瓶 m0」这类只供公式取数的过程参数,它们没有规格、
 * ä¹Ÿæ²¡æœ‰è§„则,属于「无从判定」。若照常算进分母,一份本来全合格的报告会被这些行压成「待判定」。
 *
 * åªè¯»ï¼Œä¸æ”¹ä»£ç ã€ä¸ç¢°æ•°æ®åº“。用法:npx tsx .qc-conformance/verify-undecidable.ts
 */
import type { ReportContextItem } from '../src/components/quality/engine/context';
import { createContext } from '../src/components/quality/engine/context';
import { evaluateReport } from '../src/components/quality/engine/report-evaluator';
type Item = Partial<ReportContextItem> & { itemName: string };
const base = { reportNo: 'QC-2026-0001', reportName: '来料检验报告' };
function evaluate(items: Item[], rules: unknown[] = []) {
  const context = createContext({ report: base, inspectionItems: items as ReportContextItem[] });
  return evaluateReport(context, rules as never);
}
/** ä¸€æ¡å¸¦è§„格上下限的项,用来当「正常可判定」的对照组 */
const withLimits = (name: string, actual: string, upper: number, lower: number): Item => ({
  itemName: name,
  actualValue: actual,
  upperLimit: upper,
  lowerLimit: lower,
});
/** ä¸€æ¡åªä¾›å…¬å¼å–数的过程参数:既没有上下限、也没有自带结果 */
const processParam = (name: string, actual: string): Item => ({ itemName: name, actualValue: actual });
const failures: string[] = [];
function expect(label: string, actual: unknown, wanted: unknown) {
  const ok = actual === wanted;
  if (!ok) {
    failures.push(`${label}:期望 ${JSON.stringify(wanted)},实际 ${JSON.stringify(actual)}`);
  }
  // eslint-disable-next-line no-console
  console.log(`${ok ? '  ok  ' : ' FAIL '} ${label} = ${JSON.stringify(actual)}`);
}
// --------- åœºæ™¯ 1:过程参数 + å¯åˆ¤å®šé¡¹æ··æŽ’,报告仍然应当给出「合格」
{
  // eslint-disable-next-line no-console
  console.log('\n===== åœºæ™¯ 1:试样质量 m(过程参数)不该把全合格的报告压成待判定 =====');
  const outcome = evaluate([withLimits('长度', '10.2', 10.5, 9.5), processParam('试样质量 m', '50.2')]);
  const report = outcome.context.report;
  const items = outcome.context.inspectionItems;
  expect('长度.resultText', items[0].resultText, '合格');
  expect('试样质量 m.resultText', items[1].resultText, '无判定规则');
  expect('试样质量 m.result', items[1].result, '');
  expect('report.total(真实项数)', report.total, 2);
  expect('report.passCount', report.passCount, 1);
  expect('report.failCount', report.failCount, 0);
  expect('report.passRate(分母只算可判定项)', report.passRate, '100.00%');
  expect('report.result', report.result, 'PASS');
  expect('report.conclusion', report.conclusion, '合格');
  // eslint-disable-next-line no-console
  console.log(`  é—®é¢˜æ¸…单:${JSON.stringify(outcome.errors)}`);
}
// --------- åœºæ™¯ 2:一项都判不了时不硬给结论
{
  // eslint-disable-next-line no-console
  console.log('\n===== åœºæ™¯ 2:全部是无判定规则项,合格率留空、不给结论 =====');
  const outcome = evaluate([processParam('试样质量 m', '50.2'), processParam('称量瓶 m0', '28.7')]);
  const report = outcome.context.report;
  expect('report.total', report.total, 2);
  expect('report.passRate', report.passRate, '');
  expect('report.result', report.result, '');
  expect('report.conclusion', report.conclusion, '待判定');
}
// --------- åœºæ™¯ 3:待判定(规则跑挂)仍留在分母里,仍压住合格结论
{
  // eslint-disable-next-line no-console
  console.log('\n===== åœºæ™¯ 3:待判定(规则跑挂)仍计入分母、仍不给合格结论 =====');
  const outcome = evaluate(
    [withLimits('长度', '10.2', 10.5, 9.5), withLimits('宽度', '4.9', 5.0, 4.0), processParam('外观', '无划痕')],
    // ã€Œæ— åˆ’痕」不是数字,ABS åœ¨å®ƒèº«ä¸Šç®—不出来 â†’ è¯¥é¡¹å¾…判定
    [{ id: 'r1', name: '实测值可算', scope: 'item', expression: 'ABS(item.actualValue) >= 0' }],
  );
  const report = outcome.context.report;
  const items = outcome.context.inspectionItems;
  expect('长度.resultText', items[0].resultText, '合格');
  expect('外观.resultText', items[2].resultText, '待判定');
  expect('report.total', report.total, 3);
  expect('report.passCount', report.passCount, 2);
  expect('report.passRate(待判定项留在分母里)', report.passRate, '66.67%');
  expect('report.result(没验过不给合格)', report.result, '');
  expect('report.conclusion', report.conclusion, '待判定');
}
// eslint-disable-next-line no-console
console.log(
  failures.length === 0
    ? '\n全部通过:无判定规则不计入分母与结论,待判定仍计入分母并压住合格结论。'
    : `\n有 ${failures.length} å¤„不符:\n${failures.map((line) => `  - ${line}`).join('\n')}`,
);
process.exitCode = failures.length === 0 ? 0 : 1;
CHDL.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=CHDL"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/chdl/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
KYZN.bat
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,451 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
REM ============================================================
REM Vue Frontend Deploy Script
REM ============================================================
REM =========================
REM Configuration
REM =========================
set "COMPANY=KYZN"
set "REMOTE_USER=root"
set "REMOTE_HOST=36.213.150.237"
set "REMOTE_PORT=65022"
set "PASSWORD=Sh@1008600"
set "ZIP_FILE=dist.zip"
set "REMOTE_DIR=/data/vue/mom/kyzn/dist"
set "SSH_OPTS=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
set "RETRY_COUNT=3"
set "SSHPASS_DIR=%~dp0bin"
REM ============================================================
REM Start
REM ============================================================
echo.
echo ============================================================
echo              Vue Dist Auto Deploy
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_USER%@%REMOTE_HOST%
echo Port       : %REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo.
REM ============================================================
REM Check sshpass
REM ============================================================
echo [1/7] Checking sshpass...
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
)
where sshpass >nul 2>&1
if not errorlevel 1 (
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [WARN] sshpass not found.
if not exist "%SSHPASS_DIR%" (
    mkdir "%SSHPASS_DIR%" >nul 2>&1
)
if exist "%SSHPASS_DIR%\sshpass.exe" (
    set "PATH=%SSHPASS_DIR%;%PATH%"
    echo [OK] sshpass found.
    goto CHECK_PNPM
)
echo [INFO] Downloading sshpass...
powershell -NoProfile -ExecutionPolicy Bypass -Command "$url='https://github.com/xhcoding/sshpass-win32/releases/download/v1.0.3/sshpass.exe'; $out='%SSHPASS_DIR%\sshpass.exe'; Invoke-WebRequest -Uri $url -OutFile $out"
if errorlevel 1 (
    echo [ERROR] Failed to download sshpass.
    echo [ERROR] Please install sshpass manually.
    goto ERROR_EXIT
)
set "PATH=%SSHPASS_DIR%;%PATH%"
echo [OK] sshpass installed.
REM ============================================================
REM Check pnpm
REM ============================================================
:CHECK_PNPM
echo.
echo [2/7] Checking pnpm...
where pnpm >nul 2>&1
if errorlevel 1 (
    echo [ERROR] pnpm not found.
    echo [HINT] npm install -g pnpm
    goto ERROR_EXIT
)
call pnpm --version
if errorlevel 1 (
    echo [ERROR] pnpm execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node
REM ============================================================
echo.
echo [3/7] Checking Node.js...
where node >nul 2>&1
if errorlevel 1 (
    echo [ERROR] Node.js not found.
    goto ERROR_EXIT
)
call node --version
if errorlevel 1 (
    echo [ERROR] Node.js execution failed.
    goto ERROR_EXIT
)
REM ============================================================
REM Check node_modules
REM ============================================================
if not exist "node_modules" (
    echo.
    echo [INFO] node_modules not found.
    echo [INFO] Running pnpm install...
    call pnpm install
    if errorlevel 1 (
        echo [ERROR] pnpm install failed.
        goto ERROR_EXIT
    )
    echo [OK] pnpm install completed.
) else (
    echo [OK] node_modules exists.
)
REM ============================================================
REM Build
REM ============================================================
echo.
echo [4/7] Building project...
if exist "%ZIP_FILE%" (
    echo.
    echo [WARN] %ZIP_FILE% already exists.
    set "REBUILD="
    set /p "REBUILD=Rebuild? Y/N (default Y): "
    if /I "!REBUILD!"=="N" (
        echo [INFO] Skip build.
        goto CHECK_ZIP
    )
)
echo [INFO] Company: %COMPANY%
echo [INFO] Command: pnpm run build
echo.
set "VITE_COMPANY=%COMPANY%"
call pnpm run build
if errorlevel 1 (
    echo.
    echo [ERROR] Build failed.
    goto ERROR_EXIT
)
echo.
echo [OK] Build completed.
REM ============================================================
REM Check ZIP
REM ============================================================
:CHECK_ZIP
echo.
echo [5/7] Checking package...
if not exist "%ZIP_FILE%" (
    echo [ERROR] %ZIP_FILE% not found.
    echo [HINT] Please check whether your build generates dist.zip.
    goto ERROR_EXIT
)
for %%F in ("%ZIP_FILE%") do set "ZIP_BYTES=%%~zF"
set /a ZIP_MB=ZIP_BYTES/1024/1024
echo [OK] Package found.
echo [INFO] File size: !ZIP_BYTES! bytes
echo [INFO] File size: !ZIP_MB! MB
REM ============================================================
REM Set sshpass password
REM ============================================================
set "SSHPASS=%PASSWORD%"
REM ============================================================
REM Test SSH
REM ============================================================
echo.
echo [6/7] Testing SSH connection...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "echo SSH_OK"
if errorlevel 1 (
    echo [ERROR] SSH connection failed.
    goto ERROR_EXIT
)
echo [OK] SSH connection successful.
REM ============================================================
REM Create remote directory
REM ============================================================
echo.
echo [INFO] Creating remote directory...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mkdir -p '%REMOTE_DIR%'"
if errorlevel 1 (
    echo [ERROR] Failed to create remote directory.
    goto ERROR_EXIT
)
echo [OK] Remote directory ready.
REM ============================================================
REM Upload temporary file
REM ============================================================
echo.
echo [INFO] Uploading package...
set "REMOTE_TMP=%REMOTE_DIR%/.dist.zip.uploading"
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_TMP%'"
sshpass -e scp -P %REMOTE_PORT% %SSH_OPTS% "%ZIP_FILE%" %REMOTE_USER%@%REMOTE_HOST%:"%REMOTE_TMP%"
if errorlevel 1 (
    echo [ERROR] Upload failed.
    goto ERROR_EXIT
)
echo [OK] Upload completed.
REM ============================================================
REM Check remote file
REM ============================================================
echo.
echo [INFO] Checking remote package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "test -f '%REMOTE_TMP%'"
if errorlevel 1 (
    echo [ERROR] Remote package does not exist.
    goto ERROR_EXIT
)
echo [OK] Remote package exists.
REM ============================================================
REM Compare file size
REM ============================================================
echo.
echo [INFO] Checking package size...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "stat -c %%s '%REMOTE_TMP%'"
echo.
REM ============================================================
REM Rename temporary file
REM ============================================================
echo [INFO] Activating package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "mv -f '%REMOTE_TMP%' '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [ERROR] Failed to activate package.
    goto ERROR_EXIT
)
echo [OK] Package activated.
REM ============================================================
REM Check unzip
REM ============================================================
echo.
echo [INFO] Checking unzip...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "command -v unzip"
if errorlevel 1 (
    echo [ERROR] unzip is not installed on server.
    echo.
    echo CentOS/RHEL:
    echo yum install -y unzip
    echo.
    echo Ubuntu/Debian:
    echo apt install -y unzip
    goto ERROR_EXIT
)
echo [OK] unzip found.
REM ============================================================
REM Test ZIP
REM ============================================================
echo.
echo [INFO] Testing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -t '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] ZIP test failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo [OK] ZIP test passed.
REM ============================================================
REM Extract
REM ============================================================
echo.
echo [INFO] Extracting package...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "cd '%REMOTE_DIR%' && unzip -o '%ZIP_FILE%'"
if errorlevel 1 (
    echo.
    echo [ERROR] Remote unzip failed.
    echo [INFO] Remote ZIP has been kept.
    goto ERROR_EXIT
)
echo.
echo [OK] Extract completed.
REM ============================================================
REM Remove ZIP
REM ============================================================
echo.
echo [INFO] Removing remote ZIP...
sshpass -e ssh -p %REMOTE_PORT% %SSH_OPTS% %REMOTE_USER%@%REMOTE_HOST% "rm -f '%REMOTE_DIR%/%ZIP_FILE%'"
if errorlevel 1 (
    echo [WARN] Failed to remove remote ZIP.
) else (
    echo [OK] Remote ZIP removed.
)
REM ============================================================
REM Success
REM ============================================================
echo.
echo ============================================================
echo                 DEPLOY SUCCESS
echo ============================================================
echo.
echo Company    : %COMPANY%
echo Server     : %REMOTE_HOST%:%REMOTE_PORT%
echo Remote Dir : %REMOTE_DIR%
echo Package    : %ZIP_FILE%
echo Size       : !ZIP_MB! MB
echo.
echo Deployment completed successfully.
echo.
goto END
REM ============================================================
REM Error
REM ============================================================
:ERROR_EXIT
echo.
echo ============================================================
echo                  DEPLOY FAILED
echo ============================================================
echo.
echo Please check the error message above.
echo.
goto END
REM ============================================================
REM End
REM ============================================================
:END
pause
endlocal
package.json
@@ -61,6 +61,7 @@
    "dhtmlx-gantt": "catalog:",
    "diagram-js": "catalog:",
    "fast-xml-parser": "catalog:",
    "grapesjs": "catalog:",
    "highlight.js": "catalog:",
    "livekit-client": "^2.20.0",
    "pinia": "catalog:",
pnpm-lock.yaml
@@ -312,6 +312,9 @@
    globals:
      specifier: ^17.7.0
      version: 17.7.0
    grapesjs:
      specifier: ^0.23.6
      version: 0.23.6
    highlight.js:
      specifier: ^11.11.1
      version: 11.11.1
@@ -644,6 +647,9 @@
      fast-xml-parser:
        specifier: 'catalog:'
        version: 4.5.6
      grapesjs:
        specifier: 'catalog:'
        version: 0.23.6
      highlight.js:
        specifier: 'catalog:'
        version: 11.11.1
@@ -3150,6 +3156,9 @@
  '@jridgewell/sourcemap-codec@1.5.5':
    resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
  '@jridgewell/sourcemap-codec@1.6.0':
    resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
  '@jridgewell/trace-mapping@0.3.31':
    resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -4413,6 +4422,9 @@
  '@types/archiver@7.0.0':
    resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==}
  '@types/backbone@1.4.15':
    resolution: {integrity: sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==}
  '@types/codemirror@5.60.17':
    resolution: {integrity: sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==}
@@ -4451,6 +4463,9 @@
  '@types/html-minifier-terser@7.0.2':
    resolution: {integrity: sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA==}
  '@types/jquery@4.0.1':
    resolution: {integrity: sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==}
  '@types/jsesc@2.5.1':
    resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==}
@@ -4511,6 +4526,9 @@
  '@types/trusted-types@2.0.7':
    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
  '@types/underscore@1.13.0':
    resolution: {integrity: sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==}
  '@types/video.js@7.3.58':
    resolution: {integrity: sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==}
@@ -5093,6 +5111,13 @@
    peerDependencies:
      '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
  backbone-undo@0.2.6:
    resolution: {integrity: sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==}
    deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
  backbone@1.4.1:
    resolution: {integrity: sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==}
  balanced-match@1.0.0:
    resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==}
@@ -5379,6 +5404,12 @@
  cluster-key-slot@1.1.1:
    resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
    engines: {node: '>=0.10.0'}
  codemirror-formatting@1.0.0:
    resolution: {integrity: sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==}
  codemirror@5.63.0:
    resolution: {integrity: sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==}
  codemirror@5.65.21:
    resolution: {integrity: sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==}
@@ -6614,6 +6645,9 @@
  graceful-fs@4.2.11:
    resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
  grapesjs@0.23.6:
    resolution: {integrity: sha512-I8M/sPWjHm6MLIdUtRFNOKsQ3PtDgIDsc2S8ANkJsfHj3ZoH98oNMMWr64Kx22XgjHCQ4xpUx7nvXyQSKV1mrQ==}
  gzip-size@7.0.0:
    resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==}
    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -6689,6 +6723,9 @@
  htm@3.1.1:
    resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
  html-entities@1.4.0:
    resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==}
  html-minifier-terser@7.2.0:
    resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==}
@@ -8059,6 +8096,9 @@
    resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
    engines: {node: '>= 0.6.0'}
  promise-polyfill@8.3.0:
    resolution: {integrity: sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==}
  prosemirror-changeset@2.4.1:
    resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
@@ -8740,8 +8780,8 @@
    resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==}
    engines: {node: '>=20'}
  string.prototype.matchall@4.0.12:
    resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
  string.prototype.matchall@4.1.0:
    resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
    engines: {node: '>= 0.4'}
  string.prototype.trim@1.2.11:
@@ -9204,6 +9244,9 @@
  unctx@2.5.0:
    resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==}
  underscore@1.13.8:
    resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==}
  undici-types@7.24.6:
    resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
@@ -9880,8 +9923,8 @@
  zeebe-bpmn-moddle@1.15.0:
    resolution: {integrity: sha512-C40qyX4q79lECM6JyEVGjBqDhgPgGf19T3waOPUtlW+ZuWXl/tvuAsrBwHhJssWbAFJAGYdJ9urPF80EijUmLg==}
  zimmerframe@1.1.4:
    resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
  zimmerframe@1.1.5:
    resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
  zip-stream@6.0.1:
    resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
@@ -11478,6 +11521,8 @@
  '@jridgewell/sourcemap-codec@1.5.5': {}
  '@jridgewell/sourcemap-codec@1.6.0': {}
  '@jridgewell/trace-mapping@0.3.31':
    dependencies:
      '@jridgewell/resolve-uri': 3.1.2
@@ -12462,7 +12507,7 @@
      ejs: 3.1.10
      json5: 2.2.3
      magic-string: 0.30.21
      string.prototype.matchall: 4.0.12
      string.prototype.matchall: 4.1.0
  '@tybys/wasm-util@0.10.2':
    dependencies:
@@ -12472,6 +12517,11 @@
  '@types/archiver@7.0.0':
    dependencies:
      '@types/readdir-glob': 1.1.5
  '@types/backbone@1.4.15':
    dependencies:
      '@types/jquery': 4.0.1
      '@types/underscore': 1.13.0
  '@types/codemirror@5.60.17':
    dependencies:
@@ -12511,6 +12561,8 @@
  '@types/estree@1.0.9': {}
  '@types/html-minifier-terser@7.0.2': {}
  '@types/jquery@4.0.1': {}
  '@types/jsesc@2.5.1': {}
@@ -12564,6 +12616,8 @@
      '@types/estree': 1.0.9
  '@types/trusted-types@2.0.7': {}
  '@types/underscore@1.13.0': {}
  '@types/video.js@7.3.58': {}
@@ -13304,6 +13358,15 @@
    transitivePeerDependencies:
      - supports-color
  backbone-undo@0.2.6:
    dependencies:
      backbone: 1.4.1
      underscore: 1.13.8
  backbone@1.4.1:
    dependencies:
      underscore: 1.13.8
  balanced-match@1.0.0: {}
  balanced-match@4.0.4: {}
@@ -13632,6 +13695,10 @@
  clsx@2.1.1: {}
  cluster-key-slot@1.1.1: {}
  codemirror-formatting@1.0.0: {}
  codemirror@5.63.0: {}
  codemirror@5.65.21: {}
@@ -14608,7 +14675,7 @@
  esrap@2.2.12(@typescript-eslint/types@8.62.0):
    dependencies:
      '@jridgewell/sourcemap-codec': 1.5.0
      '@jridgewell/sourcemap-codec': 1.6.0
    optionalDependencies:
      '@typescript-eslint/types': 8.62.0
@@ -14987,6 +15054,17 @@
  graceful-fs@4.2.11: {}
  grapesjs@0.23.6:
    dependencies:
      '@types/backbone': 1.4.15
      backbone: 1.4.1
      backbone-undo: 0.2.6
      codemirror: 5.63.0
      codemirror-formatting: 1.0.0
      html-entities: 1.4.0
      promise-polyfill: 8.3.0
      underscore: 1.13.8
  gzip-size@7.0.0:
    dependencies:
      duplexer: 0.1.2
@@ -15054,6 +15132,8 @@
      lru-cache: 7.5.1
  htm@3.1.1: {}
  html-entities@1.4.0: {}
  html-minifier-terser@7.2.0:
    dependencies:
@@ -16442,6 +16522,8 @@
  process@0.11.10: {}
  promise-polyfill@8.3.0: {}
  prosemirror-changeset@2.4.1:
    dependencies:
      prosemirror-transform: 1.12.0
@@ -17220,7 +17302,7 @@
      get-east-asian-width: 1.6.0
      strip-ansi: 7.2.0
  string.prototype.matchall@4.0.12:
  string.prototype.matchall@4.1.0:
    dependencies:
      call-bind: 1.0.9
      call-bound: 1.0.4
@@ -17455,7 +17537,7 @@
  svelte@5.56.3(@typescript-eslint/types@8.62.0):
    dependencies:
      '@jridgewell/remapping': 2.3.5
      '@jridgewell/sourcemap-codec': 1.5.0
      '@jridgewell/sourcemap-codec': 1.6.0
      '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0)
      '@types/estree': 1.0.9
      '@types/trusted-types': 2.0.7
@@ -17469,7 +17551,7 @@
      is-reference: 3.0.3
      locate-character: 3.0.0
      magic-string: 0.30.21
      zimmerframe: 1.1.4
      zimmerframe: 1.1.5
    transitivePeerDependencies:
      - '@typescript-eslint/types'
@@ -17751,6 +17833,8 @@
      estree-walker: 3.0.3
      magic-string: 0.30.21
      unplugin: 2.3.11
  underscore@1.13.8: {}
  undici-types@7.24.6: {}
@@ -18516,7 +18600,7 @@
  zeebe-bpmn-moddle@1.15.0: {}
  zimmerframe@1.1.4: {}
  zimmerframe@1.1.5: {}
  zip-stream@6.0.1:
    dependencies:
pnpm-workspace.yaml
@@ -128,6 +128,7 @@
  find-up: ^8.0.0
  get-port: ^7.2.0
  globals: ^17.7.0
  grapesjs: ^0.23.6
  highlight.js: ^11.11.1
  html-minifier-terser: ^7.2.0
  jsbarcode: ^3.12.3
src/api/mes/qc/indicator/index.ts
@@ -10,6 +10,11 @@
    name?: string; // æ£€æµ‹é¡¹åç§°
    type?: number; // æ£€æµ‹é¡¹ç±»åž‹
    tool?: string; // æ£€æµ‹å·¥å…·
    itemType?: number; // æ¡ç›®ç±»åž‹ï¼š1=录入项,2=分组项
    parentId?: number; // çˆ¶æŒ‡æ ‡ç¼–号(0=顶级)
    parentName?: string; // çˆ¶æŒ‡æ ‡åç§°ï¼ˆå³æ‰€å±žåˆ†ç»„名,顶级时为空)
    formulaText?: string; // å…¬å¼å±•示文本(分组项的「检测要求」)
    sortOrder?: number; // åŒçº§æŽ’序号
    resultType?: number; // ç»“果值类型
    resultSpecification?: string; // ç»“果值属性
    remark?: string; // å¤‡æ³¨
@@ -32,6 +37,13 @@
  );
}
/** æŸ¥è¯¢åˆ†ç»„项精简列表(「所属分组」下拉选项) */
export function getIndicatorGroupList() {
  return requestClient.get<MesQcIndicatorApi.Indicator[]>(
    '/mes/qc/indicator/group-list',
  );
}
/** æ–°å¢žè´¨æ£€æŒ‡æ ‡ */
export function createIndicator(data: MesQcIndicatorApi.Indicator) {
  return requestClient.post('/mes/qc/indicator/create', data);
src/api/mes/qc/report/ai/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
import type { QualityComponentCatalog } from '#/components/quality';
import { requestClient } from '#/api/request';
export namespace QcReportAiApi {
  /** ä¸€ä¸ªç»„件草稿:结构完全扁平,没有 children */
  export interface DraftComponent {
    type?: string;
    /** ç»„件属性;后端已按积木清单过滤掉未声明的 key */
    props?: Record<string, unknown>;
  }
  /** AI çŒœå‡ºçš„纸张配置,可能为空(为空表示沿用当前模板的纸张) */
  export interface DraftPage {
    size?: string;
    orientation?: string;
    margin?: { top?: number; right?: number; bottom?: number; left?: number };
  }
  /**
   * è¯†åˆ«ç»“果:一份模板草稿,**尚未落库**。
   * <p>
   * éœ€äººå·¥åˆ°è®¾è®¡å™¨ç¡®è®¤å¹¶ç‚¹ã€Œä¿å­˜ã€ï¼Œæ‰ä¼šå†™æˆæ¨¡æ¿ç‰ˆæœ¬ã€‚
   */
  export interface DraftResp {
    components?: DraftComponent[];
    page?: DraftPage;
    /** AI å¯¹è¿™ä»½æ–‡æ¡£çš„一句话说明 */
    summary?: string;
    /** è½¯æç¤ºï¼šè¢«è·³è¿‡çš„页、无法表达的区块等,必须展示给用户 */
    warnings?: string[];
    durationMs?: number;
  }
  export interface DraftReq {
    /**
     * ä¸Šä¼ æ–‡ä»¶å¯¹åº”çš„ blobId,**顺序即文档页序**。
     * <p>
     * æ–‡ä»¶å…ˆç» /system/storage-blob/upload ä¸Šä¼ å–å¾— blobId;业务模块不自建上传通道。
     */
    blobIds: number[];
    /** ç›®æ ‡æ¨¡æ¿ç¼–号,用于日志留痕与附件归属 */
    templateId: number;
    /** å¿…须与设计器一致,两端不匹配时后端直接报错 */
    schemaVersion: string;
    /** ç§¯æœ¨æ¸…单,由 buildQualityComponentCatalog() ä»Žæ´»æ³¨å†Œè¡¨ç”Ÿæˆ */
    catalog: QualityComponentCatalog[];
    /** ç”¨æˆ·è¡¥å……说明,拼进提示词帮助模型理解文档 */
    hint?: string;
  }
}
/**
 * è¯†åˆ«æ–‡ä»¶ç”Ÿæˆæ¨¡æ¿è‰ç¨¿ã€‚
 * <p>
 * åŒæ­¥è¿”回且不落库,所以单独放宽超时。这个值必须比后端的耗时预算更宽:
 * åŽç«¯ `yudao.qcreport.ai-import.max-duration-seconds`(默认 180 ç§’,只在两次模型调用之间检查)
 * åŠ ä¸Šå•æ¬¡è°ƒç”¨è¶…æ—¶ `yudao.ai.timeout`(默认 60 ç§’),最坏 240 ç§’。
 * æŽå¾—比它早,用户看到的是「请求超时」这种没有指向的报错,
 * è€ŒåŽç«¯æœ¬æ¥èƒ½ç»™å‡ºã€Œå“ªä¸€æ­¥ã€ä¸ºä»€ä¹ˆã€æ€Žä¹ˆåŠžã€çš„æ˜Žç¡®æç¤ºã€‚
 */
export function generateAiDraft(data: QcReportAiApi.DraftReq) {
  return requestClient.post<QcReportAiApi.DraftResp>(
    '/qc-report/ai-import/draft',
    data,
    { timeout: 300_000 },
  );
}
src/api/mes/qc/report/instance/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace QcReportInstanceApi {
  /** æ™ºèƒ½è´¨æ£€æŠ¥å‘Šå®žä¾‹ */
  export interface Instance {
    id?: number; // å®žä¾‹ç¼–号
    reportNo?: string; // æŠ¥å‘Šç¼–号(业务唯一)
    templateId?: number; // æ¨¡æ¿ç¼–号
    templateVersion?: string; // ç”Ÿæˆæ—¶ä½¿ç”¨çš„æ¨¡æ¿ç‰ˆæœ¬å·
    businessId?: string; // ä¸šåŠ¡å•æ®ç¼–å·
    businessType?: string; // ä¸šåŠ¡å•æ®ç±»åž‹
    status?: number; // æŠ¥å‘ŠçŠ¶æ€ï¼š0生成中 1生成成功 2生成失败
    creator?: string; // åˆ›å»ºäºº
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    updateTime?: Date; // æ›´æ–°æ—¶é—´
  }
  /**
   * å®žä¾‹è¯¦æƒ…。
   * <p>
   * æ¯”列表多一个 {@link renderHtml}——那是出件时冻结下来的产物,用 iframe ç›´æŽ¥æ¸²æŸ“即为「预览」,
   * ä¸éœ€è¦é‡æ–°å‡ºä»¶ã€‚数据快照不在这个接口里,要看得单独调 {@link QcReportInstanceApi.Instance} çš„ snapshot。
   */
  export interface InstanceDetail extends Instance {
    renderHtml?: string; // æ¸²æŸ“产物 HTML
  }
  /** PDF å‡ºä»¶ç»“æžœ */
  export interface PdfResult {
    id: number; // å®žä¾‹ç¼–号
    reportNo: string; // æŠ¥å‘Šç¼–号
    fileName: string; // PDF æ–‡ä»¶å
    byteSize: number; // PDF å­—节数
    blobId?: number; // æ–‡ä»¶ç¼–号
    attachmentId?: number; // é™„件关联编号
    previewURL?: string; // é¢„览地址(临时签名,会过期)
    downloadURL?: string; // ä¸‹è½½åœ°å€ï¼ˆä¸´æ—¶ç­¾åï¼Œä¼šè¿‡æœŸï¼‰
    durationMs?: number; // æœ¬æ¬¡å‡ºä»¶æ€»è€—时(毫秒)
    pdfDurationMs?: number; // æ‰“印耗时(毫秒)
    browserStatus?: string; // æµè§ˆå™¨çŠ¶æ€ï¼šREADY / RESTARTED
  }
  /**
   * ç”±è´¨æ£€å•出件的入参。
   * <p>
   * è¿™é‡Œåˆ»æ„æ²¡æœ‰ context——数据全部由后端从 MES è´¨æ£€å•取,前端只负责选「哪张单据 + å“ªå¼ æ¨¡æ¿ã€ã€‚
   */
  export interface GenerateFromQcReq {
    qcType: number; // è´¨æ£€ç±»åž‹ï¼š1 æ¥æ–™ 2 è¿‡ç¨‹ 3 å‡ºè´§ 4 é€€è´§
    qcId: number; // è´¨æ£€å•编号(四张表各自的主键)
    templateId: number; // æŠ¥å‘Šæ¨¡æ¿ç¼–号,其 reportType å¿…须与 qcType ä¸€è‡´
    version?: string; // æ¨¡æ¿ç‰ˆæœ¬å·ï¼Œä¸ä¼ å–模板 currentVersion
    reportNo?: string; // æŠ¥å‘Šç¼–号,不传由后端按 QR+日期+流水 ç”Ÿæˆ
  }
  /** ç”±è´¨æ£€å•出件的结果 */
  export interface GenerateFromQcResult {
    id: number; // æŠ¥å‘Šå®žä¾‹ç¼–号,拿去跳详情/反查
    reportNo: string; // æœ€ç»ˆç”Ÿæ•ˆçš„æŠ¥å‘Šç¼–号
    templateVersion: string; // æœ¬æ¬¡ä½¿ç”¨çš„æ¨¡æ¿ç‰ˆæœ¬å·
    itemCount: number; // å†™å…¥æŠ¥å‘Šçš„æ£€éªŒé¡¹æ¡æ•°ï¼ˆä¸ç­‰äºŽè´¨æ£€å•行数)
    undecidableCount: number; // å¾…判定项数,大于 0 å¿…须提示用户
    warnings?: string[]; // æ•°æ®å–舍的软提示:分组标题被跳过 / æŒ‡æ ‡å·²åˆ  / æœªå½•实测值 / æ ·å“æ•°è¿‡å¤š
    errors?: string[]; // æ¸²æŸ“期数据缺口(模板绑定了但没数据的字段)
  }
}
/** æŸ¥è¯¢æŠ¥å‘Šå®žä¾‹åˆ†é¡µ */
export function getInstancePage(params: PageParam) {
  return requestClient.get<PageResult<QcReportInstanceApi.Instance>>(
    '/qc-report/instance/page',
    { params },
  );
}
/** æŸ¥è¯¢æŠ¥å‘Šå®žä¾‹è¯¦æƒ…(含渲染产物 HTML,不含数据快照) */
export function getInstance(id: number) {
  return requestClient.get<QcReportInstanceApi.InstanceDetail>(
    `/qc-report/instance/get?id=${id}`,
  );
}
/** æŸ¥è¯¢æŠ¥å‘Šå®žä¾‹å†»ç»“的数据快照 */
export function getInstanceSnapshot(id: number) {
  return requestClient.get<Record<string, unknown>>(
    `/qc-report/instance/snapshot?id=${id}`,
  );
}
/** å¯¼å‡º PDF(打印实例已存的渲染产物并归档到附件库,可重复调用) */
export function exportInstancePdf(id: number) {
  return requestClient.post<QcReportInstanceApi.PdfResult>(
    `/qc-report/instance/pdf?id=${id}`,
  );
}
/** åˆ é™¤æŠ¥å‘Šå®žä¾‹ */
export function deleteInstance(id: number) {
  return requestClient.delete(`/qc-report/instance/delete?id=${id}`);
}
/**
 * ç”±è´¨æ£€å•(IQC/IPQC/OQC/RQC)直接生成报告。
 * <p>
 * æ•°æ®ç”±åŽç«¯ç» MesQcReportApi ä»Ž MES å–,前端不拼 context。
 * è°ƒç”¨å‰äº”道校验按业务优先级只报第一处;失败都是「数据或选择不对」,重试无用,按报文改数据或换模板。
 */
export function generateInstanceFromQc(
  data: QcReportInstanceApi.GenerateFromQcReq,
) {
  return requestClient.post<QcReportInstanceApi.GenerateFromQcResult>(
    '/qc-report/instance/generate-from-qc',
    data,
  );
}
src/api/mes/qc/report/template/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace QcReportTemplateApi {
  /** æ™ºèƒ½è´¨æ£€æŠ¥å‘Šæ¨¡æ¿ */
  export interface Template {
    id?: number; // æ¨¡æ¿ç¼–号
    templateCode?: string; // æ¨¡æ¿ç¼–码
    templateName?: string; // æ¨¡æ¿åç§°
    industry?: string; // é€‚用行业
    reportType?: string; // æŠ¥å‘Šç±»åž‹ï¼ˆå¤ç”¨ mes_qc_type)
    pageSize?: string; // çº¸å¼ å°ºå¯¸
    orientation?: string; // çº¸å¼ æ–¹å‘
    status?: number; // æ¨¡æ¿çŠ¶æ€
    currentVersion?: string; // å½“前生效版本号
    description?: string; // æ¨¡æ¿è¯´æ˜Ž
    creator?: string; // åˆ›å»ºäºº
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    updateTime?: Date; // æ›´æ–°æ—¶é—´
  }
}
/** æŸ¥è¯¢æŠ¥å‘Šæ¨¡æ¿åˆ†é¡µ */
export function getTemplatePage(params: PageParam) {
  return requestClient.get<PageResult<QcReportTemplateApi.Template>>(
    '/qc-report/template/page',
    { params },
  );
}
/**
 * æŸ¥è¯¢å¯ç”¨äºŽå‡ºä»¶çš„æ¨¡æ¿åˆ—表。
 * <p>
 * åŽç«¯å·²æŒ‰ã€ŒæŠ¥å‘Šç±»åž‹ä¸€è‡´ + æ¨¡æ¿å¯ç”¨ + å½“前版本已发布」过滤完毕,
 * è°ƒç”¨æ–¹ä¸è¦å†è‡ªå·±æŽ¨å¯¼è¿™å¥—规则:currentVersion éžç©ºå¹¶ä¸ä»£è¡¨é‚£ä¸ªç‰ˆæœ¬è¿˜èƒ½ç”¨
 * â€”—停用一个版本不会清空模板的 currentVersion。
 */
export function getSelectableTemplates(reportType: string) {
  return requestClient.get<QcReportTemplateApi.Template[]>(
    '/qc-report/template/selectable',
    { params: { reportType } },
  );
}
/** æŸ¥è¯¢æŠ¥å‘Šæ¨¡æ¿è¯¦æƒ… */
export function getTemplate(id: number) {
  return requestClient.get<QcReportTemplateApi.Template>(
    `/qc-report/template/get?id=${id}`,
  );
}
/** æ–°å¢žæŠ¥å‘Šæ¨¡æ¿ */
export function createTemplate(data: QcReportTemplateApi.Template) {
  return requestClient.post<number>('/qc-report/template/create', data);
}
/** ä¿®æ”¹æŠ¥å‘Šæ¨¡æ¿ */
export function updateTemplate(data: QcReportTemplateApi.Template) {
  return requestClient.put('/qc-report/template/update', data);
}
/** åˆ é™¤æŠ¥å‘Šæ¨¡æ¿ */
export function deleteTemplate(id: number) {
  return requestClient.delete(`/qc-report/template/delete?id=${id}`);
}
/** å¤åˆ¶æŠ¥å‘Šæ¨¡æ¿ï¼Œè¿”回新模板编号 */
export function copyTemplate(id: number) {
  return requestClient.post<number>(`/qc-report/template/copy?id=${id}`);
}
src/api/mes/qc/report/version/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace QcReportVersionApi {
  /** çº¸å¼ é¡µè¾¹è·ï¼Œå•位 mm */
  export interface SchemaMargin {
    top?: number;
    right?: number;
    bottom?: number;
    left?: number;
  }
  /** çº¸å¼ é…ç½® */
  export interface SchemaPage {
    size?: string; // A3 / A4 / A5 / Letter
    orientation?: string; // portrait / landscape
    margin?: SchemaMargin;
  }
  /**
   * ç”»å¸ƒä¸Šçš„一个质量组件节点,只记「是谁、在哪、什么属性」
   * åµŒå¥—结构以 grapes ä¸ºå‡†ï¼Œè¿™é‡Œä¸å†å­˜ä¸€ä»½æ ‘,避免两个真相来源对不上
   */
  export interface QualityComponentNode {
    /** ç”»å¸ƒå†…组件 id */
    id?: string;
    /** ç»„件在文档中的顺序,供绑定与定位使用 */
    index?: number;
    /** ç»„件类型,即画布节点上的 data-quality-type */
    qualityType?: string;
    /** ç»„件的业务属性,取值是 data-qc-* / data-quality-type è¿™ç±»å±žæ€§ */
    attributes?: Record<string, unknown>;
  }
  /** åˆ¤å®šè§„则:scope ä¸º item æ—¶é€é¡¹åˆ¤å®šï¼Œä¸º report æ—¶å†³å®šæ•´ä»½æŠ¥å‘Šçš„结论 */
  export interface SchemaRule {
    id?: string;
    name?: string;
    scope?: 'item' | 'report';
    expression?: string;
    enabled?: boolean;
  }
  /**
   * æ¨¡æ¿ Schema â€”— è®¾è®¡å™¨äº§ç‰©ï¼Œä¹Ÿæ˜¯æŠ¥å‘Šå‡ºä»¶çš„唯一输入
   *
   * å­—段分工:grapes æ˜¯ GrapesJS åŽŸå§‹é¡¹ç›®æ•°æ®ï¼ˆè®¾è®¡å™¨è¿˜åŽŸç”»å¸ƒç”¨ï¼Œæ¸²æŸ“å¼•æ“Žä¹Ÿè¯»å®ƒï¼Œ
   * æ­£æ–‡ç»“构在这里);components æ˜¯è´¨é‡ç»„件业务语义清单(渲染不读);rules æ˜¯åˆ¤å®šè§„则。
   *
   * å¥‘约版本 1.1:删除了 1.0 æ—¶æœŸçš„ dataSources / bindings / styles â€”— å®ƒä»¬å§‹ç»ˆæ˜¯ç©ºæ•°ç»„,
   * æ—¢æ— äººå†™å…¥ä¹Ÿæ— äººè¯»å–。后端 ReportTemplateSchema å·²åŒæ­¥åˆ é™¤ï¼Œä¸¤ç«¯å¿…须一起改。
   */
  export interface ReportTemplateSchema {
    schemaVersion?: string;
    page?: SchemaPage;
    grapes?: Record<string, unknown>;
    components?: QualityComponentNode[];
    rules?: SchemaRule[];
  }
  /** æ¨¡æ¿ç‰ˆæœ¬ */
  export interface Version {
    id?: number;
    templateId?: number;
    version?: string;
    schema?: ReportTemplateSchema;
    status?: number; // 0草稿 1已发布 2已停用
    description?: string;
    creator?: string;
    createTime?: Date;
    updateTime?: Date;
  }
  /** åˆ›å»ºç‰ˆæœ¬å…¥å‚:version ç•™ç©ºç”±åŽç«¯æŒ‰æœ€æ–°ç‰ˆæœ¬è‡ªå¢ž */
  export interface VersionCreateReq {
    templateId: number;
    version?: string;
    schema?: ReportTemplateSchema;
    description?: string;
  }
  /** æ›´æ–°ç‰ˆæœ¬å…¥å‚:仅允许改设计内容与说明 */
  export interface VersionUpdateReq {
    id: number;
    schema?: ReportTemplateSchema;
    description?: string;
  }
}
/** æŸ¥è¯¢æ¨¡æ¿ç‰ˆæœ¬åˆ†é¡µ */
export function getVersionPage(params: PageParam) {
  return requestClient.get<PageResult<QcReportVersionApi.Version>>(
    '/qc-report/template-version/page',
    { params },
  );
}
/** æŸ¥è¯¢æ¨¡æ¿ç‰ˆæœ¬è¯¦æƒ… */
export function getVersion(id: number) {
  return requestClient.get<QcReportVersionApi.Version>(
    `/qc-report/template-version/get?id=${id}`,
  );
}
/** æŸ¥è¯¢æ¨¡æ¿çš„æ‰€æœ‰ç‰ˆæœ¬ */
export function getVersionListByTemplate(templateId: number) {
  return requestClient.get<QcReportVersionApi.Version[]>(
    `/qc-report/template-version/list-by-template?templateId=${templateId}`,
  );
}
/** åˆ›å»ºæ¨¡æ¿ç‰ˆæœ¬ï¼Œè¿”回版本编号 */
export function createVersion(data: QcReportVersionApi.VersionCreateReq) {
  return requestClient.post<number>('/qc-report/template-version/create', data);
}
/** æ›´æ–°æ¨¡æ¿ç‰ˆæœ¬ */
export function updateVersion(data: QcReportVersionApi.VersionUpdateReq) {
  return requestClient.put('/qc-report/template-version/update', data);
}
/** åˆ é™¤æ¨¡æ¿ç‰ˆæœ¬ï¼ˆä»…草稿) */
export function deleteVersion(id: number) {
  return requestClient.delete(`/qc-report/template-version/delete?id=${id}`);
}
/** å‘布模板版本 */
export function publishVersion(id: number) {
  return requestClient.post(`/qc-report/template-version/publish?id=${id}`);
}
/** å›žæ»šåˆ°æŒ‡å®šåŽ†å²ç‰ˆæœ¬ */
export function rollbackVersion(id: number) {
  return requestClient.post(`/qc-report/template-version/rollback?id=${id}`);
}
/** åœç”¨æ¨¡æ¿ç‰ˆæœ¬ */
export function disableVersion(id: number) {
  return requestClient.post(`/qc-report/template-version/disable?id=${id}`);
}
src/components/quality/base/divider.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
import type { QualityComponentDefinition } from '../core/types';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
export const dividerDefinition: QualityComponentDefinition = {
  type: 'Divider',
  name: 'divider',
  label: '分隔线',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.divider,
  aiHint: '内容之间的横向分隔线。不要用它代替表格边框。',
  defaults: { color: '#d9d9d9', thickness: 1, spacing: 8 },
  propertySchema: [
    { key: 'color', label: '线条颜色', type: 'string', defaultValue: '#d9d9d9' },
    { key: 'thickness', label: '线条粗细(px)', type: 'number', defaultValue: 1 },
    { key: 'spacing', label: '上下间距(px)', type: 'number', defaultValue: 8 },
  ],
  dataSchema: [],
  buildContent: (properties) => ({
    tagName: 'hr',
    style: {
      margin: `${Number(properties.spacing ?? 8)}px 0`,
      border: 'none',
      'border-top': `${Number(properties.thickness ?? 1)}px solid ${String(properties.color ?? '#d9d9d9')}`,
    },
  }),
};
src/components/quality/base/heading.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
import type { QualityComponentDefinition } from '../core/types';
import { escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/** çº§åˆ« â†’ å­—号,各级别只给一个克制的视觉层次 */
const LEVEL_FONT_SIZE: Record<number, string> = {
  1: '24px',
  2: '20px',
  3: '16px',
  4: '14px',
};
export const headingDefinition: QualityComponentDefinition = {
  type: 'Heading',
  name: 'heading',
  label: '标题',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.heading,
  aiHint: '正文里的分节小标题(如「检验结果」「质量评定」)。文档最顶部的报告标题不要用我,用 ReportHeader。',
  contentKey: 'text',
  defaults: { text: '报告标题', level: 2, align: 'center' },
  propertySchema: [
    {
      key: 'text',
      label: '标题文字',
      type: 'string',
      defaultValue: '报告标题',
      required: true,
      bindable: true,
    },
    {
      key: 'level',
      label: '标题级别',
      type: 'enum',
      defaultValue: 2,
      options: [
        { label: '一级(最大)', value: 1 },
        { label: '二级', value: 2 },
        { label: '三级', value: 3 },
        { label: '四级(最小)', value: 4 },
      ],
    },
    {
      key: 'align',
      label: '对齐方式',
      type: 'enum',
      defaultValue: 'center',
      options: [
        { label: '左对齐', value: 'left' },
        { label: '居中', value: 'center' },
        { label: '右对齐', value: 'right' },
      ],
    },
  ],
  dataSchema: [
    { key: 'text', label: '标题文字', type: 'string', bindable: true },
  ],
  buildContent: (properties) => {
    const level = Number(properties.level ?? 2);
    return {
      tagName: `h${level}`,
      content: escapeHtml(properties.text),
      style: {
        margin: '0 0 8px',
        'font-size': LEVEL_FONT_SIZE[level] ?? LEVEL_FONT_SIZE[2]!,
        'font-weight': '600',
        'text-align': String(properties.align ?? 'center'),
      },
    };
  },
};
src/components/quality/base/image.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { QualityComponentDefinition } from '../core/types';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/** æœªè®¾ç½®å›¾ç‰‡æ—¶çš„占位图,避免画布上出现破图 */
const PLACEHOLDER_SRC =
  'data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="90"%3E%3Crect width="160" height="90" fill="%23f0f0f0"/%3E%3Ctext x="80" y="50" font-size="12" fill="%23999" text-anchor="middle"%3E图片%3C/text%3E%3C/svg%3E';
export const imageDefinition: QualityComponentDefinition = {
  type: 'Image',
  name: 'image',
  label: '图片',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.image,
  aiHint: '报告里的插图、印章、签名图片。识别阶段只放占位图,不写真实图片地址。',
  defaults: { src: PLACEHOLDER_SRC, alt: '图片', width: 60 },
  propertySchema: [
    {
      key: 'src',
      label: '图片地址',
      type: 'string',
      defaultValue: PLACEHOLDER_SRC,
      bindable: true,
      tip: '可直接填图片地址,或绑定数据字段',
    },
    { key: 'alt', label: '替代文字', type: 'string', defaultValue: '图片' },
    {
      key: 'width',
      label: '宽度(%)',
      type: 'number',
      defaultValue: 60,
    },
  ],
  dataSchema: [{ key: 'src', label: '图片地址', type: 'string', bindable: true }],
  buildContent: (properties) => ({
    tagName: 'img',
    attributes: {
      src: String(properties.src ?? PLACEHOLDER_SRC),
      alt: String(properties.alt ?? ''),
    },
    style: {
      display: 'block',
      'max-width': '100%',
      width: `${Number(properties.width ?? 60)}%`,
    },
  }),
};
src/components/quality/base/spacer.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
import type { QualityComponentDefinition } from '../core/types';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
export const spacerDefinition: QualityComponentDefinition = {
  type: 'Spacer',
  name: 'spacer',
  label: '间距',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.spacer,
  aiHint: '纯空白间距,只用来撑开版面,不承载任何内容。',
  defaults: { height: 16 },
  propertySchema: [{ key: 'height', label: '高度(px)', type: 'number', defaultValue: 16 }],
  dataSchema: [],
  buildContent: (properties) => ({
    tagName: 'div',
    style: { height: `${Number(properties.height ?? 16)}px` },
  }),
};
src/components/quality/base/table.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
import type { QualityComponentDefinition } from '../core/types';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/** å•元格与表头共用的边框样式 */
const CELL_BORDER = '1px solid #d9d9d9';
function buildCells(headers: string[], rows: number): string {
  const head = headers
    .map(
      (text) =>
        `<th style="${cssStyle({
          border: CELL_BORDER,
          padding: '6px 8px',
          background: '#fafafa',
          'font-weight': 600,
        })}">${escapeHtml(text)}</th>`,
    )
    .join('');
  const body = Array.from({ length: Math.max(rows, 1) })
    .map(() =>
      headers
        .map(() => `<td style="${cssStyle({ border: CELL_BORDER, padding: '6px 8px' })}">&nbsp;</td>`)
        .join(''),
    )
    .map((cells) => `<tr>${cells}</tr>`)
    .join('');
  return `<table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
/** è§£æžé€—号分隔的表头文字 */
function parseHeaders(raw: unknown): string[] {
  const headers = String(raw ?? '')
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean);
  return headers.length > 0 ? headers : ['项目', '标准值', '实测值', '判定'];
}
export const tableDefinition: QualityComponentDefinition = {
  type: 'Table',
  name: 'table',
  label: '基础表格',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.table,
  aiHint:
    '通用二维表格,如样品基础信息的两列键值表、设备清单。多行检验数据不要用我,用 QualityTable。',
  defaults: { headers: '项目,标准值,实测值,判定', rows: 2 },
  propertySchema: [
    {
      key: 'headers',
      label: '表头(英文逗号分隔)',
      type: 'string',
      defaultValue: '项目,标准值,实测值,判定',
    },
    {
      key: 'rows',
      label: '数据行数',
      type: 'number',
      defaultValue: 2,
      tip: '仅用于画布排版参考,渲染时可用检验项目数替换',
    },
  ],
  dataSchema: [],
  buildContent: (properties) => ({
    tagName: 'div',
    components: buildCells(parseHeaders(properties.headers), Number(properties.rows ?? 2)),
    style: { margin: '0 0 8px' },
  }),
};
src/components/quality/base/text.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import type { QualityComponentDefinition } from '../core/types';
import { escapeHtmlMultiline } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
export const textDefinition: QualityComponentDefinition = {
  type: 'Text',
  name: 'text',
  label: '文本',
  category: QUALITY_CATEGORY.BASIC,
  icon: QUALITY_ICONS.text,
  aiHint:
    '兜底的纯文字段落。只有在没有任何专门组件合适时才用我——页眉、页脚、样品信息、判定结论都各有专门组件,不要图省事都用我。',
  contentKey: 'text',
  defaults: { text: '在此输入文本内容', align: 'left' },
  propertySchema: [
    {
      key: 'text',
      label: '文本内容',
      type: 'text',
      defaultValue: '在此输入文本内容',
      bindable: true,
    },
    {
      key: 'align',
      label: '对齐方式',
      type: 'enum',
      defaultValue: 'left',
      options: [
        { label: '左对齐', value: 'left' },
        { label: '居中', value: 'center' },
        { label: '右对齐', value: 'right' },
      ],
    },
  ],
  dataSchema: [{ key: 'text', label: '文本内容', type: 'string', bindable: true }],
  buildContent: (properties) => ({
    tagName: 'p',
    content: escapeHtmlMultiline(properties.text),
    style: {
      margin: '0 0 8px',
      'font-size': '12px',
      'line-height': '1.6',
      'text-align': String(properties.align ?? 'left'),
    },
  }),
};
src/components/quality/core/assembler.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
/**
 * AI ç»„件草稿 â†’ ç”»å¸ƒæ•°æ®ï¼ˆè¯­ä¹‰å±‚的逆运算)
 * <p>
 * ä¸Ž use-designer çš„ collectSchema æž„成往返:collectSchema ä»Žç”»å¸ƒæ”¶é›† {type, props},
 * æœ¬æ¨¡å—把 {type, props} è£…回画布。两者必须逐项对齐,否则「AI ç”Ÿæˆ â†’ ä¿å­˜ â†’ å†æ‰“开」
 * ä¼šæŽ‰ç»„件或错位属性。
 * <p>
 * åˆ»æ„æ”¾åœ¨ core/ è€Œä¸æ˜¯è®¾è®¡å™¨é‡Œï¼šå®ƒä¸éœ€è¦ editor å®žä¾‹ï¼Œæ˜¯çº¯å‡½æ•°ï¼Œå¯å•测,
 * ä¹Ÿé¿å… components/ åå‘依赖 #/api。
 */
import type { QualityComponentDefinition, QualityProps } from './types';
import { buildQualityContent } from './factory';
import { getQualityComponent } from './registry';
import { validateQualityProps } from './validator';
/**
 * AI è‰ç¨¿é‡Œçš„一个组件。
 * <p>
 * ç»“构完全扁平:没有 children。props çš„ key ç”±å‰ç«¯ç§¯æœ¨æ¸…单定义,
 * æ‰€ä»¥è¿™é‡Œæ˜¯å®½æ¾çš„ Record è€Œä¸æ˜¯å¼ºç±»åž‹â€”—合法 key çš„æƒå¨åœ¨æ³¨å†Œè¡¨ã€‚
 */
export interface QualityDraftComponent {
  type?: string;
  props?: Record<string, unknown>;
}
/** è¢«è·³è¿‡çš„草稿组件 */
export interface QualityAssemblySkip {
  type: string;
  /** å±•示名;未注册的 type æ²¡æœ‰å±•示名,回退为 type æœ¬èº« */
  label: string;
  reason: string;
}
export interface QualityAssemblyResult {
  /** å¯ç›´æŽ¥äº¤ç»™ loadProjectData çš„ GrapesJS é¡¹ç›®æ•°æ® */
  grapes: Record<string, unknown>;
  /** è¯†åˆ«åˆ°äº†ä½†è£…不进画布的组件 */
  skipped: QualityAssemblySkip[];
  /** è£…进去了、但属性不完整或不合法的组件,提示用 */
  problems: string[];
}
/**
 * æŠŠè‰ç¨¿ç»„件装配成画布数据。
 * <p>
 * å•个组件坏掉不影响其余:未知类型进 skipped,属性有问题的进 problems,
 * éƒ½ä¸ä¼šè®©æ•´æ‰¹å¤±è´¥â€”—AI çš„产物本来就不可信,全有或全无只会让用户白等一次识别。
 */
export function assembleQualityCanvas(
  components?: QualityDraftComponent[],
): QualityAssemblyResult {
  const skipped: QualityAssemblySkip[] = [];
  const problems: string[] = [];
  const nodes: Record<string, unknown>[] = [];
  (components ?? []).forEach((draft, index) => {
    const position = `第 ${index + 1} ä¸ªç»„ä»¶`;
    const type = draft?.type ?? '';
    const definition = getQualityComponent(type);
    if (!definition) {
      skipped.push({
        type,
        label: type || '未命名组件',
        reason: `${position}:类型「${type || '空'}」不在当前设计器的组件清单内,已跳过`,
      });
      return;
    }
    const normalized = normalizeDraftProps(definition, draft?.props);
    normalized.problems.forEach((message) => problems.push(`${position}:${message}`));
    // buildQualityContent åšçš„æ­£æ˜¯å¾€è¿”所需的四件事:defaults æ‰“底、buildContent ç”Ÿæˆç»“构、
    // propsToAttributes åªè¾“出声明过的 key å¹¶å­—符串化、写入 data-quality-type。
    const content = buildQualityContent(definition, normalized.props) as Record<string, unknown>;
    // traits ä¸è¿›ç”»å¸ƒæ•°æ®ï¼šè½½å…¥åŽç”± restoreQualityTraits æŒ‰ data-quality-type é‡å»ºï¼Œ
    // ä¸Žã€Œæ‰“开一份已保存的版本」走的是同一条路(traits æœ¬æ¥ä¹Ÿä¸å‚与项目数据序列化)。
    delete content.traits;
    nodes.push(content);
    validateQualityProps(definition, normalized.props).forEach((message) =>
      problems.push(`${position}:${message}`),
    );
  });
  return {
    // pages å¿…须非空,否则 loadSchema ä¼šèµ°ç©ºç™½é¡µå…œåº•分支,装配好的组件全部丢掉
    grapes: {
      pages: [{ component: { style: { padding: '0' }, components: nodes } }],
    },
    skipped,
    problems,
  };
}
/**
 * æŠŠ AI ç»™çš„属性收敛成语义层能承载的原始值。
 * <p>
 * åªç®¡ä¸€ä»¶äº‹ï¼šå€¼å¿…须是 boolean / number / string。模型偶尔会把字段写成嵌套对象或数组
 * ï¼ˆæ¯”如把检验项数据行整段塞进 itemsPath),这类值经 coerceFieldValue çš„ String() ä¼šå˜æˆ
 * "[object Object]" å†™è¿›ç”»å¸ƒï¼Œå±žæ€§é¢æ¿é‡Œæ˜¾ç¤ºä¸€ä¸²ä¹±ç ï¼Œè¿˜ä¼šé™é»˜ç›–掉默认值——所以在这里拦掉并说明。
 * <p>
 * ä¸æ£€æŸ¥ key æ˜¯å¦å£°æ˜Žè¿‡ï¼šæœªå£°æ˜Žçš„ key ç”± propsToAttributes ä¸¢å¼ƒï¼Œæœ¬æ¥ä¹Ÿè¿›ä¸äº†ç”»å¸ƒã€‚
 */
function normalizeDraftProps(
  definition: QualityComponentDefinition,
  props?: Record<string, unknown>,
): { props: QualityProps; problems: string[] } {
  const normalized: QualityProps = {};
  const problems: string[] = [];
  if (!props) {
    return { props: normalized, problems };
  }
  definition.propertySchema.forEach((field) => {
    const value = props[field.key];
    // null ä¸Ž undefined åŒä¹‰ï¼šæ¨¡åž‹æ²¡ç»™å€¼ï¼Œäº¤ç»™ defaults å…œåº•
    if (value === undefined || value === null) {
      return;
    }
    if (
      typeof value === 'boolean' ||
      typeof value === 'number' ||
      typeof value === 'string'
    ) {
      normalized[field.key] = value;
      return;
    }
    problems.push(
      `${definition.label}的「${field.label}」收到的是结构化数据,语义层只承载单个值,已按默认值处理`,
    );
  });
  return { props: normalized, problems };
}
src/components/quality/core/attrs.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
/**
 * è´¨é‡ç»„件在画布上的标记约定。
 * <p>
 * ç”»å¸ƒèŠ‚ç‚¹é å±žæ€§è‡ªæè¿°ï¼šdata-quality-type å£°æ˜Žå®ƒæ˜¯å“ªä¸ªç»„件,
 * data-qc-<key> æ‰¿è½½ä¸šåŠ¡å±žæ€§å€¼ï¼Œä¸¤è€…ä¸€èµ·è®©ç”»å¸ƒï¼ˆgrapes åŽŸå§‹æ•°æ®ï¼‰æœ¬èº«å°±èƒ½è¿˜åŽŸä¸šåŠ¡è¯­ä¹‰ã€‚
 */
/** æ ‡è®°ç»„件类型的属性名 */
export const QUALITY_TYPE_ATTR = 'data-quality-type';
/** ä¸šåŠ¡å±žæ€§å†™å…¥ç”»å¸ƒæ—¶çš„å±žæ€§å‰ç¼€ */
export const QUALITY_PROP_PREFIX = 'data-qc-';
/** é‡å¤æ¸²æŸ“容器:值为数组在上下文中的路径,渲染期按数组长度复制子行 */
export const QUALITY_REPEAT_ATTR = `${QUALITY_PROP_PREFIX}repeat`;
/** é‡å¤æ¸²æŸ“的行模板:值为循环变量名,行内 {{变量.字段}} ç”±è¯¥å˜é‡çš„元素解析 */
export const QUALITY_REPEAT_ROW_ATTR = `${QUALITY_PROP_PREFIX}repeat-row`;
/**
 * æ¸²æŸ“产物上承载组件标识的属性。
 * <p>
 * GrapesJS æŠŠæ ·å¼æŒ‚在 #<组件id> ä¸Šï¼Œè€Œ id åœ¨é‡å¤è¡Œé‡Œå¿…然撞车;
 * æ¸²æŸ“时统一改写成这个属性,并把样式选择器一起改写,重复行才能各自带上样式。
 */
export const QUALITY_ID_ATTR = `${QUALITY_PROP_PREFIX}id`;
/** ä¸Žä¸šåŠ¡å±žæ€§å…±ç”¨å‰ç¼€ä½†ä¸å‚ä¸Žå±žæ€§è¯»å†™çš„ä¿ç•™å */
const RESERVED_KEYS = new Set(['repeat', 'repeat-row', 'id']);
/** ä¸šåŠ¡å±žæ€§ key å¯¹åº”的画布属性名 */
export function propAttrName(key: string): string {
  return `${QUALITY_PROP_PREFIX}${key}`;
}
/** åˆ¤æ–­æŸä¸ªç”»å¸ƒå±žæ€§æ˜¯å¦å±žäºŽä¸šåŠ¡å±žæ€§ */
export function isQualityPropAttr(name: string): boolean {
  return (
    name.startsWith(QUALITY_PROP_PREFIX) &&
    !RESERVED_KEYS.has(name.slice(QUALITY_PROP_PREFIX.length))
  );
}
/** ç”»å¸ƒå±žæ€§åè¿˜åŽŸä¸ºä¸šåŠ¡å±žæ€§ key */
export function propKeyFromAttr(name: string): string {
  return name.slice(QUALITY_PROP_PREFIX.length);
}
src/components/quality/core/canvas-sync.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
/**
 * ç”»å¸ƒæ–‡å­—与业务属性的双向同步
 * <p>
 * ç”¨æˆ·åœ¨ç”»å¸ƒå†…直接改文字,改的是组件的 content;在属性面板改值,改的是 data-qc-* å±žæ€§ã€‚
 * ä¿å­˜æ—¶ä»¥å±žæ€§ä¸ºå‡†ï¼Œä¸æ‰“通的话画布里的修改会在下一次保存时被静默丢弃。
 */
import type { Component, Editor } from 'grapesjs';
import { QUALITY_TYPE_ATTR, propAttrName } from './attrs';
import { buildQualityTraits } from './factory';
import { definitionOf } from './lookup';
/**
 * è½½å…¥æ¨¡æ¿åŽé‡å»ºå±žæ€§é¢æ¿å­—段。
 * <p>
 * traits ä¸å‚与 GrapesJS é¡¹ç›®æ•°æ®çš„序列化,保存再打开时组件只剩框架自带的 id/title ä¸¤ä¸ªå­—段,
 * è¿™é‡ŒæŒ‰ data-quality-type ä»Žæ³¨å†Œè¡¨é‡å»ºã€‚属性值仍以组件上的 data-qc-* ä¸ºå‡†
 * ï¼ˆtrait åˆå§‹åŒ–优先读组件属性,读不到才用默认值),所以不会把用户改过的值冲掉。
 */
export function restoreQualityTraits(editor: Editor): void {
  editor
    .getWrapper()
    ?.find(`[${QUALITY_TYPE_ATTR}]`)
    .forEach((component) => {
      const definition = definitionOf(component);
      if (definition) {
        component.setTraits(buildQualityTraits(definition));
      }
    });
}
export function bindQualityCanvasSync(editor: Editor): void {
  // ç”»å¸ƒå†…改文字 â†’ å›žå†™ä¸šåŠ¡å±žæ€§
  editor.on('component:update:content', (component: Component) => {
    const contentKey = definitionOf(component)?.contentKey;
    if (!contentKey) {
      return;
    }
    const text = component.get('content');
    if (typeof text !== 'string') {
      return;
    }
    const attrName = propAttrName(contentKey);
    if (component.getAttributes()[attrName] === text) {
      return;
    }
    component.addAttributes({ [attrName]: text });
  });
  // å±žæ€§é¢æ¿æ”¹å€¼ â†’ åŒæ­¥ç”»å¸ƒå¯è§æ–‡å­—
  editor.on('trait:value', ({ component, trait, value }) => {
    const contentKey = definitionOf(component)?.contentKey;
    if (!contentKey || typeof value !== 'string') {
      return;
    }
    if (trait?.getName() !== propAttrName(contentKey)) {
      return;
    }
    if (component.get('content') === value) {
      return;
    }
    component.set('content', value);
  });
}
src/components/quality/core/catalog.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
/**
 * è´¨é‡ç»„件注册表 â†’ AI ç§¯æœ¨æ¸…单
 * <p>
 * æ¸…单是注册表的**派生视图**:剥掉 icon / buildContent / validate è¿™äº›ä¸å¯åºåˆ—化的成员,
 * åªç•™ä¸‹æ¨¡åž‹éœ€è¦çŸ¥é“的「有哪些组件、每个组件能填哪些字段」。
 * <p>
 * è¿™ä»½æ¸…单随请求传给后端拼进提示词,但它<b>不是</b>合法性的判据:真正把关的是装配器
 * å¯¹ç€æ´»æ³¨å†Œè¡¨æŸ¥ getQualityComponent。模型即便编出清单外的 type,也只会被跳过,
 * æ°¸è¿œå˜ä¸æˆä»»æ„ HTML(清单里不含任何 HTML èƒ½åŠ›çš„æè¿°ï¼‰ã€‚
 */
import type { QualityFieldSchema } from './types';
import { listQualityComponents } from './registry';
/** æ¸…单里的一个可填字段,与后端 QcReportComponentSpecVO.Field ä¸€ä¸€å¯¹åº” */
export interface QualityCatalogField {
  key: string;
  label: string;
  type: QualityFieldSchema['type'];
  required?: boolean;
  bindable?: boolean;
  /**
   * æžšä¸¾å€™é€‰é¡¹ã€‚
   * <p>
   * ç”¨ {label, value} æˆå¯¹ç»™å‡ºï¼šåªç»™ label æ¨¡åž‹ä¼šç…§ç€è¾“出中文标签,
   * åªç»™ value åˆä¸çŸ¥é“含义。成对给出,它才知道该输出哪个字面量。
   */
  enumOptions?: { label: string; value: number | string }[];
}
/** ä¸€ä¸ªç»„件积木 */
export interface QualityComponentCatalog {
  type: string;
  label: string;
  /** ç»„件分类的中文显示名,帮模型理解组件用途 */
  category: string;
  /**
   * ç»„件的选型说明,来自注册表的 aiHint。
   * <p>
   * åªæœ‰ label æ—¶ï¼Œæ¨¡åž‹é¢å¯¹ã€ŒæŠ¥å‘ŠæŠ¬å¤´ã€è¿™ç±»å†…å®¹ä¼šåœ¨ Heading / Text / ReportHeader ä¹‹é—´çžŽçŒœï¼Œ
   * ä¸”总会挑字段最简单的基础组件。这段文字就是给它划边界的,不是给人看的。
   */
  hint?: string;
  fields: QualityCatalogField[];
}
/** ä»Žæ´»æ³¨å†Œè¡¨ç”Ÿæˆç§¯æœ¨æ¸…单,顺序与设计器左侧面板一致 */
export function buildQualityComponentCatalog(): QualityComponentCatalog[] {
  const definitions = listQualityComponents();
  // æ³¨å†Œè¡¨è¦æ˜¾å¼è°ƒç”¨ registerAllQualityComponents() æ‰æœ‰å†…容(设计器在 init æ—¶åšï¼‰ã€‚
  // ç©ºæ¸…单会被后端判为入参非法,报出来的错和真实原因隔了好几层,所以在这里就说清楚。
  if (definitions.length === 0) {
    throw new Error('质量组件注册表为空,请先调用 registerAllQualityComponents()');
  }
  return definitions.map((definition) => {
    const item: QualityComponentCatalog = {
      type: definition.type,
      label: definition.label,
      category: definition.category,
      fields: definition.propertySchema.map(toCatalogField),
    };
    // ç¼ºå¸­å³çœç•¥ï¼Œä¸Ž toCatalogField åŒä¸€é£Žæ ¼ï¼šçœæŽ‰çš„字段能明显缩小提示词
    if (definition.aiHint) {
      item.hint = definition.aiHint;
    }
    return item;
  });
}
function toCatalogField(field: QualityFieldSchema): QualityCatalogField {
  const item: QualityCatalogField = {
    key: field.key,
    label: field.label,
    type: field.type,
  };
  // ç¼ºå¸­å³ã€Œå¦ã€ï¼ŒçœæŽ‰çš„字段能明显缩小提示词
  if (field.required) {
    item.required = true;
  }
  if (field.bindable) {
    item.bindable = true;
  }
  if (field.options?.length) {
    item.enumOptions = field.options.map((option) => ({
      label: option.label,
      value: option.value,
    }));
  }
  return item;
}
src/components/quality/core/column-spec.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
/**
 * ç»„件列定义 / è¡¨å¤´è·¨åˆ—的字符串解析。
 * <p>
 * ã€Œåˆ—」这件事必须能被编码成**一个标量字符串**:组件业务属性的类型只有
 * boolean / enum / number / string / text(见 {@link QualityFieldSchema}),
 * æœ€ç»ˆè¿˜ä¼šè½åˆ°ç”»å¸ƒèŠ‚ç‚¹çš„ attributes ä¸Šï¼Œè€Œé‚£é‡Œä¹Ÿæ˜¯å­—符串映射。
 * ç”¨ JSON ç¼–码列数组会要求模型输出转义后的嵌套 JSON,是它出错的重灾区;
 * ä¸€è¡Œ `标题=绑定|标题=绑定` ä¿¡æ¯é‡ç›¸åŒï¼Œæ¨¡åž‹ã€äººéƒ½å¥½å†™ã€‚
 * <p>
 * å¿…须单行:这串东西最终是 HTML å±žæ€§å€¼ï¼Œæ¢è¡Œåœ¨å±žæ€§é‡Œåˆæ³•但容易被
 * ç¼–辑器/序列化环节改写,统一按单行处理。
 * <p>
 * è§£æžæ”¾åœ¨è¿™é‡Œè€Œä¸æ˜¯å„组件内部,是因为校验列与表头跨列都依赖同一套语法,
 * ä¸¤ä»½å®žçŽ°è¿Ÿæ—©æ¼‚ç§»ï¼Œè€Œæ¼‚ç§»çš„è¡¨çŽ°æ˜¯ã€Œåˆ—æ•°å¯¹ä¸ä¸Šã€è¿™ç§å¾ˆéš¾æŸ¥çš„æ¸²æŸ“äº‹æ•…ã€‚
 */
/** ä¸€åˆ—:表头文字 + è¡Œå†…取值表达式 */
export interface QualityColumnSpec {
  /** è¡¨å¤´æ–‡å­—,已剥掉合并标记前缀 */
  label: string;
  /** è¡Œå†…取值表达式,如 {{item.itemName}} */
  expr: string;
  /** æ˜¯å¦çºµå‘合并跨住整组(标题前写了 #) */
  merge: boolean;
}
/** ä¸Šå±‚表头的一个单元格 */
export interface QualityHeaderSpan {
  label: string;
  span: number;
}
/** è§£æžç»“果:能解析出多少算多少,错误逐条回报,由调用方决定是否拦下保存 */
export interface QualitySpecParseResult<T> {
  items: T[];
  errors: string[];
}
/** åˆ—与列之间的分隔符 */
const SEGMENT_SEPARATOR = '|';
/** åˆå¹¶åˆ—的标记前缀 */
const MERGE_MARK = '#';
/** è¡¨å¤´è·¨åˆ—的计数标记 */
const SPAN_MARK = '^';
/**
 * è§£æžåˆ—定义串。
 *
 * @param spec å½¢å¦‚ `序号={{index}}|#检验项目={{item.itemName}}|子项={{item.group.childName}}`
 * @returns åˆ—定义与错误清单;空串返回空数组,由调用方回落到组件默认列
 */
export function parseColumnSpec(spec: string | undefined): QualitySpecParseResult<QualityColumnSpec> {
  const errors: string[] = [];
  const items: QualityColumnSpec[] = [];
  const segments = splitSegments(spec);
  segments.forEach((segment, index) => {
    const position = `第 ${index + 1} æ®µã€Œ${segment}」`;
    const separatorAt = segment.indexOf('=');
    if (separatorAt < 0) {
      errors.push(`${position}缺少「=」,每一列都要写成「列标题=绑定表达式」`);
      return;
    }
    const rawLabel = segment.slice(0, separatorAt).trim();
    const expr = segment.slice(separatorAt + 1).trim();
    const merge = rawLabel.startsWith(MERGE_MARK);
    const label = merge ? rawLabel.slice(MERGE_MARK.length).trim() : rawLabel;
    if (!label) {
      errors.push(`${position}的列标题是空的,请补上打印在表头上的文字`);
      return;
    }
    if (!expr) {
      errors.push(`${position}的绑定表达式是空的,请补上「=」后面的内容,如 {{item.itemName}}`);
      return;
    }
    items.push({ label, expr, merge });
  });
  return { items, errors };
}
/**
 * è§£æžä¸Šå±‚表头串。
 *
 * @param spec        å½¢å¦‚ `检验结果^5|结论^1`;写不出 `^N` çš„æŒ‰ 1 åˆ—算,靠总数校验兜底
 * @param columnCount ä¸‹å±‚实际列数,用来校验各段跨列数之和
 */
export function parseHeaderSpans(
  spec: string | undefined,
  columnCount: number,
): QualitySpecParseResult<QualityHeaderSpan> {
  const errors: string[] = [];
  const items: QualityHeaderSpan[] = [];
  const segments = splitSegments(spec);
  // æ²¡å¡«å°±æ˜¯ã€Œä¸è¦ä¸Šå±‚表头」,不是「跨列数合计为 0」——不能拿它去撞总数校验
  if (segments.length === 0) {
    return { items, errors };
  }
  segments.forEach((segment, index) => {
    const position = `第 ${index + 1} æ®µã€Œ${segment}」`;
    const markAt = segment.lastIndexOf(SPAN_MARK);
    const rawLabel = (markAt < 0 ? segment : segment.slice(0, markAt)).trim();
    const rawSpan = markAt < 0 ? '' : segment.slice(markAt + 1).trim();
    if (!rawLabel) {
      errors.push(`${position}的表头文字是空的,请补上跨越这几列的分组标题`);
      return;
    }
    const span = rawSpan ? Number(rawSpan) : 1;
    if (!Number.isInteger(span) || span < 1) {
      errors.push(`${position}的跨列数「${rawSpan}」不是正整数,请写成如 æ£€éªŒç»“æžœ^5 çš„形式`);
      return;
    }
    items.push({ label: rawLabel, span });
  });
  if (errors.length > 0) {
    return { items, errors };
  }
  const total = items.reduce((sum, item) => sum + item.span, 0);
  if (total !== columnCount) {
    errors.push(
      `表头跨列数合计 ${total},检验表的实际列数为 ${columnCount},两者必须相等。` +
        `请检查「表头跨列」里各段末尾 ^ åŽé¢çš„æ•°å­—`,
    );
  }
  return { items, errors };
}
/** æŒ‰ `|` åˆ‡æ®µï¼šç©ºæ®µï¼ˆé¦–尾多写了分隔符)直接跳过,不当作错误,避免手滑就报错 */
function splitSegments(spec: string | undefined): string[] {
  if (!spec) {
    return [];
  }
  return spec
    .split(SEGMENT_SEPARATOR)
    .map((segment) => segment.trim())
    .filter((segment) => segment.length > 0);
}
src/components/quality/core/factory.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
/**
 * è´¨é‡ç»„ä»¶ â†’ GrapesJS äº§ç‰©
 * <p>
 * åè®®ï¼ˆQualityComponentDefinition)与 GrapesJS çš„æ¡¥æŽ¥åªåœ¨è¿™ä¸€å±‚:
 * ç»„件块、画布内容、属性面板(traits)都由 definition æŽ¨å¯¼ï¼Œç»„件实现里不出现 GrapesJS æ¦‚念。
 */
import type { BlockProperties, ComponentAdd, TraitProperties } from 'grapesjs';
import type {
  QualityComponentContent,
  QualityComponentDefinition,
  QualityFieldSchema,
  QualityProps,
} from './types';
import { QUALITY_TYPE_ATTR, propAttrName } from './attrs';
import { listQualityComponents } from './registry';
import { propsToAttributes } from './serializer';
import { BINDING_TRAIT_TYPE } from './trait-binding';
import { resolveQualityProps } from './validator';
/** è½åˆ°ç”»å¸ƒä¸Šçš„组件定义:协议内容 + å±žæ€§é¢æ¿å­—段 */
export interface QualityCanvasComponent extends QualityComponentContent {
  traits?: TraitProperties[];
}
/** definition + å±žæ€§ â†’ ç”»å¸ƒå†…容,attributes é‡Œå¸¦ä¸Šç±»åž‹æ ‡è®°ä¸Žå…¨éƒ¨ä¸šåŠ¡å±žæ€§ */
export function buildQualityContent(
  definition: QualityComponentDefinition,
  props?: QualityProps,
): QualityCanvasComponent {
  const resolved = resolveQualityProps(definition, props);
  const content = definition.buildContent(resolved);
  return {
    ...content,
    attributes: {
      ...content.attributes,
      [QUALITY_TYPE_ATTR]: definition.type,
      ...propsToAttributes(definition, resolved),
    },
    traits: buildQualityTraits(definition),
  };
}
/**
 * å±žæ€§é¢æ¿å­—段。
 * <p>
 * é»˜è®¤ changeProp ä¸º false,trait å€¼ç›´æŽ¥å†™å›žç»„ä»¶çš„ data-qc-<key> å±žæ€§ï¼Œ
 * ä¸Ž serializer çš„读取路径保持一致。
 */
export function buildQualityTraits(
  definition: QualityComponentDefinition,
): TraitProperties[] {
  return definition.propertySchema.map((field) => traitFromField(definition, field));
}
function traitFromField(
  definition: QualityComponentDefinition,
  field: QualityFieldSchema,
): TraitProperties {
  const base: TraitProperties = {
    name: propAttrName(field.key),
    label: field.label,
    placeholder: field.placeholder,
  };
  // å¯ç»‘定字段用自定义控件:既能手工输入,也能从数据字段里挑,挑中的写成 {{路径}}
  if (field.bindable && (field.type === 'string' || field.type === 'text')) {
    return { ...base, type: BINDING_TRAIT_TYPE };
  }
  switch (field.type) {
    case 'boolean': {
      return {
        ...base,
        type: 'checkbox',
        // æ˜¾å¼å£°æ˜ŽçœŸ/假字面量,DOM å±žæ€§åªèƒ½æ˜¯å­—符串,靠它才能稳定往返
        valueTrue: 'true',
        valueFalse: 'false',
        default: String(field.defaultValue ?? false),
      };
    }
    case 'enum': {
      return {
        ...base,
        type: 'select',
        options: (field.options ?? []).map((option) => ({
          id: String(option.value),
          name: option.label,
        })),
        default: field.defaultValue === undefined ? undefined : String(field.defaultValue),
      };
    }
    case 'number': {
      return {
        ...base,
        type: 'number',
        default: field.defaultValue === undefined ? undefined : String(field.defaultValue),
      };
    }
    case 'text': {
      return { ...base, type: 'textarea' };
    }
    default: {
      return { ...base, type: 'text' };
    }
  }
}
/** definition â†’ å·¦ä¾§é¢æ¿çš„一个组件块 */
export function buildQualityBlock(definition: QualityComponentDefinition): BlockProperties {
  return {
    id: `quality-${definition.type}`,
    label: definition.label,
    category: definition.category,
    media: definition.icon,
    content: buildQualityContent(definition) as unknown as BlockProperties['content'],
    // ç‚¹å‡»ç»„件块追加到纸张末尾。GrapesJS é»˜è®¤è¿½åŠ è¿›ã€Œå½“å‰é€‰ä¸­çš„ç»„ä»¶ã€å†…éƒ¨ï¼Œ
    // åœ¨æŠ¥å‘Šè¿™ç§æ‰å¹³ç»“构里会把新组件塞进标题、表格单元格等元素,还会顶掉它们原有的文本。
    // éœ€è¦åµŒå¥—时仍可用拖拽精确落点。
    onClick: (block, editor) => {
      const content = block.get('content') as ComponentAdd;
      editor.getWrapper()?.append(content);
    },
  };
}
/** æ³¨å†Œè¡¨é‡Œçš„全部组件 â†’ GrapesJS ç»„件块列表 */
export function buildQualityBlocks(): BlockProperties[] {
  return listQualityComponents().map(buildQualityBlock);
}
src/components/quality/core/html.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
/**
 * ç”»å¸ƒå†…容与渲染产物都走 HTML å­—符串,任何来自用户(设计器属性面板)的文本
 * éƒ½å¿…须先转义再拼进标签,否则在渲染期会变成注入点。
 */
export function escapeHtml(value: unknown): string {
  return String(value ?? '')
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}
/**
 * è½¬ä¹‰ä¹‹åŽå†æŠŠæ¢è¡ŒæŠ˜æˆ <br>。
 * <p>
 * é¡µè„šè½æ¬¾ã€å¤šè¡Œæ–‡æœ¬å¸¸åœ¨ä¸€ä¸ªå±žæ€§é‡Œå¡žå¤šè¡Œï¼ˆAI å¯¼å…¥ä¼šæŠŠåŽŸä»¶é¡µè„šä¸Žç­¾ç½²è¡Œåˆå¹¶æˆä¸€æ®µï¼‰ï¼Œ
 * åªè½¬ä¹‰çš„话换行符会被 HTML çš„空白折叠吃掉,几行内容连成一整片。
 */
export function escapeHtmlMultiline(value: unknown): string {
  return escapeHtml(value).replace(/\r\n?|\n/g, '<br>');
}
/** æ‹¼ä¸€æ®µå†…联样式,过滤掉空值 */
export function cssStyle(style: Record<string, number | string | undefined>): string {
  return Object.entries(style)
    .filter(([, value]) => value !== undefined && value !== '')
    .map(([key, value]) => `${key}:${value}`)
    .join(';');
}
src/components/quality/core/icons.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
/** å·¦ä¾§ç»„件面板图标:内联 SVG,跟随当前文字颜色 */
export function qualityIcon(paths: string): string {
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
        stroke-linecap="round" stroke-linejoin="round" width="22" height="22">${paths}</svg>`;
}
export const QUALITY_ICONS = {
  heading: qualityIcon('<path d="M6 4v16M18 4v16M6 12h12"/>'),
  text: qualityIcon('<path d="M4 6h16M4 12h16M4 18h10"/>'),
  image: qualityIcon(
    '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="m21 16-5-5-6 6-3-3-4 4"/>',
  ),
  divider: qualityIcon('<path d="M3 12h18"/>'),
  spacer: qualityIcon('<path d="M12 4v16M8 4h8M8 20h8"/>'),
  table: qualityIcon(
    '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M3 15h18M9 10v10M15 10v10"/>',
  ),
  header: qualityIcon('<rect x="3" y="4" width="18" height="6" rx="1"/><path d="M6 7h6"/>'),
  footer: qualityIcon(
    '<rect x="3" y="14" width="18" height="6" rx="1"/><path d="M6 17h6"/>',
  ),
  sample: qualityIcon(
    '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h4M7 13h6M15 9h2"/>',
  ),
  inspection: qualityIcon(
    '<path d="M9 4h6v3H9z"/><rect x="4" y="7" width="16" height="14" rx="2"/><path d="m8 14 2.5 2.5L16 11"/>',
  ),
  qualityTable: qualityIcon(
    '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 9v11M13 9v11M3 14h18"/>',
  ),
  groupedTable: qualityIcon(
    '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M8 9v11M8 14h13"/>',
  ),
  result: qualityIcon(
    '<circle cx="12" cy="12" r="8"/><path d="m8.5 12.5 2.5 2.5 4.5-5"/>',
  ),
} as const;
src/components/quality/core/lookup.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
/**
 * ç”»å¸ƒèŠ‚ç‚¹ â†’ è´¨é‡ç»„件定义的反查。
 * <p>
 * å•独成文件:canvas-sync ä¸Ž trait-binding éƒ½è¦ç”¨å®ƒï¼Œ
 * æ”¾åœ¨ä»»ä¸€æ–¹éƒ½ä¼šè®©ã€Œå±žæ€§é¢æ¿æŽ§ä»¶ã€å’Œã€Œç”»å¸ƒåŒæ­¥ã€äº’相依赖。
 */
import type { Component } from 'grapesjs';
import type { QualityComponentDefinition } from './types';
import { QUALITY_TYPE_ATTR } from './attrs';
import { getQualityComponent } from './registry';
/** æŒ‰èŠ‚ç‚¹ä¸Šçš„ data-quality-type æŸ¥å®šä¹‰ï¼›ä¸æ˜¯è´¨é‡ç»„件时返回 undefined */
export function definitionOf(component?: Component): QualityComponentDefinition | undefined {
  const attributes = component?.getAttributes() ?? {};
  return getQualityComponent(String(attributes[QUALITY_TYPE_ATTR] ?? ''));
}
src/components/quality/core/registry.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
/**
 * è´¨é‡ç»„件注册表
 * <p>
 * å…¨å¹³å°å”¯ä¸€çš„组件来源:设计器左侧面板、属性面板、渲染引擎、校验都从这里取定义。
 * æ–°å¢žç»„件只需要在 index.ts é‡Œå¤šæ³¨å†Œä¸€ä¸ª definition,不改动 GrapesJS é›†æˆä»£ç ã€‚
 */
import type {
  QualityCategory,
  QualityComponentDefinition,
} from './types';
import { QUALITY_CATEGORY_ORDER } from './types';
const definitions = new Map<string, QualityComponentDefinition>();
/** æ³¨å†Œå•个组件;同 type é‡å¤æ³¨å†ŒæŒ‰è¦†ç›–处理,便于开发期热更新 */
export function registerQualityComponent(definition: QualityComponentDefinition): void {
  if (!definition.type) {
    throw new Error('质量组件定义缺少 type');
  }
  definitions.set(definition.type, definition);
}
/** æ‰¹é‡æ³¨å†Œ */
export function registerQualityComponents(list: QualityComponentDefinition[]): void {
  list.forEach(registerQualityComponent);
}
/** æŒ‰ç±»åž‹å–定义,未注册返回 undefined */
export function getQualityComponent(type?: string): QualityComponentDefinition | undefined {
  return type ? definitions.get(type) : undefined;
}
/** å…¨éƒ¨ç»„件,按分类顺序排列(同分类内按注册顺序) */
export function listQualityComponents(): QualityComponentDefinition[] {
  return [...definitions.values()].sort(
    (a, b) => categoryIndex(a.category) - categoryIndex(b.category),
  );
}
/** æŒ‰åˆ†ç±»åˆ†ç»„,空分类不返回 */
export function listQualityComponentsByCategory(): {
  category: QualityCategory;
  components: QualityComponentDefinition[];
}[] {
  const grouped = new Map<QualityCategory, QualityComponentDefinition[]>();
  listQualityComponents().forEach((definition) => {
    const list = grouped.get(definition.category) ?? [];
    list.push(definition);
    grouped.set(definition.category, list);
  });
  return QUALITY_CATEGORY_ORDER.filter((category) => grouped.has(category)).map(
    (category) => ({ category, components: grouped.get(category)! }),
  );
}
function categoryIndex(category: QualityCategory): number {
  const index = QUALITY_CATEGORY_ORDER.indexOf(category);
  return index === -1 ? QUALITY_CATEGORY_ORDER.length : index;
}
src/components/quality/core/serializer.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
/**
 * è´¨é‡å±žæ€§ â†” ç”»å¸ƒ attributes çš„双向转换
 * <p>
 * ä¸šåŠ¡å±žæ€§ç»Ÿä¸€è½åœ¨ç”»å¸ƒèŠ‚ç‚¹çš„ data-qc-<key> ä¸Šï¼Œç”»å¸ƒæ•°æ®ï¼ˆgrapes)本身即自描述,
 * ä¿å­˜ã€å›žæ˜¾ã€æ¸²æŸ“都不需要再额外维护一份映射表。
 */
import type {
  QualityComponentDefinition,
  QualityProps,
} from './types';
import { isQualityPropAttr, propAttrName, propKeyFromAttr } from './attrs';
import { coerceFieldValue } from './validator';
/** ä¸šåŠ¡å±žæ€§ â†’ ç”»å¸ƒ attributes(全部字符串化,DOM å±žæ€§åªèƒ½æ˜¯å­—符串) */
export function propsToAttributes(
  definition: QualityComponentDefinition,
  props: QualityProps,
): Record<string, string> {
  const attributes: Record<string, string> = {};
  definition.propertySchema.forEach((field) => {
    const value = props[field.key];
    if (value === undefined) {
      return;
    }
    attributes[propAttrName(field.key)] = String(value);
  });
  return attributes;
}
/** ç”»å¸ƒ attributes â†’ ä¸šåŠ¡å±žæ€§ï¼ˆæŒ‰å­—æ®µå£°æ˜Žè¿˜åŽŸç±»åž‹ï¼‰ */
export function attributesToProps(
  definition: QualityComponentDefinition,
  attributes: Record<string, string>,
): QualityProps {
  const props: QualityProps = {};
  definition.propertySchema.forEach((field) => {
    const raw = attributes[propAttrName(field.key)];
    if (raw === undefined) {
      return;
    }
    props[field.key] = coerceFieldValue(field, raw);
  });
  return props;
}
/** å–出节点上所有业务属性(不认识定义时也能读,用于诊断与迁移) */
export function extractRawProps(attributes: Record<string, string>): QualityProps {
  const props: QualityProps = {};
  Object.entries(attributes).forEach(([name, value]) => {
    if (isQualityPropAttr(name)) {
      props[propKeyFromAttr(name)] = value;
    }
  });
  return props;
}
src/components/quality/core/trait-binding.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,161 @@
/**
 * ã€Œç»‘定数据」属性控件
 * <p>
 * å£°æ˜Žäº† bindable çš„字段,属性面板除了能手工输入,还可以从组件 dataSchema é‡ŒæŒ‘一个数据字段。
 * æŒ‘中后写进属性值的仍是普通文本,渲染引擎按绑定协议解析,
 * æ‰€ä»¥ç»‘定与手工输入走的是同一条路径,不存在两套取值逻辑。
 */
import type { Component, Editor, Trait } from 'grapesjs';
import type { QualityFieldSchema } from './types';
import { propAttrName } from './attrs';
import { definitionOf } from './lookup';
/** ç»‘定控件在 GrapesJS é‡Œæ³¨å†Œçš„类型名 */
export const BINDING_TRAIT_TYPE = 'qc-binding';
/** ä¸ç»‘定时的空选项 */
const NO_BINDING_LABEL = '不绑定(手工输入)';
const SELECT_CLASS = 'qc-binding-select';
const INPUT_CLASS = 'qc-binding-input';
const HINT_CLASS = 'qc-binding-hint';
/** ç»‘定值写回属性的形式,与 QualityFieldSchema.bindAs å¯¹åº” */
type BindMode = 'path' | 'text';
/** ç»‘定路径 â†’ å±žæ€§å€¼ï¼›text åŒ…成 {{path}} äº¤æ¸²æŸ“期解析,path å¿…须保持裸路径 */
function toBindingValue(path: string, mode: BindMode): string {
  if (!path) {
    return '';
  }
  return mode === 'path' ? path : `{{${path}}}`;
}
/** å±žæ€§å€¼ â†’ ç»‘定路径;识别不出就返回空串,表示这是手工输入而非绑定 */
function fromBindingValue(value: string, mode: BindMode): string {
  const trimmed = value.trim();
  if (mode === 'path') {
    return trimmed;
  }
  const match = /^\{\{\s*([^{}]+?)\s*\}\}$/.exec(trimmed);
  return match?.[1]?.trim() ?? '';
}
interface BindingInputParts {
  input: HTMLInputElement;
  select: HTMLSelectElement;
}
/**
 * è‡ªå®šä¹‰ trait å›žè°ƒçš„入参。
 * <p>
 * TraitManager.addType çš„æ³›åž‹ä¸ä¼šæŠŠå‚数类型推导到回调上,不显式标注就会是隐式 any。
 * å½¢çŠ¶ä¸Ž TraitView å†…部 getClbOpts() çš„返回值一致。
 */
interface TraitViewOpts {
  component: Component;
  elInput: HTMLInputElement;
  trait: Trait;
}
/** æ‰¾åˆ°å½“前 trait å¯¹åº”的属性字段,取它的绑定形式与提示 */
function fieldOf(component: Component | undefined, trait: Trait): QualityFieldSchema | undefined {
  const name = trait.getName();
  return definitionOf(component)?.propertySchema.find((field) => propAttrName(field.key) === name);
}
function bindModeOf(component: Component | undefined, trait: Trait): BindMode {
  return fieldOf(component, trait)?.bindAs === 'path' ? 'path' : 'text';
}
/** åœ¨å±žæ€§é¢æ¿é‡Œå»ºå‡ºã€Œä¸‹æ‹‰é€‰æ‹© + æ‰‹å·¥è¾“å…¥ + æç¤ºã€ä¸‰ä»¶å¥— */
function createBindingInput(trait: Trait, component?: Component): HTMLElement {
  const definition = definitionOf(component);
  const wrap = document.createElement('div');
  wrap.className = 'qc-binding';
  const select = document.createElement('select');
  select.className = SELECT_CLASS;
  select.append(new Option(NO_BINDING_LABEL, ''));
  (definition?.dataSchema ?? []).forEach((field) => {
    // é€‰é¡¹å€¼å°±æ˜¯ç»‘定路径,标签给用户看懂用途
    select.append(new Option(field.label, field.key));
  });
  const input = document.createElement('input');
  input.type = 'text';
  input.className = INPUT_CLASS;
  input.placeholder = trait.get('placeholder') ?? '{{report.batchNo}}';
  wrap.append(select, input);
  const tip = fieldOf(component, trait)?.tip;
  if (tip) {
    const hint = document.createElement('div');
    hint.className = HINT_CLASS;
    hint.textContent = tip;
    wrap.append(hint);
  }
  return wrap;
}
function partsOf(elInput: HTMLElement): BindingInputParts | undefined {
  const select = elInput.querySelector<HTMLSelectElement>(`.${SELECT_CLASS}`);
  const input = elInput.querySelector<HTMLInputElement>(`.${INPUT_CLASS}`);
  if (!select || !input) {
    return undefined;
  }
  return { input, select };
}
/** æŠŠå½“前属性值回填到两个控件上:能识别成绑定的勾下拉,否则只填输入框 */
function syncFromTrait(elInput: HTMLElement, trait: Trait, component?: Component): void {
  const parts = partsOf(elInput);
  if (!parts) {
    return;
  }
  const value = String(trait.getValue() ?? '');
  const path = fromBindingValue(value, bindModeOf(component, trait));
  // è®¾ç½®äº† value å´ä¸åœ¨å€™é€‰é¡¹é‡Œï¼ˆå¦‚组件换过、路径已废弃)时补一个临时选项,避免下拉显示空白
  if (path && ![...parts.select.options].some((option) => option.value === path)) {
    parts.select.append(new Option(path, path));
  }
  parts.select.value = path;
  parts.input.value = value;
}
/** æ³¨å†Œç»‘定控件类型;重复注册直接覆盖,配合热更新不报错 */
export function registerBindingTraitType(editor: Editor): void {
  editor.TraitManager.addType(BINDING_TRAIT_TYPE, {
    createInput({ trait, component }) {
      return createBindingInput(trait, component);
    },
    onRender({ elInput, trait, component }: TraitViewOpts) {
      syncFromTrait(elInput, trait, component);
    },
    onUpdate({ elInput, trait, component }: TraitViewOpts) {
      syncFromTrait(elInput, trait, component);
    },
    onEvent({ elInput, trait, component, event }: TraitViewOpts & { event: Event }) {
      const target = event.target;
      if (!(target instanceof HTMLElement)) {
        return;
      }
      const parts = partsOf(elInput);
      if (!parts) {
        return;
      }
      if (target.classList.contains(SELECT_CLASS)) {
        const value = toBindingValue(parts.select.value, bindModeOf(component, trait));
        parts.input.value = value;
        trait.setValue(value);
        return;
      }
      if (target.classList.contains(INPUT_CLASS)) {
        trait.setValue(parts.input.value);
      }
    },
  });
}
src/components/quality/core/types.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,133 @@
/**
 * è´¨é‡ç»„件协议(Quality Component Protocol)
 * <p>
 * è®¾è®¡å™¨åªè®¤è¿™å¥—协议,不认具体业务组件:新增质量组件=注册一个新 definition,
 * ä¸éœ€è¦æ”¹åЍ GrapesJS é›†æˆä»£ç ï¼Œä¹Ÿä¸éœ€è¦æ”¹åŠ¨è®¾è®¡å™¨é¡µé¢ï¼ˆæ–¹æ¡ˆè®¾è®¡ Â§9)。
 */
/** ç»„件分类,顺序即设计器左侧面板的分组顺序 */
export const QUALITY_CATEGORY = {
  BASIC: '基础组件',
  HEADER: '报告组件',
  INSPECTION: '检验组件',
  RESULT: '结果组件',
} as const;
export type QualityCategory = (typeof QUALITY_CATEGORY)[keyof typeof QUALITY_CATEGORY];
/** åˆ†ç±»çš„展示顺序 */
export const QUALITY_CATEGORY_ORDER: QualityCategory[] = [
  QUALITY_CATEGORY.BASIC,
  QUALITY_CATEGORY.HEADER,
  QUALITY_CATEGORY.INSPECTION,
  QUALITY_CATEGORY.RESULT,
];
/** å±žæ€§å­—段的值类型,决定属性面板用什么控件 */
export type QualityFieldType =
  | 'boolean'
  | 'enum'
  | 'number'
  | 'string'
  | 'text';
/** å±žæ€§å­—段的可选项 */
export interface QualityFieldOption {
  label: string;
  value: number | string;
}
/**
 * ä¸€ä¸ªä¸šåŠ¡å±žæ€§ / å¯ç»‘定数据字段的描述。
 * <p>
 * propertySchema æè¿°ã€Œè®¾è®¡æœŸç”¨æˆ·èƒ½æ”¹ä»€ä¹ˆã€ï¼ŒdataSchema æè¿°ã€Œæ¸²æŸ“期能绑什么」,
 * ä¸¤è€…共用一个结构,区别只在用途。
 */
export interface QualityFieldSchema {
  /** å­—段键,同时是写入画布 attributes çš„ data-qc-<key> å */
  key: string;
  label: string;
  type: QualityFieldType;
  /** é»˜è®¤å€¼ï¼Œå¿…须与 type å¯¹åº” */
  defaultValue?: boolean | number | string;
  /** type ä¸º enum æ—¶çš„候选项 */
  options?: QualityFieldOption[];
  placeholder?: string;
  /** è¾“入提示,展示在属性面板字段下方 */
  tip?: string;
  /** æ˜¯å¦å¿…填(校验用) */
  required?: boolean;
  /**
   * æ˜¯å¦å…è®¸ç»‘定数据源表达式({{path}})。
   * ä¸º false æ—¶å±žæ€§é¢æ¿ä¸æä¾›ã€Œç»‘定数据」入口。
   */
  bindable?: boolean;
  /**
   * bindable ä¸º true æ—¶ï¼Œä»Žæ•°æ®å­—段里挑中后写回属性值的形式。
   * <p>
   * - text(默认):写成 {{path}},渲染期按绑定协议替换成数据值,适用于标题、文本、结论等;
   * - path:直接写裸路径,渲染期把它当作结构化路径用(如按数组逐行生成的检验项数据源),
   *   åŒ…上 {{}} åè€Œä¼šè®©æ¸²æŸ“期找不到数组。
   */
  bindAs?: 'path' | 'text';
}
/** ä¸šåŠ¡å±žæ€§çš„è¿è¡Œæ—¶å–å€¼ */
export type QualityProps = Record<string, boolean | number | string | undefined>;
/** GrapesJS ç»„件内容,等价于 BlockProperties['content'] çš„常用子集 */
export interface QualityComponentContent {
  tagName?: string;
  /** å­ç»„件:HTML å­—符串或组件描述对象数组 */
  components?: QualityComponentContent[] | string;
  content?: string;
  attributes?: Record<string, string>;
  style?: Record<string, string>;
}
/**
 * è´¨é‡ç»„件定义:注册进 registry çš„唯一协议。
 */
export interface QualityComponentDefinition {
  /** å”¯ä¸€ç±»åž‹æ ‡è¯†ï¼Œå†™å…¥ç”»å¸ƒçš„ data-quality-type,也是 Schema.components[].qualityType */
  type: string;
  /** è‹±æ–‡åï¼Œç”¨äºŽä»£ç å†…引用 */
  name: string;
  /** è®¾è®¡å™¨å·¦ä¾§é¢æ¿ä¸Žå±žæ€§é¢æ¿çš„æ˜¾ç¤ºå */
  label: string;
  category: QualityCategory;
  /** å·¦ä¾§é¢æ¿çš„图标(内联 SVG å­—符串) */
  icon: string;
  /** æ–°ç»„件落到画布时的初始业务属性 */
  defaults: QualityProps;
  /** è®¾è®¡æœŸå¯ç¼–辑的业务属性 */
  propertySchema: QualityFieldSchema[];
  /** æ¸²æŸ“期可绑定的数据字段 */
  dataSchema: QualityFieldSchema[];
  /**
   * ç»„件的可见文本对应的属性 key。
   * <p>
   * å£°æ˜ŽåŽï¼Œç”¨æˆ·åœ¨ç”»å¸ƒå†…直接改文字会同步回该属性,属性面板改值也会同步回画布;
   * å†…容由多个元素拼成的组件(页眉、表格)不声明,避免整块 HTML è¢«å½“成文本。
   */
  contentKey?: string;
  /**
   * ç»™ AI å¯¼å…¥ç”¨çš„选型说明:这个组件用来放什么、不要用来放什么。
   * <p>
   * ä¸è¿›è®¾è®¡å™¨ç•Œé¢ï¼Œåªéšç§¯æœ¨æ¸…单进提示词。写它是因为清单里只有 label æ—¶ï¼Œ
   * æ¨¡åž‹é¢å¯¹ã€ŒæŠ¥å‘ŠæŠ¬å¤´ã€è¿™ç±»å†…å®¹ä¼šåœ¨ Heading / Text / ReportHeader ä¹‹é—´çžŽçŒœï¼Œ
   * è€Œåªä¼šçœ‹åˆ°ä¸­æ–‡æ ‡ç­¾çš„它没有任何理由不挑最通用的那个。
   * <p>
   * å¿…须包含「不要用来放什么」:只讲用途挡不住误用,讲清边界才有用。
   */
  aiHint?: string;
  /**
   * ç”Ÿæˆç”»å¸ƒå†…容。properties å·²ç»æ”¶æ•›è¿‡é»˜è®¤å€¼ï¼Œå®žçŽ°é‡Œå¯ä»¥ç›´æŽ¥ç”¨ã€‚
   */
  buildContent: (properties: QualityProps) => QualityComponentContent;
  /**
   * æ ¡éªŒä¸šåŠ¡å±žæ€§ï¼Œè¿”å›žé”™è¯¯ä¿¡æ¯æ•°ç»„ï¼›ç©ºæ•°ç»„è¡¨ç¤ºé€šè¿‡ã€‚
   * ä»…在保存/发布前调用,正常设计过程不应触发。
   */
  validate?: (properties: QualityProps) => string[];
}
src/components/quality/core/validator.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
/**
 * è´¨é‡ç»„件属性校验与默认值收敛
 * <p>
 * åªåœ¨ä¿å­˜ / å‘布前调用;设计过程中的中间态(比如刚拖到画布、还没填值)不触发校验。
 */
import type {
  QualityComponentDefinition,
  QualityFieldSchema,
  QualityProps,
} from './types';
/** æŠŠä»»æ„æ¥æºçš„原始值收敛成字段声明的类型 */
export function coerceFieldValue(
  field: QualityFieldSchema,
  raw: boolean | number | string,
): boolean | number | string {
  switch (field.type) {
    case 'boolean': {
      if (typeof raw === 'boolean') {
        return raw;
      }
      return raw === 'true' || raw === '1';
    }
    case 'number': {
      const parsed = Number(raw);
      if (Number.isFinite(parsed)) {
        return parsed;
      }
      return typeof field.defaultValue === 'number' ? field.defaultValue : 0;
    }
    default: {
      return String(raw);
    }
  }
}
/** ä»¥ defaults æ‰“底,用传入值覆盖其中的合法项,得到完整可用的属性集 */
export function resolveQualityProps(
  definition: QualityComponentDefinition,
  props?: QualityProps,
): QualityProps {
  const resolved: QualityProps = { ...definition.defaults };
  if (!props) {
    return resolved;
  }
  definition.propertySchema.forEach((field) => {
    const raw = props[field.key];
    if (raw === undefined) {
      return;
    }
    resolved[field.key] = coerceFieldValue(field, raw);
  });
  return resolved;
}
/**
 * æ ¡éªŒä¸šåŠ¡å±žæ€§ï¼Œè¿”å›žé”™è¯¯ä¿¡æ¯æ•°ç»„ï¼›ç©ºæ•°ç»„è¡¨ç¤ºé€šè¿‡ã€‚
 * <p>
 * æç¤ºè¦èƒ½è®©ç”¨æˆ·ç›´æŽ¥å®šä½ï¼Œæ‰€ä»¥å¸¦ä¸Šå­—段显示名与实际值。
 */
export function validateQualityProps(
  definition: QualityComponentDefinition,
  props?: QualityProps,
): string[] {
  const errors: string[] = [];
  const resolved = resolveQualityProps(definition, props);
  definition.propertySchema.forEach((field) => {
    const value = resolved[field.key];
    const isEmpty = value === undefined || value === '';
    if (isEmpty) {
      if (field.required) {
        errors.push(`${definition.label}的「${field.label}」不能为空`);
      }
      return;
    }
    if (field.type === 'enum' && field.options) {
      const matched = field.options.some((option) => String(option.value) === String(value));
      if (!matched) {
        errors.push(
          `${definition.label}的「${field.label}」值「${value}」不在可选项内,请重新选择`,
        );
      }
      return;
    }
    if (field.type === 'number' && typeof value === 'number' && !Number.isFinite(value)) {
      errors.push(`${definition.label}的「${field.label}」必须是数字,当前为「${value}」`);
    }
  });
  // é€ä¸ªå­—段的规则上面已经查完,剩下的是「字段都在、但组合起来不成立」的规则
  // ï¼ˆåˆ—定义串的语法、表头跨列数合计等),只有组件自己知道,必须在这里接上。
  // ä¸æŽ¥çš„话 definition.validate å°±æ˜¯ä¸ªæ²¡äººè°ƒç”¨çš„æ­»æ–¹æ³•,写了也不会拦住任何人。
  errors.push(...(definition.validate?.(resolved) ?? []));
  return errors;
}
src/components/quality/engine/binding.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
/**
 * æ•°æ®ç»‘定:模板文本里的 {{path}}
 * <p>
 * åªåšå–值与替换,不做任何表达式求值;需要计算的场景交给规则引擎。
 * å–不到的路径渲染为空字符串——报告上不该出现用户看不懂的 {{}}。
 */
import { formatValue, readPath } from './path';
/** ç»‘定表达式:{{ report.reportNo }} */
const BINDING_PATTERN = /\{\{\s*([^{}]+?)\s*\}\}/g;
/** æå–文本里出现的所有绑定路径(去重),用于渲染前的数据依赖检查 */
export function listBindingPaths(template: string): string[] {
  const paths = new Set<string>();
  for (const match of template.matchAll(BINDING_PATTERN)) {
    const path = match[1]?.trim();
    if (path) {
      paths.add(path);
    }
  }
  return [...paths];
}
/** è§£æžå•个绑定路径,取不到返回 undefined */
export function resolveBinding(path: string, context: unknown): unknown {
  return readPath(context, path.trim());
}
/**
 * æ›¿æ¢æ–‡æœ¬é‡Œçš„全部绑定表达式,并回报取不到值的路径。
 * <p>
 * æ¸²æŸ“时绑定不上通常意味着数据缺口,静默渲染成空会让报告看起来"正常但缺内容",
 * æ‰€ä»¥æŠŠç¼ºå£äº¤ç»™è°ƒç”¨æ–¹ï¼Œç”±æ¸²æŸ“引擎汇总成可见的问题清单。
 */
export function resolveTextWithReport(
  template: string,
  context: unknown,
  onMissing: (path: string) => void,
): string {
  return template.replaceAll(BINDING_PATTERN, (_, rawPath: string) => {
    const path = rawPath.trim();
    const value = readPath(context, path);
    if (value === undefined || value === null) {
      onMissing(path);
    }
    return formatValue(value);
  });
}
/** æ›¿æ¢æ–‡æœ¬é‡Œçš„全部绑定表达式;未绑定的路径渲染为空 */
export function resolveText(template: string, context: unknown): string {
  return resolveTextWithReport(template, context, () => {});
}
/** åˆ¤æ–­ä¸€æ®µæ–‡æœ¬æ˜¯å¦æ˜¯çº¯ç»‘定表达式({{path}} å•占一段),是则返回路径 */
export function asSingleBinding(template: string): string | undefined {
  const trimmed = template.trim();
  const match = /^\{\{\s*([^{}]+?)\s*\}\}$/.exec(trimmed);
  return match?.[1]?.trim();
}
src/components/quality/engine/context.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
/**
 * æŠ¥å‘Šä¸Šä¸‹æ–‡ï¼ˆContext)
 * <p>
 * æ¨¡æ¿é‡Œçš„ {{path}} å…¨éƒ¨ç›¸å¯¹è¿™ä¸ªç»“构解析:report æ˜¯æŠ¥å‘Šçº§å­—段,inspectionItems æ˜¯æ£€éªŒé¡¹æ•°ç»„。
 * ä¸šåŠ¡å•æ® â†’ ä¸Šä¸‹æ–‡çš„æ˜ å°„由上下文构建器负责,模板本身只认这个标准结构,
 * æ¢è¡Œä¸šã€æ¢å•据来源都不需要改模板与组件。
 */
/** åˆ¤å®šç»“果取值,组件与规则引擎共用 */
export const QUALITY_RESULT = {
  PASS: 'PASS',
  FAIL: 'FAIL',
} as const;
export type QualityResult = (typeof QUALITY_RESULT)[keyof typeof QUALITY_RESULT];
/** åˆ¤å®šç»“果对应的展示文字 */
export const QUALITY_RESULT_TEXT: Record<QualityResult, string> = {
  PASS: '合格',
  FAIL: '不合格',
};
/** æŠ¥å‘Šçº§å­—段 */
export interface ReportContextReport {
  reportNo: string;
  reportName: string;
  sampleNo: string;
  productCode: string;
  productName: string;
  spec: string;
  batchNo: string;
  workOrderNo: string;
  inspectType: string;
  inspector: string;
  inspectDate: string;
  department: string;
  customerName: string;
  supplierName: string;
  /** PASS / FAIL */
  result: string;
  /** åˆæ ¼ / ä¸åˆæ ¼ / å¾…判定 */
  resultText: string;
  conclusion: string;
  passRate: string;
  total: number;
  passCount: number;
  failCount: number;
  /**
   * è´¨æ£€å•上的原判定(数字字符串 "1"~"4",来自 MES å•据)。
   * <p>
   * ä¸Žå¼•擎算出的 {@link result} å¹¶å­˜ã€äº’不覆盖:单据判定是人工拍的板
   * ï¼ˆåˆæ ¼ / ç‰¹é‡‡ / ä¸åˆæ ¼é€€è´§ / ä¸åˆæ ¼æŠ¥åºŸï¼Œå››æ€ï¼‰ï¼Œè€Œå¼•擎只有 PASS/FAIL/待判定 ä¸‰æ€è£…不下它。
   * ä»…「由质检单出件」时有值,通用出件的上下文里恒为空串。
   */
  qcResult: string;
  /** {@link qcResult} çš„中文,如「合格」「特采」「不合格退货」「不合格报废」 */
  qcResultText: string;
}
/**
 * æ£€éªŒé¡¹çš„分组信息。
 * <p>
 * æ£€éªŒå•里的父子层级(分组头 + ç»„内子项)只有两项数据能表达:组名合并几行、
 * å­é¡¹è¡Œè¦ä¸è¦è®©å‡ºåˆå¹¶æ ¼ã€‚渲染期无法自己算出这两件事 â€”— ç»‘定的路径在数组上继续取属性
 * ä¼šæŒ‰å…ƒç´  pluck,`group.children.length` å–不到值 â€”— æ‰€ä»¥å¿…须由上下文构建器逐项下发。
 * <p>
 * è¿™å‡ é¡¹åœ¨**每一个**检验项上都必须有值(哪怕是空串):绑定的字段缺席时属性会被跳过,
 * åˆå¹¶æ ¼åè€Œä¼šéœ²å‡ºæ¥ã€‚没有分组时整个 {@link ReportContextItemGroup} å¯ä»¥ä¸ç»™ã€‚
 */
export interface ReportContextItemGroup {
  /** ã€Œå­é¡¹ã€åˆ—文字:组内子项 = è‡ªå·±çš„名字,组头行与独立项 = ç©ºä¸² */
  childName: string;
  /** ç»„名格的纵向合并行数:组头行 = 1 + ç»„内子项数,组内子项行与独立项 = 1 */
  span: number;
  /**
   * ç»„名格的隐藏值:组内子项行 = 'hidden'(该格让位给上方合并格),组头行与独立项 = ç©ºä¸²ã€‚
   * <p>
   * å­—符串而不是布尔:`hidden` å±žæ€§é ã€Œåœ¨ä¸åœ¨ã€èµ·ä½œç”¨ï¼ˆ`hidden="false"` ç…§æ ·éšè—ï¼‰ï¼Œ
   * åªæœ‰ç©ºä¸²èƒ½è¡¨è¾¾ã€Œä¸è¾“出该属性 = è¿™ä¸€æ ¼å¯è§ã€ã€‚
   */
  hidden: string;
}
/** å•个检验项 */
export interface ReportContextItem {
  index: number;
  itemCode: string;
  itemName: string;
  standardValue: string;
  actualValue: string;
  unit: string;
  /**
   * ã€Œæ£€æµ‹è¦æ±‚」文本:分组父项 = æŒ‡æ ‡ä¸Šçš„计算公式,其余 = å•据上的标准要求。
   * <p>
   * ä¸Ž {@link standardValue} å¹¶å­˜è€Œä¸åˆå¹¶ï¼Œæ˜¯å› ä¸ºä¸¤è€…的来源不同:标准要求是检验单行上的值,
   * å…¬å¼æ˜¯æ£€éªŒé¡¹ä¸»æ•°æ®ä¸Šçš„值;分组父子项压根没有规格,只有公式。
   */
  requirement?: string;
  /**
   * ã€Œæ£€æµ‹æ–¹æ³•」文本,来自单据行(如 GB 5009.3-2016)。
   * <p>
   * ä¸Ž {@link requirement} å¹¶å­˜è€Œä¸åˆå¹¶ï¼šæ£€æµ‹è¦æ±‚说的是「这一项要达到什么」,
   * æ£€æµ‹æ–¹æ³•说的是「用什么办法测的」,原件里常各占一列。
   */
  checkMethod?: string;
  /** åˆ†ç»„信息,仅在检验单存在分组结构时逐项下发 */
  group?: ReportContextItemGroup;
  /** è§„格上限,规则引擎做区间判定时使用 */
  upperLimit?: number;
  /** è§„格下限 */
  lowerLimit?: number;
  /** PASS / FAIL */
  result: string;
  /**
   * åˆæ ¼ / ä¸åˆæ ¼ / å¾…判定 / æ— åˆ¤å®šè§„则。
   * <p>
   * åŽä¸¤è€…都不是结论:待判定是规则跑挂了(仍计入合格率分母),无判定规则是这项没有判定依据(不计入)。
   */
  resultText: string;
  remark: string;
}
/** æŠ¥å‘Šä¸Šä¸‹æ–‡ */
export interface ReportContext {
  report: ReportContextReport;
  inspectionItems: ReportContextItem[];
}
function emptyReport(): ReportContextReport {
  return {
    reportNo: '',
    reportName: '',
    sampleNo: '',
    productCode: '',
    productName: '',
    spec: '',
    batchNo: '',
    workOrderNo: '',
    inspectType: '',
    inspector: '',
    inspectDate: '',
    department: '',
    customerName: '',
    supplierName: '',
    result: '',
    resultText: '',
    conclusion: '',
    passRate: '',
    total: 0,
    passCount: 0,
    failCount: 0,
    qcResult: '',
    qcResultText: '',
  };
}
/** ç©ºç™½ä¸Šä¸‹æ–‡ï¼Œå­—段齐全但没有值,便于逐项填充 */
export function createEmptyContext(): ReportContext {
  return { report: emptyReport(), inspectionItems: [] };
}
/** ç”¨ä¼ å…¥å€¼è¦†ç›–空白上下文,未传的字段保留空值 */
export function createContext(input?: Partial<ReportContext>): ReportContext {
  const base = createEmptyContext();
  return {
    report: { ...base.report, ...input?.report },
    inspectionItems: input?.inspectionItems ?? base.inspectionItems,
  };
}
src/components/quality/engine/page.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
/**
 * çº¸å¼ å°ºå¯¸
 * <p>
 * ä¸ŽåŽç«¯ QcReportEnums.PageSizeEnum å–值一一对应。
 * è®¾è®¡å™¨æ¢ç®—画布像素、渲染引擎换算 @page éƒ½ç”¨è¿™ä¸€ä»½ï¼Œé¿å…ä¸¤å¤„尺寸各写一遍后对不上。
 */
/** çº¸å¼ å°ºå¯¸ï¼ˆæ¯«ç±³ï¼‰ï¼Œæœªåˆ—出的尺寸回退到默认纸张 */
export const PAGE_SIZE_MM: Record<string, { height: number; width: number }> = {
  A3: { width: 297, height: 420 },
  A4: { width: 210, height: 297 },
  A5: { width: 148, height: 210 },
  Letter: { width: 215.9, height: 279.4 },
};
/** é»˜è®¤çº¸å¼  */
export const DEFAULT_PAGE_SIZE = 'A4';
/** é»˜è®¤é¡µè¾¹è·ï¼ˆæ¯«ç±³ï¼‰ */
export const DEFAULT_MARGIN_MM = 10;
/** çº¸å¼ é…ç½®ï¼Œç»“构等价于 Schema.page,不依赖具体接口类型定义 */
export interface PageSetting {
  size?: string;
  orientation?: string;
  margin?: { bottom?: number; left?: number; right?: number; top?: number };
}
export interface ResolvedPage {
  heightMm: number;
  marginMm: { bottom: number; left: number; right: number; top: number };
  widthMm: number;
}
/** è§£æžçº¸å¼ å°ºå¯¸ä¸Žé¡µè¾¹è·ï¼ˆæ¯«ç±³ï¼‰ï¼Œæ¨ªå‘时翻转宽高 */
export function resolvePageMm(page?: PageSetting): ResolvedPage {
  const base = PAGE_SIZE_MM[page?.size ?? DEFAULT_PAGE_SIZE] ?? PAGE_SIZE_MM[DEFAULT_PAGE_SIZE]!;
  const isLandscape = (page?.orientation ?? 'portrait') === 'landscape';
  const margin = page?.margin;
  return {
    widthMm: isLandscape ? base.height : base.width,
    heightMm: isLandscape ? base.width : base.height,
    marginMm: {
      top: margin?.top ?? DEFAULT_MARGIN_MM,
      right: margin?.right ?? DEFAULT_MARGIN_MM,
      bottom: margin?.bottom ?? DEFAULT_MARGIN_MM,
      left: margin?.left ?? DEFAULT_MARGIN_MM,
    },
  };
}
src/components/quality/engine/path.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
/**
 * ä¸Šä¸‹æ–‡å–值
 * <p>
 * åªåšç™½åå•式的属性导航,不解析、不执行任何代码:
 * æ”¯æŒ a.b.c ä¸Ž a.b[0].c ä¸¤ç§å†™æ³•,路径在数组上继续取属性时按元素逐个取值(pluck)。
 */
/** è·¯å¾„片段:普通属性名或数组下标 */
const TOKEN_PATTERN = /[^.[\]]+/g;
/** ç¦æ­¢è®¿é—®çš„属性名,避免顺着原型链读到构造器 */
const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/** æŠŠè·¯å¾„拆成片段:inspectionItems[0].actualValue â†’ ['inspectionItems','0','actualValue'] */
export function parsePath(path: string): string[] {
  return path.match(TOKEN_PATTERN) ?? [];
}
function toIndex(token: string): number | undefined {
  if (!/^\d+$/.test(token)) {
    return undefined;
  }
  return Number(token);
}
function readTokens(source: unknown, tokens: string[], start: number): unknown {
  let current = source;
  for (let index = start; index < tokens.length; index += 1) {
    const token = tokens[index]!;
    if (current === null || current === undefined) {
      return undefined;
    }
    if (Array.isArray(current)) {
      const arrayIndex = toIndex(token);
      if (arrayIndex === undefined) {
        // æ•°ç»„上继续取属性:逐元素取值,得到同长度的数组
        return current.map((item) => readTokens(item, tokens, index));
      }
      current = current[arrayIndex];
      continue;
    }
    if (typeof current === 'object') {
      if (BLOCKED_KEYS.has(token)) {
        return undefined;
      }
      current = (current as Record<string, unknown>)[token];
      continue;
    }
    // æ ‡é‡åŽé¢è¿˜æœ‰è·¯å¾„片段,说明路径写错了
    return undefined;
  }
  return current;
}
/** æŒ‰è·¯å¾„取值,取不到返回 undefined */
export function readPath(source: unknown, path: string): unknown {
  return readTokens(source, parsePath(path), 0);
}
/** å±•示用格式化:空值渲染为空字符串,布尔值转中文,日期只保留到秒 */
export function formatValue(value: unknown): string {
  if (value === null || value === undefined) {
    return '';
  }
  if (typeof value === 'boolean') {
    return value ? '是' : '否';
  }
  if (value instanceof Date) {
    const pad = (input: number) => String(input).padStart(2, '0');
    return (
      `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ` +
      `${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
    );
  }
  if (Array.isArray(value)) {
    return value.map(formatValue).join('、');
  }
  return String(value);
}
/** æ•°å€¼åŒ–:非数值返回 NaN,由调用方决定如何处理 */
export function toNumber(value: unknown): number {
  if (typeof value === 'number') {
    return value;
  }
  if (typeof value === 'boolean') {
    return value ? 1 : 0;
  }
  if (typeof value === 'string' && value.trim() !== '') {
    return Number(value);
  }
  return Number.NaN;
}
/** æŠŠä»»æ„å–值收敛成数值数组,供统计函数使用 */
export function toNumberArray(value: unknown): number[] {
  if (Array.isArray(value)) {
    return value
      .flat(Number.POSITIVE_INFINITY)
      .map((item) => toNumber(item))
      .filter((item) => Number.isFinite(item));
  }
  const single = toNumber(value);
  return Number.isFinite(single) ? [single] : [];
}
src/components/quality/engine/render.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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');
}
src/components/quality/engine/report-evaluator.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,277 @@
/**
 * æŠ¥å‘Šåˆ¤å®š
 * <p>
 * åœ¨æ¸²æŸ“前把检验项算成 PASS/FAIL,并汇总出报告结论与合格率。
 * åˆ¤å®šåªå‘生在这里,组件与模板都不做计算,保证同一份数据在任何模板下判定一致。
 */
import type { QualityResult, ReportContext, ReportContextItem } from './context';
import type { RuleNode } from './rule-engine';
import { QUALITY_RESULT, QUALITY_RESULT_TEXT } from './context';
import { toNumber } from './path';
import {
  evaluateConditionNode,
  findUnknownFunction,
  listAllFunctionNames,
  parseRule,
  RuleSyntaxError,
} from './rule-engine';
/** è§„则作用域:item é€é¡¹åˆ¤å®šï¼Œreport æŠ¥å‘Šçº§æ±‡æ€» */
export type QualityRuleScope = 'item' | 'report';
export interface QualityRuleDefinition {
  /** è§„则标识,用于出错时定位 */
  id?: string;
  /** è§„则名称 */
  name?: string;
  scope: QualityRuleScope;
  /** åˆ¤å®šè¡¨è¾¾å¼ï¼Œæ±‚值为真视为合格 */
  expression: string;
  /** è¯¥è§„则是否参与判定,默认 true */
  enabled?: boolean;
}
/** å¾…判定标记:规则本身出错时用,避免把不确定当成合格 */
const PENDING_TEXT = '待判定';
/**
 * æ— åˆ¤å®šè§„则标记:既没有规则、也没有规格上下限。
 * <p>
 * å’Œã€Œå¾…判定」刻意分开:待判定是「本该判、但规则跑挂了」,仍留在合格率分母里;
 * æ— åˆ¤å®šè§„则是「这项根本没有判定依据」,不计入合格率分母、也不参与报告结论。
 */
const UNDECIDABLE_TEXT = '无判定规则';
/**
 * æ ¡éªŒå…¥å‚:只用到这三个字段,且都允许缺省。
 * <p>
 * ä¸å¤ç”¨ {@link QualityRuleDefinition}——那里的 expression æ˜¯å¿…填的,
 * è€Œè®¾è®¡å™¨é‡Œæ­£åœ¨ç¼–辑的规则、以及历史数据里的规则项都可能是空的,
 * æ ¡éªŒå‡½æ•°æ°æ°è¦èƒ½æŽ¥ä½è¿™ç§ã€Œè¿˜æ²¡å†™å®Œã€çš„入参。
 */
export interface RuleValidationInput {
  id?: string;
  name?: string;
  expression?: string;
}
/**
 * ä¿å­˜å‰æ ¡éªŒè§„则文本:语法与函数白名单。
 * <p>
 * ä¸¤å—都要拦。只拦语法的话,{@code FOO(1)} è¿™ç§è°ƒç”¨ä¼šä¸€è·¯å­˜è¿›åº“里,
 * ä¹‹åŽæ¯å¼ æŠ¥å‘Šéƒ½å¸¦ä¸€æ¡ã€Œæ— æ³•执行」,而用户在设计器里看不出哪里不对。
 * è¿”回的问题清单与后端 {@code QualityReportEngine.validateRules} åŒå£å¾„。
 */
export function validateRule(rule: RuleValidationInput): string[] {
  const errors: string[] = [];
  const label = rule.name?.trim() || rule.id || '未命名规则';
  if (!rule.expression?.trim()) {
    errors.push(`规则「${label}」的表达式为空`);
    return errors;
  }
  let node: RuleNode;
  try {
    node = parseRule(rule.expression);
  } catch (error) {
    if (error instanceof RuleSyntaxError) {
      errors.push(`规则「${label}」语法错误:${error.message}(第 ${error.position + 1} ä¸ªå­—符)`);
    } else {
      errors.push(`规则「${label}」无法解析:${(error as Error).message}`);
    }
    return errors;
  }
  const unknown = findUnknownFunction(node);
  if (unknown) {
    errors.push(
      `规则「${label}」调用了不支持的函数 ${unknown},可用函数:${listAllFunctionNames().join('、')}`,
    );
  }
  return errors;
}
/** ç”¨è§„格上下限判定单项:实测值不在区间内即不合格 */
function evaluateByLimit(item: ReportContextItem): boolean | undefined {
  const actual = toNumber(item.actualValue);
  if (!Number.isFinite(actual)) {
    return undefined;
  }
  const hasUpper = typeof item.upperLimit === 'number' && Number.isFinite(item.upperLimit);
  const hasLower = typeof item.lowerLimit === 'number' && Number.isFinite(item.lowerLimit);
  if (!hasUpper && !hasLower) {
    return undefined;
  }
  if (hasUpper && actual > item.upperLimit!) {
    return false;
  }
  if (hasLower && actual < item.lowerLimit!) {
    return false;
  }
  return true;
}
function normalizeExisting(result: string): QualityResult | undefined {
  const text = result?.trim().toUpperCase();
  if (text === QUALITY_RESULT.PASS) {
    return QUALITY_RESULT.PASS;
  }
  if (text === QUALITY_RESULT.FAIL) {
    return QUALITY_RESULT.FAIL;
  }
  return undefined;
}
function applyResult(item: ReportContextItem, result: QualityResult | undefined): void {
  if (!result) {
    item.result = '';
    item.resultText = PENDING_TEXT;
    return;
  }
  item.result = result;
  item.resultText = QUALITY_RESULT_TEXT[result];
}
/**
 * æ²¡æœ‰åˆ¤å®šä¾æ®ï¼šç½®ä¸ºã€Œæ— åˆ¤å®šè§„则」。
 * <p>
 * æŠ¥å‘Šé‡Œå¸¸æœ‰ã€Œè¯•样质量 m」「称量瓶 m0」这类只供公式取数的过程参数,它们没有规格也就无从判定;
 * è‹¥ç…§å¸¸ç®—进分母,一份本来全合格的报告会因为几个过程参数被压成「待判定」。
 */
function applyUndecidable(item: ReportContextItem): void {
  item.result = '';
  item.resultText = UNDECIDABLE_TEXT;
}
function formatPassRate(passCount: number, total: number): string {
  if (total === 0) {
    return '';
  }
  return `${((passCount / total) * 100).toFixed(2)}%`;
}
export interface EvaluationOutcome {
  context: ReportContext;
  /** è§„则的问题清单,渲染时应在报告上显式暴露,不能悄悄吞掉 */
  errors: string[];
}
/**
 * è®¡ç®—检验项判定与报告汇总。
 * <p>
 * å•个检验项的判定优先级:item ä½œç”¨åŸŸè§„则 â†’ è§„格上下限 â†’ æ•°æ®è‡ªå¸¦ç»“果。
 * æŠ¥å‘Šçº§åˆ¤å®šé»˜è®¤å–「全部检验项合格」,有 report ä½œç”¨åŸŸè§„则时以规则为准。
 */
export function evaluateReport(
  context: ReportContext,
  rules: QualityRuleDefinition[] = [],
): EvaluationOutcome {
  const errors: string[] = [];
  const itemRules = rules.filter((rule) => rule.scope === 'item' && rule.enabled !== false);
  const reportRules = rules.filter((rule) => rule.scope === 'report' && rule.enabled !== false);
  const items = context.inspectionItems.map((item) => ({ ...item }));
  /** æ— åˆ¤å®šä¾æ®çš„项数,只用来把这类项从合格率分母与报告结论里摘出去 */
  let undecidableCount = 0;
  items.forEach((item, index) => {
    if (itemRules.length > 0) {
      let passed = true;
      let failed = false;
      itemRules.forEach((rule) => {
        // é€é¡¹è§„则在「单项上下文」里求值,item æŒ‡å‘当前行
        const scoped = { ...context, report: context.report, item, index: index + 1 };
        try {
          if (!evaluateConditionNode(compiledRule(rule.expression), scoped)) {
            passed = false;
          }
        } catch (error) {
          failed = true;
          errors.push(
            `第 ${index + 1} é¡¹ã€Œ${item.itemName}」的规则「${rule.name ?? rule.id ?? ''}」无法执行:${(error as Error).message}`,
          );
        }
      });
      applyResult(item, failed ? undefined : passed ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
      return;
    }
    const byLimit = evaluateByLimit(item);
    if (byLimit !== undefined) {
      applyResult(item, byLimit ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL);
      return;
    }
    const existing = normalizeExisting(item.result);
    if (!existing) {
      // æ—¢æ²¡æœ‰è§„则、也没有判定依据:如实标出来,不要默认合格
      applyUndecidable(item);
      undecidableCount++;
      errors.push(
        `第 ${index + 1} é¡¹ã€Œ${item.itemName || '未命名'}」没有规格上下限也没有判定规则,无法判定`,
      );
      return;
    }
    applyResult(item, existing);
  });
  const passCount = items.filter((item) => item.result === QUALITY_RESULT.PASS).length;
  const failCount = items.filter((item) => item.result === QUALITY_RESULT.FAIL).length;
  const total = items.length;
  /** åˆæ ¼çŽ‡çš„åˆ†æ¯åªç®—ã€Œèƒ½åˆ¤å®šã€çš„é¡¹ï¼Œæ— åˆ¤å®šè§„åˆ™çš„é¡¹æ—¢ä¸åŠ åˆ†ä¹Ÿä¸å‡åˆ† */
  const decidableCount = total - undecidableCount;
  let reportResult: QualityResult | undefined;
  if (reportRules.length > 0) {
    let passed = true;
    let failed = false;
    reportRules.forEach((rule) => {
      const scoped = { ...context, inspectionItems: items, index: 0 };
      try {
        if (!evaluateConditionNode(compiledRule(rule.expression), scoped)) {
          passed = false;
        }
      } catch (error) {
        failed = true;
        errors.push(`报告级规则「${rule.name ?? rule.id ?? ''}」无法执行:${(error as Error).message}`);
      }
    });
    reportResult = failed ? undefined : passed ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL;
  } else if (decidableCount > 0) {
    // åªæœ‰ã€Œå…¨éƒ¨å¯åˆ¤å®šé¡¹éƒ½åˆæ ¼ã€æ‰ç»™åˆæ ¼ç»“论。无判定规则的项不参与,它们没有对错可言。
    // å­˜åœ¨å¾…判定项(规则跑挂)时不给结论:没验过不能说合格,没证据也不能说不合格,
    // å¦åˆ™ä¼šå‡ºçŽ°ã€Œç»“è®ºåˆæ ¼ã€åˆæ ¼çŽ‡ 0%」这种自相矛盾的报告。
    if (failCount > 0) {
      reportResult = QUALITY_RESULT.FAIL;
    } else if (passCount === decidableCount) {
      reportResult = QUALITY_RESULT.PASS;
    }
  }
  const report = {
    ...context.report,
    total,
    passCount,
    failCount,
    passRate: formatPassRate(passCount, decidableCount),
    result: reportResult ?? '',
    resultText: reportResult ? QUALITY_RESULT_TEXT[reportResult] : PENDING_TEXT,
    conclusion: reportResult ? QUALITY_RESULT_TEXT[reportResult] : PENDING_TEXT,
  };
  return { context: { report, inspectionItems: items }, errors };
}
/** æ ¡éªŒè¿‡çš„规则只解析一次,多行复用时直接对语法树求值 */
const ruleCache = new Map<string, RuleNode>();
function compiledRule(expression: string): RuleNode {
  const cached = ruleCache.get(expression);
  if (cached) {
    return cached;
  }
  const node = parseRule(expression);
  ruleCache.set(expression, node);
  return node;
}
src/components/quality/engine/rule-engine.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,711 @@
/**
 * åˆ¤å®šè§„则引擎
 * <p>
 * é€’归下降解析成 AST åŽè‡ªå·±æ±‚值,全程不碰 eval / new Function:
 * è§„则文本来自用户,一旦能被当作代码执行就是注入口子。
 * æ”¯æŒçš„语法见 docs/智能质检报告平台-方案设计.md Â§13。
 */
import { parsePath, readPath, toNumber, toNumberArray } from './path';
/** è§„则文本存在语法错误 */
export class RuleSyntaxError extends Error {
  constructor(
    message: string,
    readonly position: number,
  ) {
    super(message);
    this.name = 'RuleSyntaxError';
  }
}
/** è§„则运行期错误(函数不存在、参数类型不对等) */
export class RuleRuntimeError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'RuleRuntimeError';
  }
}
type TokenType = 'comma' | 'eof' | 'lparen' | 'number' | 'operator' | 'rparen' | 'string' | 'word';
interface Token {
  type: TokenType;
  value: string;
  position: number;
}
export type RuleNode =
  | { type: 'literal'; value: boolean | null | number | string }
  | { type: 'path'; path: string }
  | { type: 'call'; name: string; args: RuleNode[] }
  | { type: 'unary'; operator: string; operand: RuleNode }
  | { type: 'binary'; operator: string; left: RuleNode; right: RuleNode }
  | { type: 'between'; value: RuleNode; lower: RuleNode; upper: RuleNode; negated: boolean }
  | { type: 'in'; value: RuleNode; items: RuleNode[]; negated: boolean };
const KEYWORDS = new Set([
  'AND',
  'BETWEEN',
  'FALSE',
  'IN',
  'NOT',
  'NOT_IN',
  'NULL',
  'OR',
  'TRUE',
]);
const IDENT_START = /[A-Za-z_$]/;
const IDENT_PART = /[\w$]/;
const TWO_CHAR_OPERATORS = new Set(['<=', '>=', '!=', '<>', '==']);
/* ------------------------------ è¯æ³•分析 ------------------------------ */
function tokenize(source: string): Token[] {
  const tokens: Token[] = [];
  const length = source.length;
  let index = 0;
  while (index < length) {
    const char = source[index]!;
    if (/\s/.test(char)) {
      index += 1;
      continue;
    }
    if (char === "'" || char === '"') {
      const quote = char;
      let value = '';
      let cursor = index + 1;
      while (cursor < length && source[cursor] !== quote) {
        if (source[cursor] === '\\' && cursor + 1 < length) {
          value += source[cursor + 1];
          cursor += 2;
          continue;
        }
        value += source[cursor];
        cursor += 1;
      }
      if (cursor >= length) {
        throw new RuleSyntaxError('字符串缺少结尾引号', index);
      }
      tokens.push({ type: 'string', value, position: index });
      index = cursor + 1;
      continue;
    }
    if (/[0-9]/.test(char)) {
      let cursor = index;
      while (cursor < length && /[0-9.]/.test(source[cursor]!)) {
        cursor += 1;
      }
      const text = source.slice(index, cursor);
      if (!/^\d+(?:\.\d+)?$/.test(text)) {
        throw new RuleSyntaxError(`数字格式不正确:${text}`, index);
      }
      tokens.push({ type: 'number', value: text, position: index });
      index = cursor;
      continue;
    }
    if (IDENT_START.test(char)) {
      let cursor = index;
      while (cursor < length && IDENT_PART.test(source[cursor]!)) {
        cursor += 1;
      }
      let value = source.slice(index, cursor);
      // è·¯å¾„尾巴:.属性 ä¸Ž [下标]
      for (;;) {
        const next = source[cursor];
        if (next === '.' && IDENT_START.test(source[cursor + 1] ?? '')) {
          let end = cursor + 1;
          while (end < length && IDENT_PART.test(source[end]!)) {
            end += 1;
          }
          value += source.slice(cursor, end);
          cursor = end;
          continue;
        }
        if (next === '[') {
          const close = source.indexOf(']', cursor);
          if (close === -1) {
            throw new RuleSyntaxError('数组下标缺少 ]', cursor);
          }
          const inner = source.slice(cursor + 1, close).trim();
          if (!/^\d+$/.test(inner)) {
            throw new RuleSyntaxError(`数组下标只能是数字:${inner}`, cursor);
          }
          value += `[${inner}]`;
          cursor = close + 1;
          continue;
        }
        break;
      }
      tokens.push({ type: 'word', value, position: index });
      index = cursor;
      continue;
    }
    const two = source.slice(index, index + 2);
    if (TWO_CHAR_OPERATORS.has(two)) {
      tokens.push({ type: 'operator', value: two, position: index });
      index += 2;
      continue;
    }
    if (char === '(' || char === ')') {
      tokens.push({ type: char === '(' ? 'lparen' : 'rparen', value: char, position: index });
      index += 1;
      continue;
    }
    if (char === ',') {
      tokens.push({ type: 'comma', value: char, position: index });
      index += 1;
      continue;
    }
    if ('><=+-*/!'.includes(char)) {
      tokens.push({ type: 'operator', value: char, position: index });
      index += 1;
      continue;
    }
    throw new RuleSyntaxError(`无法识别的字符「${char}」`, index);
  }
  tokens.push({ type: 'eof', value: '', position: length });
  return tokens;
}
/* ------------------------------ è¯­æ³•分析 ------------------------------ */
class Parser {
  private cursor = 0;
  constructor(private readonly tokens: Token[]) {}
  parse(): RuleNode {
    const node = this.parseOr();
    const token = this.peek();
    if (token.type !== 'eof') {
      throw new RuleSyntaxError(`多余的内容:${token.value}`, token.position);
    }
    return node;
  }
  private parseOr(): RuleNode {
    let left = this.parseAnd();
    while (this.matchWord('OR')) {
      left = { type: 'binary', operator: 'OR', left, right: this.parseAnd() };
    }
    return left;
  }
  private parseAnd(): RuleNode {
    let left = this.parseComparison();
    while (this.matchWord('AND')) {
      left = { type: 'binary', operator: 'AND', left, right: this.parseComparison() };
    }
    return left;
  }
  private parseComparison(): RuleNode {
    const left = this.parseAdditive();
    if (this.matchWord('BETWEEN')) {
      const lower = this.parseAdditive();
      if (!this.matchWord('AND')) {
        throw new RuleSyntaxError('BETWEEN ç¼ºå°‘ AND ä¸Žä¸Šç•Œ', this.peek().position);
      }
      const upper = this.parseAdditive();
      return { type: 'between', value: left, lower, upper, negated: false };
    }
    if (this.matchWord('NOT')) {
      if (this.matchWord('IN') || this.matchWord('NOT_IN')) {
        return { type: 'in', value: left, items: this.parseList(), negated: true };
      }
      throw new RuleSyntaxError('NOT åªèƒ½ç”¨äºŽ NOT IN', this.peek().position);
    }
    if (this.matchWord('IN')) {
      return { type: 'in', value: left, items: this.parseList(), negated: false };
    }
    const token = this.peek();
    if (token.type === 'operator' && ['<', '<=', '=', '==', '!=', '<>', '>', '>='].includes(token.value)) {
      this.cursor += 1;
      const operator = token.value === '==' ? '=' : token.value === '<>' ? '!=' : token.value;
      return { type: 'binary', operator, left, right: this.parseAdditive() };
    }
    return left;
  }
  private parseList(): RuleNode[] {
    this.expect('lparen');
    const items: RuleNode[] = [];
    if (this.peek().type !== 'rparen') {
      items.push(this.parseOr());
      while (this.peek().type === 'comma') {
        this.cursor += 1;
        items.push(this.parseOr());
      }
    }
    this.expect('rparen');
    return items;
  }
  private parseAdditive(): RuleNode {
    let left = this.parseMultiplicative();
    for (;;) {
      const token = this.peek();
      if (token.type !== 'operator' || (token.value !== '+' && token.value !== '-')) {
        return left;
      }
      this.cursor += 1;
      left = { type: 'binary', operator: token.value, left, right: this.parseMultiplicative() };
    }
  }
  private parseMultiplicative(): RuleNode {
    let left = this.parseUnary();
    for (;;) {
      const token = this.peek();
      if (token.type !== 'operator' || (token.value !== '*' && token.value !== '/')) {
        return left;
      }
      this.cursor += 1;
      left = { type: 'binary', operator: token.value, left, right: this.parseUnary() };
    }
  }
  private parseUnary(): RuleNode {
    const token = this.peek();
    if (token.type === 'operator' && (token.value === '-' || token.value === '!')) {
      this.cursor += 1;
      return { type: 'unary', operator: token.value, operand: this.parseUnary() };
    }
    return this.parsePrimary();
  }
  private parsePrimary(): RuleNode {
    const token = this.peek();
    if (token.type === 'number') {
      this.cursor += 1;
      return { type: 'literal', value: Number(token.value) };
    }
    if (token.type === 'string') {
      this.cursor += 1;
      return { type: 'literal', value: token.value };
    }
    if (token.type === 'lparen') {
      this.cursor += 1;
      const node = this.parseOr();
      this.expect('rparen');
      return node;
    }
    if (token.type === 'word') {
      this.cursor += 1;
      const upper = token.value.toUpperCase();
      if (upper === 'TRUE') {
        return { type: 'literal', value: true };
      }
      if (upper === 'FALSE') {
        return { type: 'literal', value: false };
      }
      if (upper === 'NULL') {
        return { type: 'literal', value: null };
      }
      if (this.peek().type === 'lparen') {
        return { type: 'call', name: upper, args: this.parseList() };
      }
      if (KEYWORDS.has(upper)) {
        throw new RuleSyntaxError(`关键字 ${upper} çš„位置不正确`, token.position);
      }
      return { type: 'path', path: token.value };
    }
    throw new RuleSyntaxError(
      token.type === 'eof' ? '表达式不完整' : `无法解析的内容:${token.value}`,
      token.position,
    );
  }
  private peek(): Token {
    return this.tokens[this.cursor]!;
  }
  private matchWord(word: string): boolean {
    const token = this.peek();
    if (token.type === 'word' && token.value.toUpperCase() === word) {
      this.cursor += 1;
      return true;
    }
    return false;
  }
  private expect(type: TokenType): void {
    const token = this.peek();
    if (token.type !== type) {
      throw new RuleSyntaxError(`应该是 ${type},实际是「${token.value}」`, token.position);
    }
    this.cursor += 1;
  }
}
/** è§£æžè§„则文本,语法错误会抛 RuleSyntaxError */
export function parseRule(expression: string): RuleNode {
  const trimmed = expression.trim();
  if (!trimmed) {
    throw new RuleSyntaxError('规则表达式为空', 0);
  }
  return new Parser(tokenize(trimmed)).parse();
}
/* ------------------------------ æ±‚值 ------------------------------ */
function isBlank(value: unknown): boolean {
  return value === null || value === undefined || value === '';
}
function truthy(value: unknown): boolean {
  if (Array.isArray(value)) {
    return value.length > 0;
  }
  if (isBlank(value)) {
    return false;
  }
  if (typeof value === 'boolean') {
    return value;
  }
  const numeric = toNumber(value);
  if (Number.isFinite(numeric)) {
    return numeric !== 0;
  }
  return true;
}
/** ä¸¤ä¾§éƒ½èƒ½å½“数字时按数值比较,否则按字符串比较 */
function compare(left: unknown, right: unknown): number {
  const leftNumber = toNumber(left);
  const rightNumber = toNumber(right);
  if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
    return leftNumber === rightNumber ? 0 : leftNumber > rightNumber ? 1 : -1;
  }
  const leftText = left === null || left === undefined ? '' : String(left);
  const rightText = right === null || right === undefined ? '' : String(right);
  return leftText === rightText ? 0 : leftText > rightText ? 1 : -1;
}
/** åˆ¤å®šç»“果类取值统一成 PASS / FAIL:兼容中文、布尔与数字 1/0 */
const PASS_TOKENS = new Set(['PASS', 'TRUE', '合格', 'OK', '1', '是']);
const FAIL_TOKENS = new Set(['FAIL', 'FALSE', '不合格', 'NG', '0', '否']);
function normalizeResultToken(value: unknown): string {
  if (typeof value === 'boolean') {
    return value ? 'PASS' : 'FAIL';
  }
  const text = value === null || value === undefined ? '' : String(value).trim().toUpperCase();
  if (PASS_TOKENS.has(text)) {
    return 'PASS';
  }
  if (FAIL_TOKENS.has(text)) {
    return 'FAIL';
  }
  return '';
}
function mean(values: number[]): number {
  return values.reduce((sum, item) => sum + item, 0) / values.length;
}
/** æ ·æœ¬æ ‡å‡†å·®ï¼ˆn-1),SPC è®¡ç®— CP/CPK ç”¨è¿™ä¸ªå£å¾„ */
function standardDeviation(values: number[]): number {
  if (values.length < 2) {
    return Number.NaN;
  }
  const average = mean(values);
  const variance =
    values.reduce((sum, item) => sum + (item - average) ** 2, 0) / (values.length - 1);
  return Math.sqrt(variance);
}
function requireNumbers(name: string, value: unknown): number[] {
  const values = toNumberArray(value);
  if (values.length === 0) {
    throw new RuleRuntimeError(`函数 ${name} éœ€è¦æ•°å€¼åž‹å‚数,实际取到「${String(value)}」`);
  }
  return values;
}
function requireNumber(name: string, value: unknown): number {
  const numeric = toNumber(value);
  if (!Number.isFinite(numeric)) {
    throw new RuleRuntimeError(`函数 ${name} éœ€è¦æ•°å­—参数,实际取到「${String(value)}」`);
  }
  return numeric;
}
/** ç™½åå•函数表,规则文本只能调用这里面的函数 */
const FUNCTIONS: Record<string, (args: unknown[]) => unknown> = {
  ABS: (args) => Math.abs(requireNumber('ABS', args[0])),
  AVG: (args) => mean(requireNumbers('AVG', args[0])),
  COUNT: (args) => (Array.isArray(args[0]) ? args[0].length : toNumberArray(args[0]).length),
  /** è¿‡ç¨‹èƒ½åŠ›æŒ‡æ•° CP=(USL-LSL)/(6σ) */
  CP: (args) => {
    const values = requireNumbers('CP', args[0]);
    const deviation = standardDeviation(values);
    if (!Number.isFinite(deviation) || deviation === 0) {
      return Number.NaN;
    }
    return (requireNumber('CP', args[1]) - requireNumber('CP', args[2])) / (6 * deviation);
  },
  /** è¿‡ç¨‹èƒ½åŠ›æŒ‡æ•° CPK=min(USL-μ, Î¼-LSL)/(3σ) */
  CPK: (args) => {
    const values = requireNumbers('CPK', args[0]);
    const deviation = standardDeviation(values);
    if (!Number.isFinite(deviation) || deviation === 0) {
      return Number.NaN;
    }
    const upper = requireNumber('CPK', args[1]);
    const lower = requireNumber('CPK', args[2]);
    const average = mean(values);
    return Math.min(upper - average, average - lower) / (3 * deviation);
  },
  /** ä¸åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰ï¼Œå£å¾„ä¸Ž PASS_RATE ä¸€è‡´ */
  FAIL_RATE: (args) => {
    const values = Array.isArray(args[0]) ? args[0].flat(Number.POSITIVE_INFINITY) : [args[0]];
    if (values.length === 0) {
      return Number.NaN;
    }
    const failCount = values.filter((item) => normalizeResultToken(item) === 'FAIL').length;
    return Number(((failCount / values.length) * 100).toFixed(2));
  },
  MAX: (args) => Math.max(...requireNumbers('MAX', args[0])),
  MIN: (args) => Math.min(...requireNumbers('MIN', args[0])),
  /** åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼Œä¿ç•™ä¸¤ä½ï¼‰ */
  PASS_RATE: (args) => {
    const values = Array.isArray(args[0]) ? args[0].flat(Number.POSITIVE_INFINITY) : [args[0]];
    if (values.length === 0) {
      return Number.NaN;
    }
    const passCount = values.filter((item) => normalizeResultToken(item) === 'PASS').length;
    return Number(((passCount / values.length) * 100).toFixed(2));
  },
  ROUND: (args) => {
    const digits = args.length > 1 ? requireNumber('ROUND', args[1]) : 0;
    const factor = 10 ** digits;
    return Math.round(requireNumber('ROUND', args[0]) * factor) / factor;
  },
  STDDEV: (args) => standardDeviation(requireNumbers('STDDEV', args[0])),
  SUM: (args) => requireNumbers('SUM', args[0]).reduce((sum, item) => sum + item, 0),
};
/**
 * ç™½åå•里的全部函数名,供保存前校验与界面提示使用。
 * <p>
 * ä¸è¦å¦æŠ„一份到 UI ä¸Šï¼šç™½åå•是引擎的一部分,抄一份就会在加函数时漏改,
 * ç”¨æˆ·çœ‹åˆ°çš„可用函数列表与引擎实际支持的悄悄对不上。
 */
export function listAllFunctionNames(): string[] {
  return Object.keys(FUNCTIONS);
}
/** é™æ€æ£€æŸ¥è¡¨è¾¾å¼é‡Œè°ƒç”¨çš„函数是否在白名单内,用于保存前校验 */
export function listFunctionNames(node: RuleNode): string[] {
  if (node.type === 'call') {
    return [node.name, ...node.args.flatMap(listFunctionNames)];
  }
  if (node.type === 'unary') {
    return listFunctionNames(node.operand);
  }
  if (node.type === 'binary') {
    return [...listFunctionNames(node.left), ...listFunctionNames(node.right)];
  }
  if (node.type === 'between') {
    return [
      ...listFunctionNames(node.value),
      ...listFunctionNames(node.lower),
      ...listFunctionNames(node.upper),
    ];
  }
  if (node.type === 'in') {
    return [...listFunctionNames(node.value), ...node.items.flatMap(listFunctionNames)];
  }
  return [];
}
/** æœªçŸ¥å‡½æ•°åè¿”回 undefined,供调用方给出可读报错 */
export function findUnknownFunction(node: RuleNode): string | undefined {
  return listFunctionNames(node).find((name) => !FUNCTIONS[name]);
}
function evaluateNode(node: RuleNode, context: unknown): unknown {
  switch (node.type) {
    case 'literal': {
      return node.value;
    }
    case 'path': {
      return readPath(context, node.path);
    }
    case 'call': {
      const handler = FUNCTIONS[node.name];
      if (!handler) {
        throw new RuleRuntimeError(
          `不支持函数 ${node.name},可用函数:${Object.keys(FUNCTIONS).join('、')}`,
        );
      }
      return handler(node.args.map((arg) => evaluateNode(arg, context)));
    }
    case 'unary': {
      const value = evaluateNode(node.operand, context);
      return node.operator === '!' ? !truthy(value) : -toNumber(value);
    }
    case 'between': {
      const value = evaluateNode(node.value, context);
      const hit =
        compare(value, evaluateNode(node.lower, context)) >= 0 &&
        compare(value, evaluateNode(node.upper, context)) <= 0;
      return node.negated ? !hit : hit;
    }
    case 'in': {
      const value = evaluateNode(node.value, context);
      const hit = node.items.some(
        (item) => compare(value, evaluateNode(item, context)) === 0,
      );
      return node.negated ? !hit : hit;
    }
    case 'binary': {
      return evaluateBinary(node, context);
    }
    default: {
      throw new RuleRuntimeError('规则节点类型不支持');
    }
  }
}
function evaluateBinary(
  node: Extract<RuleNode, { type: 'binary' }>,
  context: unknown,
): unknown {
  if (node.operator === 'AND') {
    return truthy(evaluateNode(node.left, context)) && truthy(evaluateNode(node.right, context));
  }
  if (node.operator === 'OR') {
    return truthy(evaluateNode(node.left, context)) || truthy(evaluateNode(node.right, context));
  }
  const left = evaluateNode(node.left, context);
  const right = evaluateNode(node.right, context);
  switch (node.operator) {
    case '=': {
      return compare(left, right) === 0;
    }
    case '!=': {
      return compare(left, right) !== 0;
    }
    case '>': {
      return compare(left, right) > 0;
    }
    case '>=': {
      return compare(left, right) >= 0;
    }
    case '<': {
      return compare(left, right) < 0;
    }
    case '<=': {
      return compare(left, right) <= 0;
    }
    case '+': {
      const leftNumber = toNumber(left);
      const rightNumber = toNumber(right);
      // ä¸¤ä¾§éƒ½æ˜¯æ•°å€¼æ‰åšåŠ æ³•ï¼Œå¦åˆ™æŒ‰æ–‡æœ¬æ‹¼æŽ¥ï¼ˆç¼–å·ç±»å­—æ®µå¸¸ç”¨ï¼‰
      if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
        return leftNumber + rightNumber;
      }
      return `${left ?? ''}${right ?? ''}`;
    }
    case '-': {
      return toNumber(left) - toNumber(right);
    }
    case '*': {
      return toNumber(left) * toNumber(right);
    }
    case '/': {
      const divisor = toNumber(right);
      if (divisor === 0) {
        throw new RuleRuntimeError('规则里出现了除以 0');
      }
      return toNumber(left) / divisor;
    }
    default: {
      throw new RuleRuntimeError(`不支持的运算符 ${node.operator}`);
    }
  }
}
/** æ±‚值,返回原始结果(可能是数字、字符串、布尔) */
export function evaluateRule(expression: string, context: unknown): unknown {
  return evaluateNode(parseRule(expression), context);
}
/** æ±‚值并转成布尔判定,用于「是否合格」这类条件 */
export function evaluateCondition(expression: string, context: unknown): boolean {
  return truthy(evaluateRule(expression, context));
}
/** å¯¹å·²è§£æžçš„语法树求值,避免同一条规则在多行上重复解析 */
export function evaluateRuleNode(node: RuleNode, context: unknown): unknown {
  return evaluateNode(node, context);
}
/** å¯¹å·²è§£æžçš„语法树做布尔判定 */
export function evaluateConditionNode(node: RuleNode, context: unknown): boolean {
  return truthy(evaluateRuleNode(node, context));
}
/** è§£æžåŽçš„路径依赖,渲染前可据此检查数据是否齐备 */
export function listRulePaths(node: RuleNode): string[] {
  const paths: string[] = [];
  const visit = (current: RuleNode): void => {
    switch (current.type) {
      case 'path': {
        paths.push(current.path);
        return;
      }
      case 'call': {
        current.args.forEach(visit);
        return;
      }
      case 'unary': {
        visit(current.operand);
        return;
      }
      case 'binary': {
        visit(current.left);
        visit(current.right);
        return;
      }
      case 'between': {
        visit(current.value);
        visit(current.lower);
        visit(current.upper);
        return;
      }
      case 'in': {
        visit(current.value);
        current.items.forEach(visit);
        return;
      }
      default: {
        return;
      }
    }
  };
  visit(node);
  // ç»Ÿè®¡å‡½æ•°ä½œç”¨åœ¨æ•°ç»„路径上,这里去掉末端字段只保留数组本身
  return [...new Set(paths.map((path) => parsePath(path).join('.')))];
}
src/components/quality/header/report-footer.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
import type { QualityComponentDefinition } from '../core/types';
import { escapeHtmlMultiline } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
export const reportFooterDefinition: QualityComponentDefinition = {
  type: 'ReportFooter',
  name: 'reportFooter',
  label: '报告页脚',
  category: QUALITY_CATEGORY.HEADER,
  icon: QUALITY_ICONS.footer,
  aiHint:
    '文档最底部的落款块:检验员/审核人/日期签署行、厂址与联系电话。整份报告最多一个,放在最后。' +
    '签署行即使排在检验表格的最后一行,也属于落款而不是检验数据,要取出来放进我这里;' +
    '原件页脚与签署行合成一处。签署行里的人名与日期留空,不要写死某一份报告的值。',
  contentKey: 'text',
  defaults: { text: '检验员:__________  审核:__________  日期:__________' },
  propertySchema: [
    {
      key: 'text',
      label: '页脚文字',
      type: 'string',
      defaultValue: '检验员:__________  审核:__________  日期:__________',
      bindable: true,
    },
  ],
  dataSchema: [{ key: 'text', label: '页脚文字', type: 'string', bindable: true }],
  buildContent: (properties) => ({
    tagName: 'div',
    content: escapeHtmlMultiline(properties.text),
    style: {
      'font-size': '11px',
      color: '#8c8c8c',
      'text-align': 'center',
      padding: '8px 0',
      'border-top': '1px solid #d9d9d9',
      margin: '12px 0 0',
    },
  }),
};
src/components/quality/header/report-header.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,80 @@
import type { QualityComponentDefinition } from '../core/types';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/** æŠ¥å‘Šç¼–号行;未绑定成功时渲染为空,不会在报告上留下花括号 */
const REPORT_NO_EXPR = '{{report.reportNo}}';
export const reportHeaderDefinition: QualityComponentDefinition = {
  type: 'ReportHeader',
  name: 'reportHeader',
  label: '报告页眉',
  category: QUALITY_CATEGORY.HEADER,
  icon: QUALITY_ICONS.header,
  aiHint:
    '文档最顶部的抬头块:公司名称、报告标题、报告编号。整份报告最多一个,且必须是最外层第一个组件。',
  defaults: {
    title: '智能质检报告',
    align: 'center',
    showReportNo: true,
    reportNo: REPORT_NO_EXPR,
  },
  propertySchema: [
    {
      key: 'title',
      label: '报告标题',
      type: 'string',
      defaultValue: '智能质检报告',
      required: true,
      bindable: true,
    },
    {
      key: 'align',
      label: '对齐方式',
      type: 'enum',
      defaultValue: 'center',
      options: [
        { label: '左对齐', value: 'left' },
        { label: '居中', value: 'center' },
      ],
    },
    { key: 'showReportNo', label: '显示报告编号', type: 'boolean', defaultValue: true },
    {
      key: 'reportNo',
      label: '报告编号',
      type: 'string',
      defaultValue: REPORT_NO_EXPR,
      bindable: true,
    },
  ],
  dataSchema: [
    { key: 'title', label: '报告标题', type: 'string', bindable: true },
    { key: 'reportNo', label: '报告编号', type: 'string', bindable: true },
  ],
  buildContent: (properties) => {
    const align = String(properties.align ?? 'center');
    const title = `<div style="${cssStyle({
      'font-size': '18px',
      'font-weight': 600,
      'text-align': align,
      padding: '8px 0',
      border: 'none',
      'border-bottom': '2px solid #1677ff',
    })}">${escapeHtml(properties.title)}</div>`;
    const reportNo = properties.showReportNo
      ? `<div style="${cssStyle({
          'font-size': '11px',
          color: '#8c8c8c',
          'text-align': align,
          padding: '4px 0 0',
        })}">报告编号:${escapeHtml(properties.reportNo)}</div>`
      : '';
    return {
      tagName: 'div',
      components: title + reportNo,
      style: { margin: '0 0 12px' },
    };
  },
};
src/components/quality/header/sample-info.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
import type { QualityComponentDefinition, QualityProps } from '../core/types';
import { parseColumnSpec } from '../core/column-spec';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/** æ ·å“ä¿¡æ¯é»˜è®¤å±•示的字段,值全部来自报告上下文 */
const SAMPLE_FIELDS: { expr: string; label: string }[] = [
  { label: '样品编号', expr: '{{report.sampleNo}}' },
  { label: '产品名称', expr: '{{report.productName}}' },
  { label: '规格型号', expr: '{{report.spec}}' },
  { label: '批次号', expr: '{{report.batchNo}}' },
  { label: '检验日期', expr: '{{report.inspectDate}}' },
  { label: '检验员', expr: '{{report.inspector}}' },
];
const CELL_BORDER = '1px solid #d9d9d9';
function textOf(value: unknown): string {
  return String(value ?? '').trim();
}
/**
 * å‡ºè¡¨ç”¨çš„字段:填了 fields å°±æ•´å—照它来,否则用默认字段。
 * <p>
 * ä¸Žåˆ†ç»„检验项表共用同一套 `字段名=绑定表达式` è¯­æ³•(见 {@link parseColumnSpec}),
 * å…å¾—同一个「键值对列表」长出两种写法。合并标记 `#` åœ¨è¿™é‡Œæ²¡æœ‰æ„ä¹‰ï¼Œè§£æžå‡ºæ¥ä¹Ÿåªæ˜¯è¢«ä¸¢æŽ‰ï¼Œ
 * æ‰€ä»¥ validate é‡Œæ˜Žç¡®æ‹¦ä¸‹ï¼Œä¸è®©ç”¨æˆ·ä»¥ä¸ºè‡ªå·±å†™å¯¹äº†ã€‚
 */
function effectiveFields(properties: QualityProps): { expr: string; label: string }[] {
  const custom = parseColumnSpec(textOf(properties.fields));
  return custom.items.length > 0
    ? custom.items.map((field) => ({ label: field.label, expr: field.expr }))
    : SAMPLE_FIELDS;
}
function buildRows(
  pairsPerRow: number,
  showBorder: boolean,
  fields: { expr: string; label: string }[],
): string {
  const perRow = Math.max(pairsPerRow, 1);
  const border = showBorder ? CELL_BORDER : 'none';
  const rows: string[] = [];
  for (let index = 0; index < fields.length; index += perRow) {
    const rowFields = fields.slice(index, index + perRow);
    // æœ«è¡Œå‡‘不满一组时,让最后一个值格横向吃掉余下的列。照原样铺满格子的话,
    // å³ä¾§ä¼šç§ƒå‡ºå‡ ä¸ªåªæœ‰è¾¹æ¡†çš„空格(字段数不是「每行组数」整数倍时必然出现)。
    const tailColspan = rowFields.length < perRow ? (perRow - rowFields.length) * 2 + 1 : 1;
    const cells = rowFields
      .map((field, position) => {
        const span = position === rowFields.length - 1 && tailColspan > 1 ? ` colspan="${tailColspan}"` : '';
        return (
          `<td style="${cssStyle({
            border,
            padding: '5px 8px',
            background: '#fafafa',
            'font-weight': 600,
            width: '90px',
          })}">${escapeHtml(field.label)}</td>` +
          `<td${span} style="${cssStyle({ border, padding: '5px 8px' })}">${escapeHtml(field.expr)}</td>`
        );
      })
      .join('');
    rows.push(`<tr>${cells}</tr>`);
  }
  return rows.join('');
}
export const sampleInfoDefinition: QualityComponentDefinition = {
  type: 'SampleInfo',
  name: 'sampleInfo',
  label: '样品信息',
  category: QUALITY_CATEGORY.HEADER,
  icon: QUALITY_ICONS.sample,
  aiHint: '样品与物料的基本信息块:产品名称、规格、批号、数量、生产日期、有效日期等键值对。',
  defaults: { title: '样品信息', pairsPerRow: 2, showBorder: true },
  propertySchema: [
    { key: 'title', label: '区块标题', type: 'string', defaultValue: '样品信息' },
    {
      key: 'pairsPerRow',
      label: '每行字段数',
      type: 'number',
      defaultValue: 2,
      tip: '一行放几组「字段名 + å€¼ã€',
    },
    { key: 'showBorder', label: '显示边框', type: 'boolean', defaultValue: true },
    {
      key: 'fields',
      label: '字段定义(覆盖默认字段)',
      type: 'text',
      tip:
        '留空则用默认的六项。格式:字段名=绑定表达式,多项用 | åˆ†éš”,' +
        '如 æ ·å“ç¼–号={{report.sampleNo}}|产品名称={{report.productName}}|生产日期={{report.produceDate}}。' +
        '每行放几组仍由上面的「每行字段数」控制;这里不支持 # åˆå¹¶æ ‡è®°',
    },
  ],
  dataSchema: SAMPLE_FIELDS.map((field) => ({
    // è¡¨è¾¾å¼å½¢å¦‚ {{report.sampleNo}},去掉两层花括号即绑定路径
    key: field.expr.slice(2, -2),
    label: field.label,
    type: 'string' as const,
    bindable: true,
  })),
  buildContent: (properties) => {
    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 table = `<table style="${cssStyle({
      width: '100%',
      'border-collapse': 'collapse',
      'font-size': '12px',
    })}">${buildRows(
      Number(properties.pairsPerRow ?? 2),
      properties.showBorder !== false,
      effectiveFields(properties),
    )}</table>`;
    return {
      tagName: 'div',
      components: heading + table,
      style: { margin: '0 0 10px' },
    };
  },
  validate: (properties) => {
    const parsed = parseColumnSpec(textOf(properties.fields));
    const errors = [...parsed.errors];
    if (parsed.items.some((field) => field.merge)) {
      errors.push(
        '样品信息的「字段定义」不支持 # åˆå¹¶æ ‡è®°ï¼ˆé‚£æ˜¯åˆ†ç»„检验项表标组名用的),请去掉字段名前的 #',
      );
    }
    return errors;
  },
};
src/components/quality/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,97 @@
/**
 * è´¨é‡ç»„件统一注册入口
 * <p>
 * è®¾è®¡å™¨ã€æ¸²æŸ“引擎都从这里取组件;新增一个质量组件=在下面的清单里加一个 definition,
 * ä¸æ”¹åЍ GrapesJS é›†æˆä»£ç ï¼Œä¹Ÿä¸æ”¹åŠ¨è®¾è®¡å™¨é¡µé¢ã€‚
 */
import { headingDefinition } from './base/heading';
import { dividerDefinition } from './base/divider';
import { imageDefinition } from './base/image';
import { spacerDefinition } from './base/spacer';
import { tableDefinition } from './base/table';
import { textDefinition } from './base/text';
import { registerQualityComponents } from './core/registry';
import { reportFooterDefinition } from './header/report-footer';
import { reportHeaderDefinition } from './header/report-header';
import { sampleInfoDefinition } from './header/sample-info';
import { groupedQualityTableDefinition } from './inspection/grouped-quality-table';
import { inspectionItemDefinition } from './inspection/inspection-item';
import { qualityTableDefinition } from './inspection/quality-table';
import { reportResultDefinition } from './result/report-result';
/** å†…置组件清单:顺序即左侧面板同一分类内的展示顺序 */
const BUILT_IN_DEFINITIONS = [
  headingDefinition,
  textDefinition,
  imageDefinition,
  dividerDefinition,
  spacerDefinition,
  tableDefinition,
  reportHeaderDefinition,
  reportFooterDefinition,
  sampleInfoDefinition,
  qualityTableDefinition,
  groupedQualityTableDefinition,
  inspectionItemDefinition,
  reportResultDefinition,
];
/** å¹‚等:重复调用只会用最新定义覆盖注册表 */
export function registerAllQualityComponents(): void {
  registerQualityComponents(BUILT_IN_DEFINITIONS);
}
export { extractRawProps, attributesToProps, propsToAttributes } from './core/serializer';
export { renderReport } from './engine/render';
export { createContext, createEmptyContext, QUALITY_RESULT, QUALITY_RESULT_TEXT } from './engine/context';
export { evaluateReport, validateRule } from './engine/report-evaluator';
export { listAllFunctionNames } from './engine/rule-engine';
export type {
  QualityRuleDefinition,
  QualityRuleScope,
} from './engine/report-evaluator';
export type {
  RenderNode,
  RenderOptions,
  RenderOutcome,
  RenderSchema,
  RenderStyleRule,
} from './engine/render';
export type {
  ReportContext,
  ReportContextItem,
  ReportContextItemGroup,
  ReportContextReport,
  QualityResult,
} from './engine/context';
export { resolvePageMm, PAGE_SIZE_MM } from './engine/page';
export { bindQualityCanvasSync, restoreQualityTraits } from './core/canvas-sync';
export { BINDING_TRAIT_TYPE, registerBindingTraitType } from './core/trait-binding';
export {
  getQualityComponent,
  listQualityComponents,
  listQualityComponentsByCategory,
  registerQualityComponent,
  registerQualityComponents,
} from './core/registry';
export { buildQualityBlock, buildQualityBlocks, buildQualityContent } from './core/factory';
export { resolveQualityProps, validateQualityProps } from './core/validator';
export { buildQualityComponentCatalog } from './core/catalog';
export type {
  QualityCatalogField,
  QualityComponentCatalog,
} from './core/catalog';
export { assembleQualityCanvas } from './core/assembler';
export type {
  QualityAssemblyResult,
  QualityAssemblySkip,
  QualityDraftComponent,
} from './core/assembler';
export { QUALITY_TYPE_ATTR, propAttrName } from './core/attrs';
export type {
  QualityCategory,
  QualityComponentDefinition,
  QualityFieldSchema,
  QualityProps,
} from './core/types';
export { QUALITY_CATEGORY } from './core/types';
src/components/quality/inspection/grouped-quality-table.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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;
  },
};
src/components/quality/inspection/inspection-item.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
import type { QualityComponentDefinition } from '../core/types';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/**
 * å•个检验项的明细块。
 * <p>
 * å–值全部来自循环变量 item,放在检验项表格的重复行里、或独立的重复容器里使用;
 * å•独拖到画布上时渲染期没有 item ä¸Šä¸‹æ–‡ï¼Œä¼šæ¸²æŸ“成空值,这是预期行为。
 */
const LOOP_VAR = 'item';
interface FieldSpec {
  label: string;
  /** å–值表达式,'' è¡¨ç¤ºè¯¥å­—段只有一个值、不带标签 */
  expr: string;
  toggle: string;
}
const FIELDS: FieldSpec[] = [
  { label: '', expr: `{{${LOOP_VAR}.itemName}}`, toggle: '' },
  { label: '标准值:', expr: `{{${LOOP_VAR}.standardValue}}`, toggle: 'showStandard' },
  { label: '实测值:', expr: `{{${LOOP_VAR}.actualValue}}`, toggle: 'showActual' },
  { label: '单位:', expr: `{{${LOOP_VAR}.unit}}`, toggle: 'showUnit' },
  { label: '判定:', expr: `{{${LOOP_VAR}.resultText}}`, toggle: 'showResult' },
];
export const inspectionItemDefinition: QualityComponentDefinition = {
  type: 'InspectionItem',
  name: 'inspectionItem',
  label: '检验项明细',
  category: QUALITY_CATEGORY.INSPECTION,
  icon: QUALITY_ICONS.inspection,
  aiHint:
    '单条检验项的竖排明细卡片,用于检验项很少、不适合排成表格的场合。多行数据用 QualityTable。',
  defaults: {
    showStandard: true,
    showActual: true,
    showUnit: true,
    showResult: true,
    showBorder: true,
  },
  propertySchema: [
    { key: 'showStandard', 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: 'showBorder', label: '显示边框', type: 'boolean', defaultValue: true },
  ],
  dataSchema: [
    { key: `${LOOP_VAR}.itemName`, label: '检验项目名称', type: 'string', bindable: true },
    { key: `${LOOP_VAR}.standardValue`, 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 visible = FIELDS.filter(
      (field) => field.toggle === '' || properties[field.toggle] !== false,
    );
    const [first, ...rest] = visible;
    const name = first
      ? `<div style="${cssStyle({ 'font-weight': 600, 'font-size': '13px' })}">${first.expr}</div>`
      : '';
    const details = rest
      .map(
        (field) =>
          `<div style="${cssStyle({ 'font-size': '12px', 'line-height': '1.8' })}">${escapeHtml(field.label)}<span>${field.expr}</span></div>`,
      )
      .join('');
    return {
      tagName: 'div',
      components: name + details,
      style: {
        margin: '0 0 6px',
        padding: '6px 8px',
        border: properties.showBorder === false ? 'none' : '1px solid #d9d9d9',
      },
    };
  },
};
src/components/quality/inspection/quality-table.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,139 @@
import type { QualityComponentDefinition } from '../core/types';
import { QUALITY_REPEAT_ATTR, QUALITY_REPEAT_ROW_ATTR } from '../core/attrs';
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';
interface ColumnSpec {
  /** è¡¨å¤´æ–‡å­— */
  label: string;
  /** è¡Œå†…取值表达式 */
  expr: string;
  /** å¯¹åº”的显示开关属性名 */
  toggle: string;
  width?: string;
}
const COLUMNS: ColumnSpec[] = [
  { label: '序号', expr: `{{${INDEX_VAR}}}`, toggle: 'showIndex', width: '48px' },
  { label: '检验项目', expr: `{{${LOOP_VAR}.itemName}}`, toggle: '' },
  { label: '标准值', expr: `{{${LOOP_VAR}.standardValue}}`, toggle: 'showStandard' },
  { label: '实测值', expr: `{{${LOOP_VAR}.actualValue}}`, toggle: 'showActual', width: '40%' },
  { label: '单位', expr: `{{${LOOP_VAR}.unit}}`, toggle: 'showUnit', width: '60px' },
  { label: '判定', expr: `{{${LOOP_VAR}.resultText}}`, toggle: 'showResult', width: '72px' },
];
export const qualityTableDefinition: QualityComponentDefinition = {
  type: 'QualityTable',
  name: 'qualityTable',
  label: '检验项目表格',
  category: QUALITY_CATEGORY.INSPECTION,
  icon: QUALITY_ICONS.qualityTable,
  aiHint:
    '多行检验数据的表格,检验项平铺一层、没有父子关系(序号/检验项目/标准值/实测值/单位/判定)。' +
    '一个检验项目下挂若干子项(原件里表现为该列纵向合并、后续行写着「↑同上」)时不要用我,' +
    '改用分组检验项表。' +
    '报告末尾的落款签署行(检验员/审核人/日期)不要放进我这里,那属于 ReportFooter。',
  defaults: {
    title: '检验项目',
    itemsPath: DEFAULT_ITEMS_PATH,
    showIndex: true,
    showStandard: 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: 'showStandard', 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 },
  ],
  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}.standardValue`, 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 visible = COLUMNS.filter(
      (column) => column.toggle === '' || properties[column.toggle] !== false,
    );
    const head = visible
      .map(
        (column) =>
          `<th style="${cssStyle({
            border: CELL_BORDER,
            padding: '6px 8px',
            background: '#fafafa',
            'font-weight': 600,
            width: column.width,
          })}">${column.label}</th>`,
      )
      .join('');
    const row = visible
      .map(
        (column) =>
          `<td style="${cssStyle({ border: CELL_BORDER, padding: '6px 8px' })}">${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><tr>${head}</tr></thead>` +
      // æ¸²æŸ“期按 data-qc-repeat æŒ‡å‘的数组复制 data-qc-repeat-row æ‰€åœ¨çš„行
      `<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 path = String(properties.itemsPath ?? '').trim();
    const errors: string[] = [];
    if (!path) {
      errors.push('检验项目表格的「检验项数据源」不能为空,渲染时无法确定生成哪些行');
    }
    if (properties.showStandard === false && properties.showResult === true) {
      errors.push(
        '检验项目表格隐藏了「标准值」却保留「判定」列,判定依据将不可见,请一并隐藏判定或恢复标准值',
      );
    }
    return errors;
  },
};
src/components/quality/result/report-result.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
import type { QualityComponentDefinition } from '../core/types';
import { cssStyle, escapeHtml } from '../core/html';
import { QUALITY_ICONS } from '../core/icons';
import { QUALITY_CATEGORY } from '../core/types';
/**
 * åˆ¤å®šç»“果块。
 * <p>
 * ç»“论与合格率来自报告上下文,由规则引擎在渲染前算好;
 * è¿™é‡Œåªè´Ÿè´£å±•示,不在组件里做任何判定计算。
 */
const CONCLUSION_EXPR = '{{report.conclusion}}';
const PASS_RATE_EXPR = '{{report.passRate}}';
export const reportResultDefinition: QualityComponentDefinition = {
  type: 'Result',
  name: 'reportResult',
  label: '判定结果',
  category: QUALITY_CATEGORY.RESULT,
  icon: QUALITY_ICONS.result,
  aiHint: '整份报告的判定结论与判定依据,放在报告末尾。',
  defaults: {
    label: '判定结论',
    conclusion: CONCLUSION_EXPR,
    showPassRate: true,
    passRateLabel: '合格率',
  },
  propertySchema: [
    { key: 'label', label: '结论标签', type: 'string', defaultValue: '判定结论' },
    {
      key: 'conclusion',
      label: '结论取值',
      type: 'string',
      defaultValue: CONCLUSION_EXPR,
      required: true,
      bindable: true,
    },
    { key: 'showPassRate', label: '显示合格率', type: 'boolean', defaultValue: true },
    { key: 'passRateLabel', label: '合格率标签', type: 'string', defaultValue: '合格率' },
  ],
  dataSchema: [
    { key: 'report.conclusion', label: '判定结论', type: 'string', bindable: true },
    { key: 'report.passRate', label: '合格率', type: 'string', bindable: true },
  ],
  buildContent: (properties) => {
    const label = escapeHtml(String(properties.label ?? '').trim());
    const conclusion = escapeHtml(String(properties.conclusion ?? '').trim());
    const conclusionLine =
      `<div style="${cssStyle({ 'font-size': '13px', 'line-height': '1.9' })}">` +
      `${label ? `${label}:<span style="font-weight:600">${conclusion}</span>` : `<span style="font-weight:600">${conclusion}</span>`}` +
      `</div>`;
    const passRateLine = properties.showPassRate
      ? `<div style="${cssStyle({ 'font-size': '12px', color: '#595959', 'line-height': '1.9' })}">` +
        `${escapeHtml(String(properties.passRateLabel ?? '').trim())}:<span>${PASS_RATE_EXPR}</span>` +
        `</div>`
      : '';
    return {
      tagName: 'div',
      components: conclusionLine + passRateLine,
      style: {
        margin: '0 0 10px',
        padding: '8px 10px',
        border: '1px solid #b7eb8f',
        background: '#f6ffed',
      },
    };
  },
};
src/packages/constants/src/biz-mes-enum.ts
@@ -476,6 +476,12 @@
  FILE: 5,
} as const;
/** MES è´¨æ£€æŒ‡æ ‡æ¡ç›®ç±»åž‹æžšä¸¾ï¼ˆå¯¹é½åŽç«¯ MesQcIndicatorItemTypeEnum) */
export const MesQcIndicatorItemType = {
  ENTRY: 1,
  GROUP: 2,
} as const;
/** MES ä¸šåŠ¡ç±»åž‹å¸¸é‡ */
export const MesBizTypeEnum = {
  // WM ä»“库模块 [100, 200)
src/packages/constants/src/dict-enum.ts
@@ -298,6 +298,16 @@
  MES_NCR_DISPOSITION: 'mes_ncr_disposition', // MES ä¸åˆæ ¼å“å¤„置方式
} as const;
/** ========== QCReport - æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å° ========== */
const QC_REPORT_DICT = {
  QC_REPORT_TEMPLATE_STATUS: 'qc_report_template_status', // æŠ¥å‘Šæ¨¡æ¿çŠ¶æ€
  QC_REPORT_VERSION_STATUS: 'qc_report_version_status', // æŠ¥å‘Šæ¨¡æ¿ç‰ˆæœ¬çŠ¶æ€
  QC_REPORT_INSTANCE_STATUS: 'qc_report_instance_status', // æŠ¥å‘Šå®žä¾‹çŠ¶æ€
  QC_REPORT_INDUSTRY: 'qc_report_industry', // æŠ¥å‘Šæ‰€å±žè¡Œä¸š
  QC_REPORT_PAGE_SIZE: 'qc_report_page_size', // æŠ¥å‘Šçº¸å¼ å°ºå¯¸
  QC_REPORT_ORIENTATION: 'qc_report_orientation', // æŠ¥å‘Šçº¸å¼ æ–¹å‘
} as const;
/** ========== WMS - ä»“储管理模块 ========== */
const WMS_DICT = {
  WMS_MERCHANT_TYPE: 'merchant_type', // WMS å¾€æ¥ä¼ä¸šç±»åž‹
@@ -352,6 +362,7 @@
  ...MP_DICT,
  ...PAY_DICT,
  ...MALL_DICT,
  ...QC_REPORT_DICT,
  ...SYSTEM_DICT,
  ...COMMON_DICT,
} as const;
src/router/routes/modules/qcreport.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router';
/**
 * æ™ºèƒ½è´¨æ£€æŠ¥å‘Šè®¾è®¡å¹³å°çš„路由
 * <p>
 * åˆ—表页由后端菜单驱动(/qc/report-template),设计器与实例预览等页面
 * ä¸è¿›å…¥èœå•,由列表页带 id è·³è½¬è¿‡æ¥ã€‚
 */
const routes: RouteRecordRaw[] = [
  {
    path: '/qc/report-template/designer',
    name: 'QcReportTemplateDesigner',
    component: () => import('#/views/mes/qc/report/template/designer/index.vue'),
    meta: {
      title: '报告模板设计器',
      activePath: '/qc/report-template',
      hideInMenu: true,
      keepAlive: false,
    },
    props: (route) => {
      return {
        templateId: route.query.id,
      };
    },
  },
];
export default routes;
src/views/mes/qc/components/report-generate-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,251 @@
<script lang="ts" setup>
import type { QcReportTemplateApi } from '#/api/mes/qc/report/template';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { Alert, Button, Empty, Radio, Spin, Tag } from 'ant-design-vue';
import { generateInstanceFromQc } from '#/api/mes/qc/report/instance';
import { getSelectableTemplates } from '#/api/mes/qc/report/template';
/**
 * è´¨æ£€ç±»åž‹ â†’ æŠ¥å‘Šè½åº“用的 businessType(就是 MES è¡¨åï¼‰ã€‚
 * <p>
 * ä¸Ž mes_qc_type å­—典的数值不是一回事,反查报告时用的就是这四个字符串。
 */
const BUSINESS_TYPE_BY_QC_TYPE: Record<number, string> = {
  1: 'mes_qc_iqc',
  2: 'mes_qc_ipqc',
  3: 'mes_qc_oqc',
  4: 'mes_qc_rqc',
};
/** æ‰“开弹窗时由质检单列表页带进来 */
interface ReportModalData {
  /** è´¨æ£€ç±»åž‹ï¼š1 æ¥æ–™ 2 è¿‡ç¨‹ 3 å‡ºè´§ 4 é€€è´§ */
  qcType: number;
  /** è´¨æ£€å•编号 */
  qcId: number;
  /** å•据编号,仅用于标题展示 */
  qcNo?: string;
}
/** å‡ºä»¶ç»“果,只留弹窗要展示的字段 */
interface ReportResult {
  id: number;
  reportNo: string;
  templateVersion: string;
  itemCount: number;
  undecidableCount: number;
  warnings: string[];
  errors: string[];
}
defineOptions({ name: 'QcReportGenerateModal' });
const router = useRouter();
const modalData = ref<ReportModalData>();
const templates = ref<QcReportTemplateApi.Template[]>([]);
const templateId = ref<number>();
const loadingTemplates = ref(false);
const generating = ref(false);
const result = ref<ReportResult>();
const [Modal, modalApi] = useVbenModal({
  footer: false,
  onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      modalData.value = undefined;
      templates.value = [];
      templateId.value = undefined;
      result.value = undefined;
      return;
    }
    const data = modalApi.getData<ReportModalData>();
    modalData.value = data;
    result.value = undefined;
    templateId.value = undefined;
    templates.value = [];
    if (data?.qcType) {
      loadTemplates(data.qcType);
    }
  },
});
/**
 * åªåˆ—同类型、已启用、且当前版本已发布的模板。
 * <p>
 * è¿™ä¸‰é‡è¿‡æ»¤å…¨åœ¨åŽç«¯åšï¼ˆ/qc-report/template/selectable),前端不再自己推导:
 * report_type å­˜çš„æ˜¯ mes_qc_type çš„æ•°å€¼å­—符串("1"~"4"),拿「IQC」这类英文名去比会一个都查不到;
 * ä¸” currentVersion éžç©º â‰  èƒ½ç”¨â€”—停用版本不会清空模板的 currentVersion,
 * è¿™ç§æ¨¡æ¿é€‰ä¸­åŽå‡ºä»¶å¿…报「模板版本未发布」。
 */
async function loadTemplates(qcType: number) {
  loadingTemplates.value = true;
  try {
    templates.value = await getSelectableTemplates(String(qcType));
  } catch {
    templates.value = [];
  } finally {
    loadingTemplates.value = false;
  }
}
/** ç”ŸæˆæŠ¥å‘Šï¼šæ•°æ®å…¨éƒ¨ç”±åŽç«¯ä»Žè´¨æ£€å•取,这里只决定「哪张单据 + å“ªå¼ æ¨¡æ¿ã€ */
async function handleGenerate() {
  const data = modalData.value;
  const chosen = templateId.value;
  if (!data || !chosen || generating.value) {
    return;
  }
  generating.value = true;
  try {
    const resp = await generateInstanceFromQc({
      qcType: data.qcType,
      qcId: data.qcId,
      templateId: chosen,
    });
    result.value = {
      id: resp.id,
      reportNo: resp.reportNo,
      templateVersion: resp.templateVersion,
      itemCount: resp.itemCount,
      undecidableCount: resp.undecidableCount,
      warnings: resp.warnings ?? [],
      errors: resp.errors ?? [],
    };
  } catch {
    // å¤±è´¥æŠ¥æ–‡å·²ç”±è¯·æ±‚拦截器弹出(五道校验按业务优先级只报第一处),这里不重复提示。
    // å¤±è´¥éƒ½æ˜¯ã€Œæ•°æ®æˆ–选择不对」,重试无用,按报文提示去改数据或换模板。
  } finally {
    generating.value = false;
  }
}
/** ç”Ÿæˆå®Œè·³åˆ°æŠ¥å‘Šå®žä¾‹åˆ—表,并把单据过滤条件带过去,直接看到这份报告 */
function handleGoReportList() {
  const data = modalData.value;
  modalApi.close();
  if (!data) {
    return;
  }
  router.push({
    path: '/qc/report-instance',
    query: {
      businessType: BUSINESS_TYPE_BY_QC_TYPE[data.qcType],
      businessId: String(data.qcId),
    },
  });
}
</script>
<template>
  <Modal
    :title="`出报告 - ${modalData?.qcNo ?? ''}`"
    class="w-3/5"
    :mask-closable="false"
  >
    <!-- é€‰æ¨¡æ¿é˜¶æ®µ -->
    <template v-if="!result">
      <Spin :spinning="loadingTemplates">
        <div class="px-1 py-2">
          <div class="mb-3 text-sm text-gray-600">
            æŠ¥å‘Šæ•°æ®ç”±åŽç«¯ä»Žè¯¥è´¨æ£€å•读取,只需选一张模板;模板类型必须与单据类型一致。
          </div>
          <div v-if="templates.length === 0 && !loadingTemplates">
            <Empty description="没有可用的同类型模板,请先到「报告模板设计」里配好并发布一个版本" />
          </div>
          <Radio.Group v-else v-model:value="templateId" class="w-full">
            <div class="flex flex-col gap-2">
              <Radio v-for="item in templates" :key="item.id" :value="item.id">
                <span>{{ item.templateName }}</span>
                <Tag class="ml-2" color="green">{{ item.currentVersion }}</Tag>
                <span v-if="item.industry" class="ml-2 text-xs text-gray-400">
                  {{ item.industry }}
                </span>
              </Radio>
            </div>
          </Radio.Group>
        </div>
      </Spin>
      <div class="mt-4 flex justify-end gap-2">
        <Button @click="modalApi.close()">取消</Button>
        <Button
          type="primary"
          :loading="generating"
          :disabled="!templateId"
          @click="handleGenerate"
        >
          ç”ŸæˆæŠ¥å‘Š
        </Button>
      </div>
    </template>
    <!-- ç»“果阶段:告警必须展示,不要静默吞掉 -->
    <template v-else>
      <div class="px-1 py-2">
        <Alert type="success" show-icon>
          <template #message>
            æŠ¥å‘Šå·²ç”Ÿæˆï¼š<b>{{ result.reportNo }}</b>
          </template>
          <template #description>
            <div>
              æ¨¡æ¿ç‰ˆæœ¬ {{ result.templateVersion }};写入检验项
              {{ result.itemCount }} æ¡
            </div>
          </template>
        </Alert>
        <Alert
          v-if="result.undecidableCount > 0"
          class="mt-3"
          type="warning"
          show-icon
          :message="`本次有 ${result.undecidableCount} é¡¹æ²¡æœ‰åˆ¤å®šç»“论`"
          description="缺规格上下限又没规则的项标为「无判定规则」,不计入合格率也不参与报告结论;规则执行失败的项标为「待判定」,仍计入合格率。两项都计入总数。如需判定,请先在质检单上补规格上下限,或修好模板里的规则后重新出件。"
        />
        <Alert
          v-else
          class="mt-3"
          type="info"
          show-icon
          message="全部检验项均已判定,没有无法判定的项"
        />
        <div v-if="result.warnings.length > 0" class="mt-3">
          <div class="mb-1 text-sm font-medium text-gray-700">
            æ•°æ®å–舍提示({{ result.warnings.length }})
          </div>
          <Alert type="warning">
            <ul class="m-0 list-disc pl-4">
              <li v-for="(w, i) in result.warnings" :key="i">{{ w }}</li>
            </ul>
          </Alert>
        </div>
        <div v-if="result.errors.length > 0" class="mt-3">
          <div class="mb-1 text-sm font-medium text-gray-700">
            æ¨¡æ¿å­—段缺口({{ result.errors.length }})
          </div>
          <Alert type="error">
            <ul class="m-0 list-disc pl-4">
              <li v-for="(e, i) in result.errors" :key="i">{{ e }}</li>
            </ul>
          </Alert>
        </div>
        <div class="mt-3 text-xs text-gray-400">
          ä»¥ä¸Šæç¤ºåªåœ¨æœ¬æ¬¡å“åº”里出现,不落库;事后回看历史报告时看不到,需要留痕请自行记录。
        </div>
      </div>
      <div class="mt-4 flex justify-end gap-2">
        <Button @click="modalApi.close()">关闭</Button>
        <Button type="primary" @click="handleGoReportList">查看报告列表</Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/qc/indicator/data.ts
@@ -7,13 +7,16 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesQcIndicatorItemType,
  MesQcResultValueType,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getIndicatorGroupList } from '#/api/mes/qc/indicator';
import { QcIndicatorResultSpecificationInput } from './components';
@@ -63,6 +66,61 @@
      rules: 'required',
    },
    {
      fieldName: 'itemType',
      label: '条目类型',
      component: 'RadioGroup',
      componentProps: {
        buttonStyle: 'solid',
        optionType: 'button',
        options: [
          { label: '录入项', value: MesQcIndicatorItemType.ENTRY },
          { label: '分组项', value: MesQcIndicatorItemType.GROUP },
        ],
      },
      dependencies: {
        triggerFields: ['itemType'],
        // åˆ‡æˆåˆ†ç»„项时清掉只属于录入项的字段;分组项必须顶级,残留的 parentId ä¼šè¢«åŽç«¯å±‚级校验顶回来
        trigger: (values) => {
          if (values.itemType === MesQcIndicatorItemType.GROUP) {
            formApi?.setFieldValue('parentId', 0);
            formApi?.setFieldValue('resultType', undefined);
            formApi?.setFieldValue('resultSpecification', undefined);
          }
        },
      },
      rules: z.number().default(MesQcIndicatorItemType.ENTRY),
    },
    {
      fieldName: 'parentId',
      label: '所属分组',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        // çˆ¶çº§å€™é€‰åªæœ‰åˆ†ç»„项:分组项只能是顶级,所以这里是一层平铺,不需要 handleTree
        api: async () => {
          const data = await getIndicatorGroupList();
          return [
            {
              id: 0,
              name: '顶级指标',
              children: data,
            },
          ];
        },
        childrenField: 'children',
        labelField: 'name',
        placeholder: '请选择所属分组,不选则为顶级指标',
        treeDefaultExpandAll: true,
        treeNodeFilterProp: 'name',
        valueField: 'id',
      },
      dependencies: {
        triggerFields: ['itemType'],
        if: (values) => values.itemType !== MesQcIndicatorItemType.GROUP,
      },
      defaultValue: 0,
    },
    {
      fieldName: 'type',
      label: '检测项类型',
      component: 'Select',
@@ -83,6 +141,30 @@
      },
    },
    {
      fieldName: 'formulaText',
      label: '公式',
      component: 'Textarea',
      componentProps: {
        maxlength: 500,
        placeholder: '请输入公式,报告里作为该分组的检测要求展示',
        rows: 2,
      },
      dependencies: {
        triggerFields: ['itemType'],
        if: (values) => values.itemType === MesQcIndicatorItemType.GROUP,
      },
      rules: 'required',
    },
    {
      fieldName: 'sortOrder',
      label: '排序号',
      component: 'InputNumber',
      componentProps: {
        min: 0,
        placeholder: '留空自动排到同级末尾',
      },
    },
    {
      fieldName: 'resultType',
      label: '结果值类型',
      component: 'Select',
@@ -93,6 +175,11 @@
          formApi?.setFieldValue('resultSpecification', undefined),
        options: getDictOptions(DICT_TYPE.MES_QC_RESULT_TYPE, 'number'),
        placeholder: '请选择结果值类型',
      },
      // åˆ†ç»„项没有结果值,不展示也不校验(落库时后端同样会清空)
      dependencies: {
        triggerFields: ['itemType'],
        if: (values) => values.itemType !== MesQcIndicatorItemType.GROUP,
      },
      rules: 'required',
    },
@@ -147,6 +234,19 @@
      },
    },
    {
      fieldName: 'itemType',
      label: '条目类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: [
          { label: '录入项', value: MesQcIndicatorItemType.ENTRY },
          { label: '分组项', value: MesQcIndicatorItemType.GROUP },
        ],
        placeholder: '请选择条目类型',
      },
    },
    {
      fieldName: 'type',
      label: '检测项类型',
      component: 'Select',
@@ -183,6 +283,18 @@
      minWidth: 160,
    },
    {
      field: 'itemType',
      title: '条目类型',
      width: 100,
      slots: { default: 'itemType' },
    },
    {
      field: 'parentName',
      title: '所属分组',
      width: 140,
      slots: { default: 'parentName' },
    },
    {
      field: 'type',
      title: '检测项类型',
      width: 120,
src/views/mes/qc/indicator/index.vue
@@ -13,7 +13,7 @@
  exportIndicator,
  getIndicatorPage,
} from '#/api/mes/qc/indicator';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, MesQcIndicatorItemType } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '#/locales';
@@ -40,6 +40,14 @@
function getTypeLabel(type: number): string {
  const item = typeDictOptions.find((d) => d.value === type);
  return item?.label || String(type);
}
function getItemTypeColor(itemType: number): string {
  return itemType === MesQcIndicatorItemType.GROUP ? 'purple' : 'blue';
}
function getItemTypeLabel(itemType: number): string {
  return itemType === MesQcIndicatorItemType.GROUP ? '分组项' : '录入项';
}
/** åˆ·æ–°è¡¨æ ¼ */
@@ -133,6 +141,14 @@
          ]"
        />
      </template>
      <template #itemType="{ row }">
        <Tag v-if="row.itemType" :color="getItemTypeColor(row.itemType)">
          {{ getItemTypeLabel(row.itemType) }}
        </Tag>
      </template>
      <template #parentName="{ row }">
        {{ row.parentName || '-' }}
      </template>
      <template #type="{ row }">
        <Tag v-if="row.type !== undefined && row.type !== null" :color="getTypeColor(row.type)">
          {{ getTypeLabel(row.type) }}
src/views/mes/qc/ipqc/index.vue
@@ -11,9 +11,13 @@
import { deleteIpqc, exportIpqc, getIpqcPage } from '#/api/mes/qc/ipqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æœ¬é¡µå¯¹åº” mes_qc_type çš„值:2 è¿‡ç¨‹æ£€éªŒ */
const QC_TYPE = 2;
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -22,6 +26,11 @@
const [ExportRangeModal, exportRangeModalApi] = useVbenModal({
  connectedComponent: QcExportRangeModal,
  destroyOnClose: true,
});
const [ReportModal, reportModalApi] = useVbenModal({
  connectedComponent: QcReportGenerateModal,
  destroyOnClose: true,
});
@@ -71,6 +80,13 @@
    .open();
}
/** å‡ºæŠ¥å‘Šï¼šæ•°æ®ç”±åŽç«¯ä»Žè¯¥è´¨æ£€å•取,前端只选模板 */
function handleReport(row: MesQcIpqcApi.Ipqc) {
  reportModalApi
    .setData({ qcType: QC_TYPE, qcId: row.id, qcNo: row.code })
    .open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -106,6 +122,7 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <Grid table-title="过程检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +153,14 @@
        <TableAction
          :actions="[
            {
              label: '出报告',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['qc-report:instance:generate'],
              ifShow: row.status === MesQcStatusEnum.FINISHED,
              onClick: handleReport.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/iqc/index.vue
@@ -14,11 +14,15 @@
import { deleteIqc, exportIqc, getIqcPage } from '#/api/mes/qc/iqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'MesQcIqc' });
/** æœ¬é¡µå¯¹åº” mes_qc_type çš„值:1 æ¥æ–™æ£€éªŒ */
const QC_TYPE = 1;
const route = useRoute();
@@ -29,6 +33,11 @@
const [ExportRangeModal, exportRangeModalApi] = useVbenModal({
  connectedComponent: QcExportRangeModal,
  destroyOnClose: true,
});
const [ReportModal, reportModalApi] = useVbenModal({
  connectedComponent: QcReportGenerateModal,
  destroyOnClose: true,
});
@@ -78,6 +87,13 @@
    .open();
}
/** å‡ºæŠ¥å‘Šï¼šæ•°æ®ç”±åŽç«¯ä»Žè¯¥è´¨æ£€å•取,前端只选模板 */
function handleReport(row: MesQcIqcApi.Iqc) {
  reportModalApi
    .setData({ qcType: QC_TYPE, qcId: row.id, qcNo: row.code })
    .open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -120,6 +136,7 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <Grid table-title="来料检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -150,6 +167,14 @@
        <TableAction
          :actions="[
            {
              label: '出报告',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['qc-report:instance:generate'],
              ifShow: row.status === MesQcStatusEnum.FINISHED,
              onClick: handleReport.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/oqc/index.vue
@@ -11,9 +11,13 @@
import { deleteOqc, exportOqc, getOqcPage } from '#/api/mes/qc/oqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æœ¬é¡µå¯¹åº” mes_qc_type çš„值:3 å‡ºè´§æ£€éªŒ */
const QC_TYPE = 3;
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -22,6 +26,11 @@
const [ExportRangeModal, exportRangeModalApi] = useVbenModal({
  connectedComponent: QcExportRangeModal,
  destroyOnClose: true,
});
const [ReportModal, reportModalApi] = useVbenModal({
  connectedComponent: QcReportGenerateModal,
  destroyOnClose: true,
});
@@ -71,6 +80,13 @@
    .open();
}
/** å‡ºæŠ¥å‘Šï¼šæ•°æ®ç”±åŽç«¯ä»Žè¯¥è´¨æ£€å•取,前端只选模板 */
function handleReport(row: MesQcOqcApi.Oqc) {
  reportModalApi
    .setData({ qcType: QC_TYPE, qcId: row.id, qcNo: row.code })
    .open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -106,6 +122,7 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <Grid table-title="出货检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +153,14 @@
        <TableAction
          :actions="[
            {
              label: '出报告',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['qc-report:instance:generate'],
              ifShow: row.status === MesQcStatusEnum.FINISHED,
              onClick: handleReport.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/pendinginspect/index.vue
@@ -96,7 +96,7 @@
    checkQuantity: row.quantity,
    inspectDate: row.recordTime,
    itemId: row.itemId,
    name: `${row.sourceDocCode} é€€æ–™æ£€éªŒå•`,
    name: `${row.sourceDocCode} é€€è´§æ£€éªŒå•`,
    sourceDocCode: row.sourceDocCode,
    sourceDocId: row.sourceDocId,
    sourceDocType: row.sourceDocType,
@@ -170,7 +170,7 @@
              onClick: handleCreateOqc.bind(null, row),
            },
            {
              label: '退料检验',
              label: '退货检验',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['mes:qc-rqc:create'],
src/views/mes/qc/report/instance/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,165 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { QcReportInstanceApi } from '#/api/mes/qc/report/instance';
import type { QcReportTemplateApi } from '#/api/mes/qc/report/template';
import type { SystemUserApi } from '#/api/system/user';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getTemplatePage } from '#/api/mes/qc/report/template';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
/**
 * ä¸šåŠ¡å•æ®ç±»åž‹ â†’ å±•示名。
 * <p>
 * åŽç«¯æŠŠ businessType å®šä¹‰æˆè‡ªç”±å­—符串(没有枚举约束),这里只把已知的四种质检单翻成中文,
 * è®¤ä¸å‡ºçš„一律原样显示——比猜错成另一种单据要诚实。
 */
const BUSINESS_TYPE_LABEL: Record<string, string> = {
  mes_qc_iqc: '来料检验',
  mes_qc_ipqc: '过程检验',
  mes_qc_oqc: '出货检验',
  mes_qc_rqc: '退货检验',
};
export function businessTypeLabel(value?: string): string {
  if (!value) {
    return '';
  }
  return BUSINESS_TYPE_LABEL[value] ?? value;
}
/** å…³è”数据:实例表只存了模板编号与创建人编号,名称都要另查一次 */
let templateList: QcReportTemplateApi.Template[] = [];
getTemplatePage({ pageNo: 1, pageSize: 100 }).then(
  (data) => (templateList = data.list ?? []),
);
let userList: SystemUserApi.User[] = [];
getSimpleUserList().then((data) => (userList = data));
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'reportNo',
      label: '报告编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入报告编号',
      },
    },
    {
      fieldName: 'businessType',
      label: '业务类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: Object.entries(BUSINESS_TYPE_LABEL).map(([value, label]) => ({
          label,
          value,
        })),
        placeholder: '请选择业务类型',
      },
    },
    {
      fieldName: 'businessId',
      label: '业务单据',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入业务单据编号(精确匹配)',
      },
    },
    {
      fieldName: 'status',
      label: '报告状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_INSTANCE_STATUS, 'number'),
        placeholder: '请选择报告状态',
      },
    },
    {
      fieldName: 'createTime',
      label: '生成时间',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<QcReportInstanceApi.Instance>['columns'] {
  return [
    {
      field: 'reportNo',
      title: '报告编号',
      width: 180,
    },
    {
      field: 'templateId',
      title: '报告模板',
      minWidth: 200,
      formatter: ({ row }) =>
        templateList.find((item) => item.id === row.templateId)?.templateName ??
        `模板 #${row.templateId}`,
    },
    {
      field: 'templateVersion',
      title: '版本',
      width: 100,
      slots: { default: 'templateVersion' },
    },
    {
      field: 'businessType',
      title: '业务类型',
      width: 120,
      formatter: ({ row }) => businessTypeLabel(row.businessType),
    },
    {
      field: 'businessId',
      title: '业务单据',
      width: 180,
      formatter: ({ row }) => row.businessId || '—',
    },
    {
      field: 'status',
      title: '状态',
      width: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.QC_REPORT_INSTANCE_STATUS },
      },
    },
    {
      field: 'creator',
      title: '生成人',
      width: 120,
      formatter: ({ row }) =>
        userList.find((user) => String(user.id) === String(row.creator))
          ?.nickname ??
        row.creator ??
        '—',
    },
    {
      field: 'createTime',
      title: '生成时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/qc/report/instance/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { QcReportInstanceApi } from '#/api/mes/qc/report/instance';
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteInstance,
  getInstancePage,
} from '#/api/mes/qc/report/instance';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue';
defineOptions({ name: 'QcReportInstance' });
const route = useRoute();
const [DetailModal, detailModalApi] = useVbenModal({
  connectedComponent: Detail,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æŸ¥çœ‹æŠ¥å‘Šè¯¦æƒ…与预览 */
function handleDetail(row: QcReportInstanceApi.Instance) {
  detailModalApi.setData(row).open();
}
/** åˆ é™¤æŠ¥å‘Šå®žä¾‹ */
async function handleDelete(row: QcReportInstanceApi.Instance) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.reportNo]),
    duration: 0,
  });
  try {
    await deleteInstance(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.reportNo]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getInstancePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<QcReportInstanceApi.Instance>,
});
/**
 * ä»Žè´¨æ£€å•「出报告」跳过来时会带 businessType + businessId,预填到搜索条件里并直接查一次,
 * ç”¨æˆ·è¿›æ¥å°±çœ‹åˆ°åˆšå‡ºçš„那份报告,不用自己再筛一遍。
 * <p>
 * å¿…须跟着调 submitForm:表格取搜索条件是读 formApi.getLatestSubmissionValues(),
 * åª setFieldValue çš„话条件会填进表单但查询仍用旧值,列表还是全量——等于没筛。
 */
onMounted(async () => {
  const { businessType, businessId } = route.query;
  if (!businessType && !businessId) {
    return;
  }
  const values: Record<string, string> = {};
  if (businessType) {
    values.businessType = String(businessType);
  }
  if (businessId) {
    values.businessId = String(businessId);
  }
  await gridApi.formApi.setValues(values);
  await gridApi.formApi.submitForm();
});
</script>
<template>
  <Page auto-content-height>
    <DetailModal />
    <Grid table-title="报告实例列表">
      <template #templateVersion="{ row }">
        <Tag v-if="row.templateVersion" color="green">
          {{ row.templateVersion }}
        </Tag>
        <span v-else class="text-gray-400">—</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '预览',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['qc-report:instance:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['qc-report:instance:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.reportNo]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/qc/report/instance/modules/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<script lang="ts" setup>
import type { QcReportInstanceApi } from '#/api/mes/qc/report/instance';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatFileSize, openSafeUrl } from '@vben/utils';
import { DICT_TYPE } from '@vben/constants';
import { Alert, Button, Descriptions, message, Spin, Tabs, Tag } from 'ant-design-vue';
import {
  exportInstancePdf,
  getInstance,
  getInstanceSnapshot,
} from '#/api/mes/qc/report/instance';
import { DictTag } from '#/components/dict-tag';
import { businessTypeLabel } from '../data';
defineOptions({ name: 'QcReportInstanceDetail' });
const detail = ref<QcReportInstanceApi.InstanceDetail>();
const snapshot = ref<Record<string, unknown>>();
const activeTab = ref('preview');
const loading = ref(false);
const exporting = ref(false);
const pdf = ref<QcReportInstanceApi.PdfResult>();
/** äº§ç‰© HTML æ˜¯å‡ºä»¶æ—¶å†»ç»“下来的那一份,直接渲染即为预览,不必重新出件 */
const renderHtml = computed(() => detail.value?.renderHtml ?? '');
const snapshotText = computed(() =>
  snapshot.value ? JSON.stringify(snapshot.value, null, 2) : '',
);
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      detail.value = undefined;
      snapshot.value = undefined;
      pdf.value = undefined;
      activeTab.value = 'preview';
      return;
    }
    const data = modalApi.getData<QcReportInstanceApi.Instance>();
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    loading.value = true;
    try {
      // ä¸¤ä¸ªæŽ¥å£åˆ†å¼€å–:详情里有几 MB çš„产物 HTML,快照也不小,合成一个响应会让这份数据每次都被整包传输
      const [loadedDetail, loadedSnapshot] = await Promise.all([
        getInstance(data.id),
        getInstanceSnapshot(data.id),
      ]);
      detail.value = loadedDetail;
      snapshot.value = loadedSnapshot;
    } finally {
      loading.value = false;
      modalApi.unlock();
    }
  },
});
/** å¯¼å‡º PDF:后端会先删旧的归档附件再绑定新的,所以重复点不会在磁盘上堆文件 */
async function handleExportPdf() {
  if (!detail.value?.id) {
    return;
  }
  exporting.value = true;
  try {
    pdf.value = await exportInstancePdf(detail.value.id);
    message.success(
      `已生成 ${pdf.value.fileName}(${formatFileSize(pdf.value.byteSize)},耗时 ${pdf.value.durationMs} æ¯«ç§’)`,
    );
  } finally {
    exporting.value = false;
  }
}
function handleOpenPreview() {
  openSafeUrl(pdf.value?.previewURL);
}
function handleDownload() {
  openSafeUrl(pdf.value?.downloadURL);
}
</script>
<template>
  <Modal :title="`报告详情${detail?.reportNo ? ` - ${detail.reportNo}` : ''}`" class="w-4/5">
    <Spin :spinning="loading">
      <div class="flex flex-col gap-3">
        <Descriptions bordered size="small" :column="3">
          <Descriptions.Item label="报告编号">
            {{ detail?.reportNo || '—' }}
          </Descriptions.Item>
          <Descriptions.Item label="模板版本">
            <Tag v-if="detail?.templateVersion" color="green">
              {{ detail.templateVersion }}
            </Tag>
            <span v-else>—</span>
          </Descriptions.Item>
          <Descriptions.Item label="报告状态">
            <DictTag
              v-if="detail?.status !== undefined && detail?.status !== null"
              :type="DICT_TYPE.QC_REPORT_INSTANCE_STATUS"
              :value="detail.status"
            />
            <span v-else>—</span>
          </Descriptions.Item>
          <Descriptions.Item label="业务类型">
            {{ businessTypeLabel(detail?.businessType) || '—' }}
          </Descriptions.Item>
          <Descriptions.Item label="业务单据">
            {{ detail?.businessId || '—' }}
          </Descriptions.Item>
          <Descriptions.Item label="生成时间">
            {{ detail?.createTime || '—' }}
          </Descriptions.Item>
        </Descriptions>
        <Tabs v-model:active-key="activeTab" type="card">
          <Tabs.TabPane key="preview" tab="报告预览" force-render>
            <Alert
              v-if="!renderHtml"
              type="warning"
              show-icon
              message="这份报告没有可预览的产物"
              description="实例的渲染产物为空,通常意味着出件当时未成功。请回到列表确认报告状态,或重新出件。"
            />
            <!--
              sandbox ç•™ç©º = å½»åº•禁用脚本、表单与同源,只让 CSS ç”Ÿæ•ˆï¼š
              é¢„览的是渲染产物,不需要也不应该让它执行任何东西。
            -->
            <iframe
              v-else
              :srcdoc="renderHtml"
              sandbox=""
              class="h-[60vh] w-full rounded border border-border bg-white"
            ></iframe>
          </Tabs.TabPane>
          <Tabs.TabPane key="snapshot" tab="数据快照">
            <Alert
              class="mb-2"
              type="info"
              show-icon
              message="这是出件时冻结的业务数据,不会随后续单据修改而变化"
              description="查看「这份历史报告当时用的是哪些数据」请以这里为准。"
            />
            <pre
              class="max-h-[55vh] overflow-auto rounded border border-border bg-gray-50 p-3 text-xs"
              >{{ snapshotText || '(无快照数据)' }}</pre
            >
          </Tabs.TabPane>
        </Tabs>
        <Alert v-if="pdf" type="success" show-icon>
          <template #message>
            <div class="text-xs">
              <div>
                å·²ç”Ÿæˆ {{ pdf.fileName }}({{ formatFileSize(pdf.byteSize) }},打印耗时
                {{ pdf.pdfDurationMs }} æ¯«ç§’)
              </div>
              <div class="mt-1 flex gap-2">
                <Button size="small" @click="handleOpenPreview">在线预览</Button>
                <Button size="small" type="primary" @click="handleDownload">
                  ä¸‹è½½ PDF
                </Button>
              </div>
            </div>
          </template>
        </Alert>
      </div>
    </Spin>
    <template #footer>
      <div class="flex justify-end gap-2">
        <Button @click="modalApi.close()">关闭</Button>
        <Button
          type="primary"
          :loading="exporting"
          :disabled="!detail?.id"
          @click="handleExportPdf"
        >
          <IconifyIcon icon="lucide:file-down" class="mr-1" />
          å¯¼å‡º PDF
        </Button>
      </div>
    </template>
  </Modal>
</template>
src/views/mes/qc/report/template/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,229 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { QcReportTemplateApi } from '#/api/mes/qc/report/template';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { DEFAULT_PAGE } from './designer/constants';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'templateCode',
      label: '模板编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入模板编码(字母/数字/下划线/中划线)',
        maxlength: 64,
      },
      rules: 'required',
    },
    {
      fieldName: 'templateName',
      label: '模板名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入模板名称',
        maxlength: 128,
      },
      rules: 'required',
    },
    {
      fieldName: 'industry',
      label: '适用行业',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_INDUSTRY, 'string'),
        placeholder: '请选择适用行业',
      },
    },
    {
      fieldName: 'reportType',
      label: '报告类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_QC_TYPE, 'string'),
        placeholder: '请选择报告类型',
      },
    },
    {
      fieldName: 'pageSize',
      label: '纸张尺寸',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_PAGE_SIZE, 'string'),
        placeholder: '请选择纸张尺寸',
      },
      // ç»å¤§å¤šæ•°è´¨æ£€æŠ¥å‘Šéƒ½æ˜¯ A4 çºµå‘,默认带出,省得每次选
      defaultValue: DEFAULT_PAGE.size,
    },
    {
      fieldName: 'orientation',
      label: '纸张方向',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_ORIENTATION, 'string'),
        placeholder: '请选择纸张方向',
      },
      defaultValue: DEFAULT_PAGE.orientation,
    },
    {
      fieldName: 'status',
      label: '模板状态',
      component: 'RadioGroup',
      componentProps: {
        buttonStyle: 'solid',
        optionType: 'button',
        options: getDictOptions(DICT_TYPE.QC_REPORT_TEMPLATE_STATUS, 'number'),
      },
      defaultValue: 0,
      rules: 'required',
    },
    {
      fieldName: 'description',
      label: '模板说明',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入模板说明',
        rows: 3,
        maxlength: 255,
        showCount: true,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'templateCode',
      label: '模板编码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入模板编码',
      },
    },
    {
      fieldName: 'templateName',
      label: '模板名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入模板名称',
      },
    },
    {
      fieldName: 'industry',
      label: '适用行业',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_INDUSTRY, 'string'),
        placeholder: '请选择适用行业',
      },
    },
    {
      fieldName: 'reportType',
      label: '报告类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_QC_TYPE, 'string'),
        placeholder: '请选择报告类型',
      },
    },
    {
      fieldName: 'status',
      label: '模板状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.QC_REPORT_TEMPLATE_STATUS, 'number'),
        placeholder: '请选择模板状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<QcReportTemplateApi.Template>['columns'] {
  return [
    {
      field: 'templateCode',
      title: '模板编码',
      width: 160,
    },
    {
      field: 'templateName',
      title: '模板名称',
      minWidth: 200,
    },
    {
      field: 'industry',
      title: '适用行业',
      width: 120,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.QC_REPORT_INDUSTRY },
      },
    },
    {
      field: 'reportType',
      title: '报告类型',
      width: 140,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_QC_TYPE },
      },
    },
    {
      field: 'pageSize',
      title: '纸张',
      width: 100,
      slots: { default: 'pageSetting' },
    },
    {
      field: 'currentVersion',
      title: '当前版本',
      width: 110,
      slots: { default: 'currentVersion' },
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.QC_REPORT_TEMPLATE_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 280,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/qc/report/template/designer/constants.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
import { resolvePageMm } from '#/components/quality/engine/page';
/**
 * å½“前 Schema ç‰ˆæœ¬å·ï¼Œä¸ŽåŽç«¯ ReportTemplateSchema.SCHEMA_VERSION å¿…须一致
 * 1.1 åˆ é™¤äº†ä»Žæœªä½¿ç”¨çš„ dataSources / bindings / styles,详见 version/index.ts çš„契约说明
 */
export const SCHEMA_VERSION = '1.1';
/** CSS åƒç´ ä¸Žæ¯«ç±³çš„æ¢ç®—:1mm = 96/25.4 px */
export const MM_TO_PX = 96 / 25.4;
/** ç”»å¸ƒä¸Šçº¸å¼ çš„颜色,仅作视觉参考 */
export const PAPER_COLOR = '#ffffff';
/** é»˜è®¤çº¸å¼ é…ç½® */
export const DEFAULT_PAGE: QcReportVersionApi.SchemaPage = {
  size: 'A4',
  orientation: 'portrait',
  margin: { top: 10, right: 10, bottom: 10, left: 10 },
};
/** è®¡ç®—画布尺寸(px),按纸张方向自动翻转宽高 */
export function resolveCanvasSize(page?: QcReportVersionApi.SchemaPage): {
  height: number;
  marginPx: Required<QcReportVersionApi.SchemaMargin>;
  width: number;
} {
  const { heightMm, marginMm, widthMm } = resolvePageMm(page);
  return {
    width: Math.round(widthMm * MM_TO_PX),
    height: Math.round(heightMm * MM_TO_PX),
    marginPx: {
      top: marginMm.top * MM_TO_PX,
      right: marginMm.right * MM_TO_PX,
      bottom: marginMm.bottom * MM_TO_PX,
      left: marginMm.left * MM_TO_PX,
    },
  };
}
/** ç”±æ¨¡æ¿å…ƒæ•°æ®æŽ¨å¯¼åˆå§‹ Schema(新建版本时使用) */
export function createEmptySchema(
  page?: QcReportVersionApi.SchemaPage,
): QcReportVersionApi.ReportTemplateSchema {
  return {
    schemaVersion: SCHEMA_VERSION,
    page: { ...DEFAULT_PAGE, ...page },
    grapes: {},
    components: [],
    rules: [],
  };
}
src/views/mes/qc/report/template/designer/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,637 @@
<script lang="ts" setup>
import type { QcReportAiApi } from '#/api/mes/qc/report/ai';
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
import type { QualityAssemblySkip } from '#/components/quality';
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Empty,
  InputNumber,
  message,
  Modal,
  Select,
  Spin,
  Tag,
  Tooltip,
} from 'ant-design-vue';
import {
  createVersion,
  getVersion,
  getVersionListByTemplate,
  publishVersion,
  updateVersion,
} from '#/api/mes/qc/report/version';
import { getTemplate, updateTemplate } from '#/api/mes/qc/report/template';
import { validateRule } from '#/components/quality';
import { router } from '#/router';
import { DEFAULT_PAGE } from './constants';
import AiImportModal from './modules/ai-import-modal.vue';
import RuleEditor from './modules/rule-editor.vue';
import { useDesigner } from './use-designer';
defineOptions({ name: 'QcReportTemplateDesigner' });
const props = defineProps<{
  templateId?: number | string;
}>();
const tabs = useTabs();
const { hasAccessByCodes } = useAccess();
/** AI å¯¼å…¥æ˜¯ç‹¬ç«‹æƒé™ç ï¼šèƒ½æ”¹æ¨¡æ¿ä¸ç­‰äºŽèƒ½ç”¨å¤§æ¨¡åž‹è¯†åˆ«æ–‡ä»¶ï¼ˆæ¯æ¬¡è°ƒç”¨éƒ½è¦èŠ±é’±ï¼‰ */
const canAiImport = computed(() =>
  hasAccessByCodes(['qc-report:template:ai-import']),
);
const loading = ref(true);
const saving = ref(false);
const publishing = ref(false);
const ruleDrawerOpen = ref(false);
const aiImportOpen = ref(false);
const template = ref<Awaited<ReturnType<typeof getTemplate>>>();
const versions = ref<QcReportVersionApi.Version[]>([]);
const activeVersionId = ref<number>();
const pageSetting = ref<QcReportVersionApi.SchemaPage>({ ...DEFAULT_PAGE });
const blockPanelRef = ref<HTMLElement>();
const canvasRef = ref<HTMLElement>();
const traitPanelRef = ref<HTMLElement>();
const stylePanelRef = ref<HTMLElement>();
const {
  canRedo,
  canUndo,
  editor,
  rules,
  selectedLabel,
  zoom,
  applyPageSize,
  collectSchema,
  destroy,
  init,
  loadCanvasFromDraft,
  loadSchema,
  redo,
  setZoom,
  undo,
} = useDesigner();
const activeVersion = computed(() =>
  versions.value.find((item) => item.id === activeVersionId.value),
);
const isDraft = computed(() => activeVersion.value?.status === 0);
const templateName = computed(() => template.value?.templateName ?? '');
/** å†™ä¸é€šçš„规则条数。规则写坏了要到出件时才炸,顶栏得先给出提示 */
const ruleErrorCount = computed(
  () => rules.value.filter((rule) => validateRule(rule).length > 0).length,
);
/**
 * ä¿å­˜å‰å…ˆå¡ä¸€é“。
 * <p>
 * åŽç«¯ä¹Ÿä¼šæ‹¦ï¼Œä½†æŠ¥é”™å¼¹åˆ°ç•Œé¢ä¸Šæ—¶ç”¨æˆ·å·²ç»ç¦»å¼€æŠ½å±‰äº†ï¼Œè¿˜å¾—自己翻是哪条规则写坏的;
 * è¿™é‡Œç›´æŽ¥æŠŠæŠ½å±‰æ‰“开,人一进来就看到红字标在哪一条上。
 */
function blockOnInvalidRules(): boolean {
  if (ruleErrorCount.value === 0) {
    return false;
  }
  ruleDrawerOpen.value = true;
  message.warning(`有 ${ruleErrorCount.value} æ¡åˆ¤å®šè§„则还没写完整,请先修正再保存`);
  return true;
}
const ruleButtonText = computed(() =>
  rules.value.length > 0 ? `判定规则(${rules.value.length})` : '判定规则',
);
/** æŠ½å±‰é‡Œæ”¹å®Œçš„规则写回设计器状态,保存时由 collectSchema ä¸€å¹¶äº§å‡º */
function handleRulesChange(next: QcReportVersionApi.SchemaRule[]) {
  rules.value = next;
}
/** ç”»å¸ƒä¸Šå·²æœ‰çš„顶层组件数,用来判断 AI å¯¼å…¥ä¼šä¸ä¼šè¦†ç›–掉用户正在做的内容 */
const canvasNodeCount = computed(() => editor.value?.getComponents().length ?? 0);
/**
 * AI è‰ç¨¿è½åœ°ã€‚
 * <p>
 * è‰ç¨¿çŒœå‡ºçš„纸张要并进 pageSetting:保存时写的是 pageSetting.value,
 * ä¸åŒæ­¥çš„话 AI è®¤å‡ºæ¥çš„纸张会在下一次保存时被旧值盖回去。
 */
function handleApplyDraft(payload: {
  components?: QcReportAiApi.DraftComponent[];
  page?: QcReportAiApi.DraftPage;
}) {
  const components = payload.components ?? [];
  if (components.length === 0) {
    message.warning('草稿里没有可用的组件,请重新识别');
    return;
  }
  const mergedPage: QcReportVersionApi.SchemaPage = {
    size: payload.page?.size ?? pageSetting.value.size,
    orientation: payload.page?.orientation ?? pageSetting.value.orientation,
    margin: { ...pageSetting.value.margin, ...payload.page?.margin },
  };
  const applyDraft = () => {
    pageSetting.value = mergedPage;
    const { nodeCount, problems, skipped } = loadCanvasFromDraft(
      components,
      mergedPage,
    );
    reportDraftIssues(nodeCount, problems, skipped);
  };
  if (canvasNodeCount.value > 0) {
    Modal.confirm({
      title: '替换画布内容?',
      content: `画布上已有 ${canvasNodeCount.value} ä¸ªç»„件,将被 AI è¯†åˆ«ç»“果整体替换,且无法用撤销恢复。建议先把当前内容保存为新版本。`,
      onOk: applyDraft,
    });
    return;
  }
  applyDraft();
}
/** è£…配结果里的跳过项与问题项统一汇报;逐条弹 message ä¼šåˆ·æ»¡å±å¹•,攒成一个弹窗更好读 */
function reportDraftIssues(
  nodeCount: number,
  problems: string[],
  skipped: QualityAssemblySkip[],
) {
  if (nodeCount === 0) {
    message.error('AI è¯†åˆ«å‡ºçš„组件设计器都不认识,画布未改动');
    return;
  }
  message.success(`已生成 ${nodeCount} ä¸ªç»„件到画布,确认无误后点「保存」`);
  if (problems.length === 0 && skipped.length === 0) {
    return;
  }
  Modal.warning({
    title: `有 ${skipped.length + problems.length} å¤„需要留意`,
    width: 560,
    content: h(
      'div',
      { style: 'max-height: 40vh; overflow: auto; font-size: 12px;' },
      [
        ...skipped.map((item) => h('div', `· ${item.reason}`)),
        ...problems.map((text) => h('div', `· ${text}`)),
      ],
    ),
  });
}
const VERSION_STATUS_LABEL: Record<number, string> = {
  0: '草稿',
  1: '已发布',
  2: '已停用',
};
const ZOOM_OPTIONS = [50, 75, 100, 125, 150].map((value) => ({
  label: `${value}%`,
  value,
}));
/** åº”用纸张设置:只重排画布尺寸与页边距参考线,不覆盖画布上未保存的内容 */
function applyPageSetting() {
  applyPageSize(pageSetting.value);
}
/** åŠ è½½æ¨¡æ¿ä¸Žå…¶ç‰ˆæœ¬åˆ—è¡¨ */
async function loadTemplate() {
  const id = Number(props.templateId);
  if (!Number.isFinite(id) || id <= 0) {
    message.error('缺少模板编号,无法进入设计器');
    loading.value = false;
    return;
  }
  loading.value = true;
  try {
    const detail = await getTemplate(id);
    // æ¨¡æ¿å¯èƒ½å·²è¢«ä»–人删除,或链接是过期/伪造的,这里必须兜住空响应
    if (!detail) {
      message.error('该报告模板不存在或已被删除,无法进入设计器');
      handleBack();
      return;
    }
    template.value = detail;
    pageSetting.value = {
      size: detail.pageSize ?? DEFAULT_PAGE.size,
      orientation: detail.orientation ?? DEFAULT_PAGE.orientation,
      margin: { ...DEFAULT_PAGE.margin },
    };
    versions.value = await getVersionListByTemplate(id);
    // é»˜è®¤åœåœ¨æœ€æ–°ç‰ˆæœ¬ï¼›æ²¡æœ‰ç‰ˆæœ¬æ—¶ç•™ç©ºï¼Œç”±ç”¨æˆ·ç‚¹å‡»â€œä¿å­˜â€ç”Ÿæˆ v1.0
    const latest = versions.value[0];
    activeVersionId.value = latest?.id;
    const schema = latest ? await getVersion(latest.id!) : undefined;
    if (schema?.schema?.page) {
      pageSetting.value = schema.schema.page;
    }
    await nextTick();
    loadSchema(schema?.schema);
  } finally {
    loading.value = false;
  }
}
/** åˆ‡æ¢ç‰ˆæœ¬ï¼ˆAntDV çš„ Select å›žè°ƒå€¼ç±»åž‹æ¯” number å®½ï¼Œè¿™é‡Œç»Ÿä¸€æ”¶æ•›ï¼‰ */
async function handleVersionChange(value: unknown) {
  const id = Number(value);
  if (!Number.isFinite(id)) {
    return;
  }
  const detail = await getVersion(id);
  if (!detail) {
    message.error('该版本不存在或已被删除,请重新选择版本');
    await refreshVersions();
    return;
  }
  activeVersionId.value = id;
  if (detail.schema?.page) {
    pageSetting.value = detail.schema.page;
  }
  // è‰ç¨¿å¯èƒ½è¿˜æ²¡å†™å…¥è¿‡å†…容(schema ä¸ºç©ºï¼‰ï¼ŒæŒ‰ç©ºç™½ç”»å¸ƒè½½å…¥å³å¯ï¼Œä¸ç®—错误
  loadSchema(detail.schema ?? undefined);
}
/** ä¿å­˜ï¼šè‰ç¨¿ç›´æŽ¥è¦†ç›–,已发布版本另存为新版本 */
async function handleSave() {
  const id = Number(props.templateId);
  if (!Number.isFinite(id) || id <= 0) {
    return;
  }
  if (blockOnInvalidRules()) {
    return;
  }
  saving.value = true;
  try {
    const baseSchema = collectSchema(pageSetting.value);
    const current = activeVersion.value;
    if (current?.id && current.status === 0) {
      await updateVersion({ id: current.id, schema: baseSchema });
      message.success(`已保存到 ${current.version}`);
    } else {
      const newId = await createVersion({
        templateId: id,
        schema: baseSchema,
        description: current ? `基于 ${current.version} åˆ›å»º` : undefined,
      });
      message.success('已保存为新版本草稿');
      await refreshVersions();
      activeVersionId.value = newId;
    }
    /*
     * çº¸å¼ è®¾ç½®å±žäºŽæ¨¡æ¿çº§å±žæ€§ï¼ŒåŒæ­¥å›žæ¨¡æ¿ï¼Œåˆ—表页据此展示。
     * è¿™ä¸ªæŽ¥å£æ˜¯æ•´ä½“更新,模板编码/名称等必填字段不带上会被校验打回来
     * ï¼ˆã€Œæ¨¡æ¿ç¼–码不能为空」),所以要把模板原有字段一并带过去,只覆盖纸张两项。
     */
    await updateTemplate({
      ...template.value,
      id,
      pageSize: pageSetting.value.size,
      orientation: pageSetting.value.orientation,
    });
    template.value = await getTemplate(id);
  } finally {
    saving.value = false;
  }
}
async function refreshVersions() {
  const id = Number(props.templateId);
  versions.value = await getVersionListByTemplate(id);
}
/** å‘布当前版本 */
function handlePublish() {
  const current = activeVersion.value;
  if (!current?.id) {
    message.warning('请先保存草稿,再发布版本');
    return;
  }
  if (current.status !== 0) {
    message.warning(`当前版本已是「${VERSION_STATUS_LABEL[current.status ?? 0]}」,无需重复发布`);
    return;
  }
  // å‘布等于把当前内容固化成不可改的版本,规则写坏了这会儿拦下来最省事
  if (blockOnInvalidRules()) {
    return;
  }
  Modal.confirm({
    title: '确认发布该版本?',
    content: `发布后 ${current.version} çš„内容将不可再修改,如需调整请另存新版本。`,
    async onOk() {
      publishing.value = true;
      try {
        // å‘布前先把画布最新内容落库,避免发布的是旧内容
        await updateVersion({ id: current.id!, schema: collectSchema(pageSetting.value) });
        await publishVersion(current.id!);
        await refreshVersions();
        message.success(`版本 ${current.version} å·²å‘布`);
      } finally {
        publishing.value = false;
      }
    },
  });
}
/** ç”»å¸ƒç¼©æ”¾ï¼ˆSelect å›žè°ƒå€¼ç±»åž‹æ¯” number å®½ï¼Œè¿™é‡Œç»Ÿä¸€æ”¶æ•›ï¼‰ */
function handleZoomChange(value: unknown) {
  const next = Number(value);
  if (Number.isFinite(next)) {
    setZoom(next);
  }
}
/** è¿”回列表页 */
function handleBack() {
  tabs.closeCurrentTab();
  router.push('/qc/report-template');
}
onMounted(async () => {
  await nextTick();
  if (!canvasRef.value || !blockPanelRef.value || !traitPanelRef.value || !stylePanelRef.value) {
    return;
  }
  init({
    canvasContainer: canvasRef.value,
    blockContainer: blockPanelRef.value,
    traitContainer: traitPanelRef.value,
    styleContainer: stylePanelRef.value,
  });
  await loadTemplate();
});
onBeforeUnmount(() => {
  destroy();
});
</script>
<template>
  <Page auto-content-height>
    <div class="qc-report-designer flex h-full flex-col">
      <!-- é¡¶æ  -->
      <div class="flex items-center justify-between border-b border-border bg-card px-3 py-2">
        <div class="flex items-center gap-2">
          <Button size="small" @click="handleBack">
            <IconifyIcon icon="lucide:arrow-left" class="mr-1" />
            è¿”回
          </Button>
          <span class="font-medium">{{ templateName || '报告模板设计器' }}</span>
          <Select
            v-if="versions.length > 0"
            :value="activeVersionId"
            :options="
              versions.map((item) => ({
                label: `${item.version}(${VERSION_STATUS_LABEL[item.status ?? 0]})`,
                value: item.id,
              }))
            "
            class="w-44"
            size="small"
            @change="handleVersionChange"
          />
          <Tag v-else color="default">尚无版本</Tag>
          <Tag v-if="activeVersion" :color="isDraft ? 'blue' : 'green'">
            {{ isDraft ? '可编辑' : '只读' }}
          </Tag>
          <Button v-if="canAiImport" size="small" @click="aiImportOpen = true">
            <IconifyIcon icon="lucide:sparkles" class="mr-1" />
            AI å¯¼å…¥
          </Button>
          <Button size="small" @click="ruleDrawerOpen = true">
            <IconifyIcon icon="lucide:list-checks" class="mr-1" />
            {{ ruleButtonText }}
          </Button>
          <Tag v-if="ruleErrorCount > 0" color="red">
            {{ ruleErrorCount }} æ¡è§„则待修正
          </Tag>
        </div>
        <div class="flex items-center gap-2">
          <Tooltip title="撤销">
            <Button size="small" :disabled="!canUndo" @click="undo">
              <IconifyIcon icon="lucide:undo-2" />
            </Button>
          </Tooltip>
          <Tooltip title="重做">
            <Button size="small" :disabled="!canRedo" @click="redo">
              <IconifyIcon icon="lucide:redo-2" />
            </Button>
          </Tooltip>
          <Select
            :value="zoom"
            :options="ZOOM_OPTIONS"
            size="small"
            class="w-24"
            @change="handleZoomChange"
          />
          <span v-if="selectedLabel" class="text-xs text-gray-500">
            å·²é€‰ä¸­ï¼š{{ selectedLabel }}
          </span>
          <Button size="small" type="primary" :loading="saving" @click="handleSave">
            <IconifyIcon icon="lucide:save" class="mr-1" />
            ä¿å­˜
          </Button>
          <Button
            size="small"
            :loading="publishing"
            :disabled="!isDraft"
            @click="handlePublish"
          >
            <IconifyIcon icon="lucide:send" class="mr-1" />
            å‘布
          </Button>
        </div>
      </div>
      <!-- çº¸å¼ è®¾ç½® -->
      <div class="flex items-center gap-3 border-b border-border bg-card px-3 py-1.5 text-xs">
        <span class="text-gray-500">纸张</span>
        <Select
          v-model:value="pageSetting.size"
          :options="[
            { label: 'A3', value: 'A3' },
            { label: 'A4', value: 'A4' },
            { label: 'A5', value: 'A5' },
            { label: 'Letter', value: 'Letter' },
          ]"
          size="small"
          class="w-24"
          @change="applyPageSetting"
        />
        <Select
          v-model:value="pageSetting.orientation"
          :options="[
            { label: '纵向', value: 'portrait' },
            { label: '横向', value: 'landscape' },
          ]"
          size="small"
          class="w-24"
          @change="applyPageSetting"
        />
        <span class="text-gray-500">页边距(mm)</span>
        <InputNumber
          v-model:value="pageSetting.margin!.top"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="上"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.right"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="右"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.bottom"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="下"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.left"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="å·¦"
        />
        <Button size="small" type="link" @click="applyPageSetting">应用到画布</Button>
      </div>
      <!-- è®¾è®¡åŒº -->
      <Spin :spinning="loading" wrapper-class-name="flex-1 overflow-hidden">
        <div class="flex h-full">
          <div class="w-56 shrink-0 overflow-auto border-r border-border bg-card p-2">
            <div class="mb-2 text-xs font-medium text-gray-500">组件</div>
            <div ref="blockPanelRef" class="qc-report-blocks"></div>
          </div>
          <div class="flex-1 overflow-hidden bg-gray-100">
            <div ref="canvasRef" class="h-full"></div>
          </div>
          <div class="w-72 shrink-0 overflow-auto border-l border-border bg-card p-2">
            <div class="mb-2 text-xs font-medium text-gray-500">属性</div>
            <div ref="traitPanelRef"></div>
            <div class="mt-3 mb-2 text-xs font-medium text-gray-500">样式</div>
            <div ref="stylePanelRef"></div>
            <Empty
              v-if="!selectedLabel"
              :image="Empty.PRESENTED_IMAGE_SIMPLE"
              description="选中画布中的组件以编辑属性"
              class="mt-2"
            />
          </div>
        </div>
      </Spin>
      <RuleEditor
        v-model:open="ruleDrawerOpen"
        :rules="rules"
        @update:rules="handleRulesChange"
      />
      <AiImportModal
        v-model:open="aiImportOpen"
        :template-id="Number(props.templateId)"
        @apply="handleApplyDraft"
      />
    </div>
  </Page>
</template>
<style scoped>
/*
 * GrapesJS è‡ªå¸¦çš„æ˜¯æ·±è‰²ä¸»é¢˜ï¼Œè¿™é‡ŒæŠŠå®ƒå¯¹é½ Ant Design çš„æµ…色主题。
 * å˜é‡æŒ‚在页面根节点上:组件面板 / å±žæ€§é¢æ¿æŒ‚在编辑器容器之外,只有从共同祖先继承才生效。
 */
.qc-report-designer {
  --gjs-main-color: #f5f5f5;
  --gjs-primary-color: #ffffff;
  --gjs-secondary-color: #333333;
  --gjs-tertiary-color: #1677ff;
  --gjs-quaternary-color: #1677ff;
  --gjs-font-color: #333333;
  --gjs-font-color-active: #1677ff;
  --gjs-main-dark-color: rgba(0, 0, 0, 6%);
  --gjs-secondary-dark-color: rgba(0, 0, 0, 4%);
  --gjs-main-light-color: rgba(0, 0, 0, 6%);
  --gjs-secondary-light-color: rgba(0, 0, 0, 55%);
  --gjs-soft-light-color: rgba(0, 0, 0, 2%);
  --gjs-light-border: rgba(0, 0, 0, 8%);
  --gjs-arrow-color: rgba(0, 0, 0, 55%);
  --gjs-dark-text-shadow: rgba(0, 0, 0, 10%);
  /* é¡¶æ ä¸Žå·¦ä¾§ç»„件面板都由本页面自绘,画布占满编辑器容器 */
  --gjs-canvas-top: 0px;
  --gjs-left-width: 0%;
}
/*
 * Ant çš„ Spin åœ¨ä¸­é—´å¥—了一层 .ant-spin-container,它默认是内容高度而非撑满父级,
 * ä¼šæŠŠå·¦å³ä¸¤æ æ’‘得比可视区高,导致两栏的 overflow:auto æ°¸è¿œä¸è§¦å‘、底部被裁掉。
 * è¿™é‡ŒæŠŠå®ƒæŽ¥å›žçˆ¶çº§çš„ flex é«˜åº¦ï¼Œå·¦å³æ æ‰èƒ½æ­£å¸¸æ»šåŠ¨ã€‚
 */
:deep(.ant-spin-container) {
  height: 100%;
}
/* è®© GrapesJS é»˜è®¤é¢æ¿è´´åˆå³ä¾§æ å®½åº¦ */
:deep(.gjs-trt-traits),
:deep(.gjs-sm-sectors) {
  font-size: 12px;
}
:deep(.gjs-cv-canvas) {
  overflow: auto;
  background-color: #f0f0f0;
}
/*
 * GrapesJS çš„画布默认是一块 overflow:hidden çš„绝对定位舞台,纸张比可视区大时会被裁掉。
 * è¿™é‡ŒæŠŠç”»å¸ƒæ”¹æˆæ™®é€šæ»šåŠ¨å®¹å™¨ï¼šå¸§å®¹å™¨å›žå½’æ–‡æ¡£æµï¼Œçº¸å¼ ç”¨ margin å±…中,超出即可滚动。
 */
:deep(.gjs-cv-canvas__frames),
:deep(.gjs-frames) {
  position: static;
  width: auto;
  height: auto;
}
:deep(.gjs-frame-wrapper) {
  position: relative;
  top: auto !important;
  left: auto !important;
  margin: 24px auto;
}
:deep(.gjs-block) {
  width: calc(50% - 6px);
  min-height: auto;
  padding: 6px 4px;
  font-size: 12px;
}
:deep(.gjs-block-label) {
  font-size: 12px;
}
</style>
src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,341 @@
<script lang="ts" setup>
/**
 * AI å¯¼å…¥å¼¹çª—:上传一份已有的检验报告 â†’ è¯†åˆ«æˆæ¨¡æ¿è‰ç¨¿ â†’ äº¤ç»™è®¾è®¡å™¨ã€‚
 * <p>
 * å¼¹çª—只负责「拿草稿」这一段:上传、绑附件、请求识别、给用户看结果。
 * è£…到画布上是设计器的事,由父组件在用户确认后来做——
 * ç”»å¸ƒéžç©ºæ—¶è¦å…ˆå¼¹æ›¿æ¢ç¡®è®¤ï¼Œé‚£ä»½åˆ¤æ–­å±žäºŽè®¾è®¡å™¨è€Œä¸æ˜¯è¿™é‡Œã€‚
 */
import type { QcReportAiApi } from '#/api/mes/qc/report/ai';
import type { UploadFile, UploadProps } from 'ant-design-vue';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, Input, message, Modal, Spin, Tag, Upload } from 'ant-design-vue';
import { generateAiDraft } from '#/api/mes/qc/report/ai';
import {
  bindAttachments,
  deleteAttachments,
  listAttachments,
  uploadFile,
} from '#/api/system/storage';
import {
  buildQualityComponentCatalog,
  getQualityComponent,
  registerAllQualityComponents,
} from '#/components/quality';
import { SCHEMA_VERSION } from '../constants';
defineOptions({ name: 'QcReportAiImportModal' });
const props = defineProps<{
  templateId?: number;
}>();
const open = defineModel<boolean>('open', { default: false });
const emit = defineEmits<{
  /** ç”¨æˆ·ç¡®è®¤æŠŠè‰ç¨¿è£…到画布上 */
  apply: [
    payload: {
      components?: QcReportAiApi.DraftComponent[];
      page?: QcReportAiApi.DraftPage;
    },
  ];
}>();
/** ä¸ŽåŽç«¯ yudao.qcreport.ai-import.max-files ä¸€è‡´ï¼›è¿™é‡Œåªåšç•Œé¢ä¸Šçš„即时约束,权威校验在后端 */
const MAX_FILES = 3;
const ACCEPT =
  '.pdf,.docx,.xlsx,.png,.jpg,.jpeg,.bmp,.gif,.webp,.txt,.csv';
const fileList = ref<UploadFile[]>([]);
const hint = ref('');
const recognizing = ref(false);
const draft = ref<QcReportAiApi.DraftResp>();
/** Upload å†…部持有的文件对象类型(RcFile,继承自浏览器的 File) */
type UploadedFile = NonNullable<UploadFile['originFileObj']>;
/** å·²é€‰æ–‡ä»¶æ•°ï¼›AntDV çš„ fileList é‡Œå¯èƒ½æ··ç€ä¸Šä¼ å¤±è´¥é¡¹ï¼Œè¿™é‡Œåªè®¤æœ‰å®žä½“文件的 */
const selectedFiles = computed(() =>
  fileList.value
    .map((item) => item.originFileObj)
    .filter((file): file is UploadedFile => Boolean(file)),
);
const canRecognize = computed(
  () => !recognizing.value && selectedFiles.value.length > 0,
);
/** ç»„件草稿的展示行:类型展示名 + è®¤å¾—出来的属性 */
const draftRows = computed(() =>
  (draft.value?.components ?? []).map((component, index) => {
    const definition = getQualityComponent(component.type);
    const propsText = Object.entries(component.props ?? {})
      .filter(([, value]) => value !== undefined && value !== '')
      .map(([key, value]) => `${fieldLabel(component.type, key)}=${String(value)}`)
      .join(',');
    return {
      index: index + 1,
      label: definition?.label ?? component.type ?? '(未知组件)',
      type: component.type ?? '',
      registered: Boolean(definition),
      propsText,
    };
  }),
);
const unknownCount = computed(
  () => draftRows.value.filter((row) => !row.registered).length,
);
/** è½¯æç¤ºï¼šè¢«è·³è¿‡çš„页、无法表达的区块等 */
const warnings = computed(() => draft.value?.warnings ?? []);
const durationText = computed(() =>
  draft.value?.durationMs ? `${(draft.value.durationMs / 1000).toFixed(1)} ç§’` : '',
);
/** å±žæ€§ key â†’ å­—段显示名;认不出定义时退回 key æœ¬èº« */
function fieldLabel(type: string | undefined, key: string): string {
  const definition = getQualityComponent(type);
  return (
    definition?.propertySchema.find((field) => field.key === key)?.label ?? key
  );
}
/** äº¤ç»™ Upload è‡ªå·±æ”¶é›†æ–‡ä»¶ï¼Œä¸èµ°å®ƒå†…置的上传通道(上传时机由用户点按钮决定) */
const beforeUpload: UploadProps['beforeUpload'] = () => false;
function reset() {
  fileList.value = [];
  hint.value = '';
  draft.value = undefined;
}
watch(open, (value) => {
  if (value) {
    reset();
  }
});
/**
 * æ¸…掉模板上已挂着的源文件。
 * <p>
 * è¯­ä¹‰æ˜¯**替换**而不是累积:一次导入对应一份源文件,新的进来、旧的出去。这些文件只是识别用的输入,
 * æ¨¡æ¿çœŸæ­£çš„产物是版本 Schema,没有归档它们的必要。
 * <p>
 * å¿…须在这一步删,不能指望绑定时顺手清掉 â€”— ç»‘定的实现是「只删旧的附件关联记录、保留 blob è®°å½•
 * ï¼ˆæ–‡ä»¶æœ¬èº«ä¸åˆ é™¤ï¼‰ã€ï¼Œæ—§æ–‡ä»¶åˆ ä¸äº†è¡Œä¹Ÿåˆ ä¸äº†ç›˜ï¼Œé‡å¤å¯¼å…¥å‡ æ¬¡ç£ç›˜ä¸Šå°±å¤šå‡ ä»½å†æ²¡äººå¼•用的文件。
 */
async function removeExistingSourceFiles(templateId: number) {
  const existing = await listAttachments({
    application: 'file',
    recordType: 'qc_report_template',
    recordId: templateId,
  });
  const attachmentIds = (existing ?? [])
    .map((blob) => blob.storageAttachmentId)
    .filter((id): id is number => typeof id === 'number');
  if (attachmentIds.length > 0) {
    await deleteAttachments(attachmentIds);
  }
}
/** ä¸Šä¼  â†’ æ¸…旧源文件 â†’ ç»‘附件 â†’ è¯†åˆ«ã€‚四步都要走完,失败即中断并如实报错 */
async function handleRecognize() {
  const templateId = Number(props.templateId);
  if (!Number.isFinite(templateId) || templateId <= 0) {
    message.error('缺少模板编号,无法关联上传文件');
    return;
  }
  const files = selectedFiles.value;
  if (files.length === 0) {
    return;
  }
  recognizing.value = true;
  try {
    // ä¸Šä¼ è¿”回的顺序即 pages é¡ºåºï¼Œç›´æŽ¥æŒ‰è¿™ä¸ªé¡ºåºæŠŠ blobId äº¤ç»™åŽç«¯
    const blobs = await uploadFile(files);
    const blobIds = blobs.map((blob) => blob.id);
    if (blobIds.length === 0) {
      message.error('文件上传失败,请重新选择文件');
      return;
    }
    // æ”¾åœ¨ä¸Šä¼ ä¹‹åŽï¼šä¸Šä¼ å¤±è´¥å°±ä»€ä¹ˆéƒ½ä¸åŠ¨ï¼Œä¸Šä¸€ä»½æºæ–‡ä»¶è¿˜åœ¨
    await removeExistingSourceFiles(templateId);
    // å…ˆç»‘定再识别:识别失败文件也已归属到模板,不会留成孤儿附件
    await bindAttachments({
      application: 'file',
      recordType: 'qc_report_template',
      recordId: templateId,
      blobItems: blobIds.map((blobId) => ({ blobId })),
    });
    // æ³¨å†Œè¡¨è¦æ˜¾å¼æ³¨å†Œæ‰æœ‰å†…容(设计器 init æ—¶åšè¿‡ä¸€æ¬¡ï¼Œè¿™é‡Œæ˜¯å¹‚等的兜底)
    registerAllQualityComponents();
    draft.value = await generateAiDraft({
      blobIds,
      templateId,
      schemaVersion: SCHEMA_VERSION,
      catalog: buildQualityComponentCatalog(),
      hint: hint.value.trim() || undefined,
    });
    if ((draft.value?.components ?? []).length === 0) {
      message.warning('没有识别出任何组件,请换一份更清晰的文件重试');
    }
  } finally {
    // å¤±è´¥æ—¶ä¸åœ¨è¿™ä¸€å±‚吞掉错误:后端已按「维度 + å®žé™…值 + å¯æ‰§è¡ŒåŠ¨ä½œã€ç»™äº†ç²¾ç¡®æ–‡æ¡ˆï¼Œ
    // ç”±è¯·æ±‚层统一弹出即可,这里包一层反而会盖住它
    recognizing.value = false;
  }
}
function handleApply() {
  emit('apply', {
    components: draft.value?.components,
    page: draft.value?.page,
  });
  open.value = false;
}
</script>
<template>
  <Modal
    v-model:open="open"
    title="AI å¯¼å…¥æ¨¡æ¿"
    :width="720"
    :footer="null"
    :mask-closable="!recognizing"
    :closable="!recognizing"
  >
    <div class="flex flex-col gap-3">
      <Alert
        type="info"
        show-icon
        message="识别结果只是草稿"
        description="AI è¯†åˆ«å‡ºçš„内容不会自动保存,需要你到设计器里确认、调整后点「保存」才会成为模板版本。"
      />
      <template v-if="!draft">
        <Upload.Dragger
          v-model:file-list="fileList"
          :multiple="true"
          :max-count="MAX_FILES"
          :accept="ACCEPT"
          :before-upload="beforeUpload"
          :disabled="recognizing"
        >
          <p class="ant-upload-drag-icon">
            <IconifyIcon icon="lucide:upload-cloud" class="text-2xl" />
          </p>
          <p class="ant-upload-text">点击或拖拽文件到此处</p>
          <p class="ant-upload-hint text-xs">
            æ”¯æŒæ‰«æä»¶/照片(png、jpg、jpeg、bmp、gif、webp)、PDF、Word(.docx)、Excel(.xlsx)、文本(txt、csv),
            ä¸€æ¬¡æœ€å¤š {{ MAX_FILES }} ä¸ªæ–‡ä»¶ï¼Œå¤šé¡µæ–‡ä»¶æŒ‰é¡µåºè¯†åˆ«ã€‚
          </p>
          <p class="ant-upload-hint text-xs">
            .doc / .xls æ—§æ ¼å¼ä¸æ”¯æŒï¼Œè¯·å…ˆç”¨ Office å¦å­˜ä¸º .docx / .xlsx。
          </p>
        </Upload.Dragger>
        <Input
          v-model:value="hint"
          placeholder="补充说明(选填),例如:这是 IQC æ¥æ–™æ£€éªŒæŠ¥å‘Š"
          :disabled="recognizing"
        />
        <Spin v-if="recognizing" size="small">
          <div class="py-2 text-xs text-gray-500">
            æ­£åœ¨è¯†åˆ«ï¼Œé¢„计 10–60 ç§’。扫描件按页逐页识别,每页单独一次调用,页数多时会明显更久;
            æœ€é•¿ç­‰å¾…约 5 åˆ†é’Ÿï¼Œè¶…时后请减少文件数量或页数再试。请勿关闭页面。
          </div>
        </Spin>
        <div class="flex justify-end gap-2">
          <Button :disabled="recognizing" @click="open = false">取消</Button>
          <Button
            type="primary"
            :loading="recognizing"
            :disabled="!canRecognize"
            @click="handleRecognize"
          >
            <IconifyIcon icon="lucide:sparkles" class="mr-1" />
            å¼€å§‹è¯†åˆ«
          </Button>
        </div>
      </template>
      <template v-else>
        <div class="flex items-center gap-2 text-xs text-gray-500">
          <span>识别出 {{ draftRows.length }} ä¸ªç»„ä»¶</span>
          <span v-if="durationText">耗时 {{ durationText }}</span>
          <Tag v-if="unknownCount > 0" color="orange">
            {{ unknownCount }} ä¸ªç»„件设计器不认识
          </Tag>
        </div>
        <Alert
          v-if="draft.summary"
          type="success"
          show-icon
          :message="draft.summary"
        />
        <Alert v-if="warnings.length > 0" type="warning" show-icon>
          <template #message>
            <div class="text-xs">
              <div v-for="(warning, index) in warnings" :key="index">
                Â· {{ warning }}
              </div>
            </div>
          </template>
        </Alert>
        <div class="max-h-72 overflow-auto rounded border border-border">
          <div
            v-for="row in draftRows"
            :key="row.index"
            class="flex gap-2 border-b border-border px-3 py-2 text-xs last:border-b-0"
          >
            <span class="w-6 shrink-0 text-gray-400">{{ row.index }}</span>
            <div class="min-w-0 flex-1">
              <div class="flex items-center gap-2">
                <span class="font-medium">{{ row.label }}</span>
                <span class="text-gray-400">{{ row.type }}</span>
                <Tag v-if="!row.registered" color="orange">未注册,将被跳过</Tag>
              </div>
              <div v-if="row.propsText" class="mt-0.5 truncate text-gray-500">
                {{ row.propsText }}
              </div>
            </div>
          </div>
        </div>
        <div class="text-xs text-gray-500">
          è¡¨æ ¼åªè¯†åˆ«ç»“构,文件里的具体数据行不会写进模板;数据在出件时由业务单据提供。
        </div>
        <div class="flex justify-end gap-2">
          <Button @click="reset">
            <IconifyIcon icon="lucide:refresh-cw" class="mr-1" />
            é‡æ–°ä¸Šä¼ 
          </Button>
          <Button type="primary" :disabled="draftRows.length === 0" @click="handleApply">
            <IconifyIcon icon="lucide:check" class="mr-1" />
            ç”Ÿæˆåˆ°ç”»å¸ƒ
          </Button>
        </div>
      </template>
    </div>
  </Modal>
</template>
src/views/mes/qc/report/template/designer/modules/rule-editor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,156 @@
<script lang="ts" setup>
/**
 * åˆ¤å®šè§„则抽屉。
 * <p>
 * è§„则列表由设计器持有(use-designer çš„ rules),这里直接改那份数据:
 * ä¿å­˜æ—¶ collectSchema äº§å‡ºçš„æ˜¯æ•´ä»½ Schema,抽屉与画布必须共用同一份规则,
 * å„存一份就迟早会出现「画布上看到的规则」和「保存下去的规则」不一致。
 */
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Drawer,
  Empty,
  Input,
  Popconfirm,
  Select,
  Switch,
} from 'ant-design-vue';
import { listAllFunctionNames, validateRule } from '#/components/quality';
defineOptions({ name: 'QcReportRuleEditor' });
const rules = defineModel<QcReportVersionApi.SchemaRule[]>('rules', { required: true });
const open = defineModel<boolean>('open', { default: false });
const SCOPE_OPTIONS = [
  { label: '逐项判定', value: 'item' },
  { label: '报告级', value: 'report' },
];
/** ç©ºç™½è§„则的写法示例,按作用域给不同的样例,省得用户对着一片空白猜语法 */
const EXPRESSION_PLACEHOLDER: Record<string, string> = {
  item: '例:item.actualValue >= item.lowerLimit AND item.actualValue <= item.upperLimit',
  report: '例:PASS_RATE(inspectionItems.result) >= 60',
};
/**
 * å¯ç”¨å‡½æ•°çš„æ¸…单直接取自引擎白名单,不在界面上另抄一份。
 * æŠ„一份就会在引擎加函数时漏改,用户照提示写的表达式反而被引擎拒掉。
 */
const FUNCTION_HINT = listAllFunctionNames().join('、');
/** æ¯æ¡è§„则的问题,空串表示没问题。与后端保存时的校验同口径 */
const ruleErrors = computed(() => rules.value.map((rule) => validateRule(rule)[0] ?? ''));
const errorCount = computed(() => ruleErrors.value.filter(Boolean).length);
let sequence = 0;
function handleAdd() {
  sequence += 1;
  rules.value = [
    ...rules.value,
    {
      // id åªç”¨äºŽå‡ºé”™æ—¶å®šä½ï¼Œæ—¶é—´æˆ³åŠ åºå·è¶³å¤Ÿä¸”ä¸ä¾èµ–éšæœºæ•°
      id: `rule-${Date.now()}-${sequence}`,
      name: `规则 ${rules.value.length + 1}`,
      scope: 'item',
      expression: '',
      enabled: true,
    },
  ];
}
function handleRemove(index: number) {
  rules.value = rules.value.filter((_, current) => current !== index);
}
</script>
<template>
  <Drawer v-model:open="open" title="判定规则" placement="right" :width="640">
    <div class="flex h-full flex-col gap-3">
      <div class="rounded border border-border bg-gray-50 p-2 text-xs leading-5 text-gray-600">
        <div>
          <span class="font-medium">逐项判定</span
          >:对每条检验项分别求值,表达式为真即判该项合格。没有任何逐项规则时,退回按规格上下限判定。
        </div>
        <div>
          <span class="font-medium">报告级</span
          >:决定整份报告的结论。没有任何报告级规则时,取「全部检验项都合格」。
        </div>
        <div class="mt-1">
          å¯ç”¨å­—段:<code>item.actualValue</code>、<code>item.lowerLimit</code>、<code
            >item.upperLimit</code
          >、<code>item.itemName</code>、<code>item.result</code>、<code>report.total</code>、<code
            >report.passCount</code
          >、<code>report.failCount</code>、<code>inspectionItems.result</code>
        </div>
        <div>
          å¯ç”¨å‡½æ•°ï¼š{{ FUNCTION_HINT }}
        </div>
      </div>
      <div v-if="errorCount > 0" class="text-xs text-red-500">
        æœ‰ {{ errorCount }} æ¡è§„则还没写完整,保存前需要先改好。
      </div>
      <div class="flex-1 overflow-auto">
        <Empty
          v-if="rules.length === 0"
          :image="Empty.PRESENTED_IMAGE_SIMPLE"
          description="还没有判定规则,检验项将按规格上下限判定"
        />
        <div v-else class="flex flex-col gap-3">
          <div
            v-for="(rule, index) in rules"
            :key="rule.id ?? index"
            class="rounded border border-border p-3"
            :class="rule.enabled === false ? 'opacity-60' : ''"
          >
            <div class="mb-2 flex items-center gap-2">
              <Input
                v-model:value="rule.name"
                size="small"
                placeholder="规则名称,出错时会带上这个名字"
                class="flex-1"
              />
              <Select
                v-model:value="rule.scope"
                :options="SCOPE_OPTIONS"
                size="small"
                class="w-28"
              />
              <Switch v-model:checked="rule.enabled" size="small" />
              <Popconfirm title="删除该规则?" @confirm="handleRemove(index)">
                <Button size="small" type="text" danger>
                  <IconifyIcon icon="lucide:trash-2" />
                </Button>
              </Popconfirm>
            </div>
            <Input.TextArea
              v-model:value="rule.expression"
              :rows="2"
              :placeholder="EXPRESSION_PLACEHOLDER[rule.scope ?? 'item']"
              class="font-mono text-xs"
            />
            <div v-if="ruleErrors[index]" class="mt-1 text-xs text-red-500">
              {{ ruleErrors[index] }}
            </div>
          </div>
        </div>
      </div>
      <Button block type="dashed" @click="handleAdd">
        <IconifyIcon icon="lucide:plus" class="mr-1" />
        æ–°å¢žè§„则
      </Button>
    </div>
  </Drawer>
</template>
src/views/mes/qc/report/template/designer/use-designer.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,363 @@
import type { Editor, EditorConfig } from 'grapesjs';
import type { QualityAssemblySkip, QualityDraftComponent } from '#/components/quality';
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
import { ref, shallowRef } from 'vue';
import grapesjs from 'grapesjs';
import {
  assembleQualityCanvas,
  bindQualityCanvasSync,
  buildQualityBlocks,
  registerAllQualityComponents,
  registerBindingTraitType,
  restoreQualityTraits,
  QUALITY_TYPE_ATTR,
} from '#/components/quality';
import { PAPER_COLOR, resolveCanvasSize, SCHEMA_VERSION } from './constants';
import 'grapesjs/dist/css/grapes.min.css';
/** GrapesJS å†…部页面设备的标识前缀,后缀为画布尺寸,尺寸随纸张设置动态变化 */
const PAGE_DEVICE_PREFIX = 'qc-report-page';
/** ç”»å¸ƒä¸“用引导样式的 style å…ƒç´  id,重建画布帧后靠它判断有没有重复注入 */
const CANVAS_BINDING_GUIDE_ID = 'qc-canvas-binding-guide';
/**
 * åªè¦†ç›–「值还停在占位符」的 hidden,不动真正的 hidden="hidden"。
 * æ”¶çª„到 td æ˜¯å› ä¸ºè¿™ä¸ªå±žæ€§ç›®å‰åªç”¨åœ¨å•元格上;哪天真用到别的标签上,
 * ä¹Ÿè¦æŒ‰é‚£ä¸ªæ ‡ç­¾çš„ display å„写一条,不能一条通用规则糊过去。
 */
const CANVAS_BINDING_GUIDE_CSS = 'td[hidden*="{{"] { display: table-cell; }';
export interface DesignerInitOptions {
  /** ç»„件面板容器(左侧) */
  blockContainer: HTMLElement;
  /** ç”»å¸ƒå®¹å™¨ï¼ˆä¸­é—´ï¼‰ */
  canvasContainer: HTMLElement;
  /** æ ·å¼é¢æ¿å®¹å™¨ï¼ˆå³ä¾§ä¸‹åŠï¼‰ */
  styleContainer: HTMLElement;
  /** å±žæ€§é¢æ¿å®¹å™¨ï¼ˆå³ä¾§ä¸ŠåŠï¼‰ */
  traitContainer: HTMLElement;
}
/**
 * GrapesJS è®¾è®¡å™¨å°è£…
 * <p>
 * åªè´Ÿè´£ç”»å¸ƒä¸Ž Schema çš„双向转换,不承担保存/发布等业务动作,便于后续替换渲染内核。
 */
export function useDesigner() {
  const editor = shallowRef<Editor>();
  const zoom = ref(100);
  const selectedLabel = ref('');
  const canUndo = ref(false);
  const canRedo = ref(false);
  let canvasSize = resolveCanvasSize();
  /**
   * åˆ¤å®šè§„则。随版本一起载入、随 collectSchema ä¸€èµ·äº§å‡ºã€‚
   * <p>
   * ç¼–辑动作在规则抽屉里,这里只负责持有这份数据。
   */
  const rules = ref<QcReportVersionApi.SchemaRule[]>([]);
  /**
   * æŠŠç”»å¸ƒè°ƒåˆ°æŒ‡å®šçº¸å¼ å°ºå¯¸ã€‚
   * <p>
   * GrapesJS åªåœ¨ device å–值发生变化时才重排画布帧,所以设备 id é‡Œå¸¦ä¸Šå°ºå¯¸ï¼Œ
   * æ¯æ¬¡æ¢çº¸å¼ éƒ½æ˜¯ä¸€æ¬¡"真正的新设备",换完再把旧尺寸的设备清掉。
   */
  function setPageDevice(width: number, height: number) {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const id = `${PAGE_DEVICE_PREFIX}-${width}x${height}`;
    instance.Devices.add({
      id,
      name: '页面',
      width: `${width}px`,
      height: `${height}px`,
      // çº¸å¼ æ¨¡æ¿çš„æ ·å¼ä¸èƒ½æŒ‚在某个设备宽度的 @media ä¸Šï¼Œå¦åˆ™æ¢çº¸å¼ åŽæ ·å¼ä¼šå¤±æ•ˆ
      widthMedia: '',
    });
    instance.setDevice(id);
    instance.Devices.getDevices()
      .filter((device) => {
        const deviceId = device.get('id');
        return (
          typeof deviceId === 'string' &&
          deviceId !== id &&
          deviceId.startsWith(PAGE_DEVICE_PREFIX)
        );
      })
      .forEach((device) => instance.Devices.remove(device));
  }
  /**
   * ç”»å¸ƒä¸Šçš„纸张外观:白底 + é¡µè¾¹è·ç•™ç™½ã€‚
   * <p>
   * ä»…作视觉参考,直接写在画布 body ä¸Šï¼Œä¸ä¼šè¿›å…¥ Schema;
   * frameStyle åªåœ¨é¦–个画布帧生效,重建页面后要在这里补一次白底。
   */
  function applyFrameGuide() {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const doc = instance.Canvas.getDocument();
    if (!doc) {
      return;
    }
    const { marginPx } = canvasSize;
    doc.body.style.backgroundColor = PAPER_COLOR;
    doc.body.style.padding = `${marginPx.top}px ${marginPx.right}px ${marginPx.bottom}px ${marginPx.left}px`;
    doc.body.style.boxSizing = 'border-box';
    applyBindingGuide(doc);
  }
  /**
   * ç”»å¸ƒæŠŠ {{...}} åŽŸæ ·ç•™ç€ä¸æ±‚è§£ï¼ŒäºŽæ˜¯ç»„åæ ¼ä¸Šçš„ hidden="{{item.group.hidden}}"
   * åœ¨æµè§ˆå™¨çœ¼é‡Œå°±æ˜¯ã€Œæœ‰ hidden å±žæ€§ã€â€”—整格 display:none,这一行后面的格整体左移一格,
   * æœ«åˆ—只剩表头、被挤成竖排细条,设计器里看到的排版跟出件结果对不上。
   * è¿™é‡Œåªåœ¨ç”»å¸ƒæ–‡æ¡£å†…把「值还停在占位符」的 hidden è¿˜åŽŸæˆå¯è§æ ¼ï¼›å‡ºä»¶æ—¶è¯¥å€¼å·²è§£æˆ
   * ç©ºä¸²æˆ– hidden,选择器不再命中,报告渲染一个字都不受影响。
   */
  function applyBindingGuide(doc: Document) {
    if (doc.getElementById(CANVAS_BINDING_GUIDE_ID)) {
      return;
    }
    const style = doc.createElement('style');
    style.id = CANVAS_BINDING_GUIDE_ID;
    style.textContent = CANVAS_BINDING_GUIDE_CSS;
    doc.head.append(style);
  }
  function syncHistoryState() {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const undoManager = instance.UndoManager;
    canUndo.value = undoManager.hasUndo();
    canRedo.value = undoManager.hasRedo();
  }
  function init(options: DesignerInitOptions): Editor {
    // ç»„件面板的内容全部来自质量组件注册表,这里先保证内置组件已注册
    registerAllQualityComponents();
    const config: EditorConfig = {
      container: options.canvasContainer,
      height: '100%',
      width: 'auto',
      fromElement: false,
      // ç”±æœ¬å¹³å°è‡ªå·±çš„æŽ¥å£ä¿å­˜ï¼Œä¸ä½¿ç”¨ GrapesJS å†…置存储
      storageManager: false,
      // é¡¶æ ç”±é¡µé¢è‡ªç»˜ï¼ŒåŽ»æŽ‰ GrapesJS é»˜è®¤é¢æ¿
      panels: { defaults: [] },
      blockManager: {
        appendTo: options.blockContainer,
        blocks: buildQualityBlocks(),
      },
      traitManager: { appendTo: options.traitContainer },
      styleManager: {
        appendTo: options.styleContainer,
        sectors: [
          { name: '布局', open: false, properties: ['display', 'width', 'height', 'margin', 'padding'] },
          { name: '排版', open: false, properties: ['font-size', 'font-weight', 'color', 'text-align', 'line-height'] },
          { name: '背景与边框', open: false, properties: ['background-color', 'border', 'border-radius'] },
        ],
      },
      canvas: {
        // frameStyle ä¼šè¢«åŽŸæ ·æ‹¼è¿›ç”»å¸ƒ iframe çš„ <style>,必须传 CSS å­—符串;
        // ä¼ å¯¹è±¡åªä¼šå¾—到字面量 [object Object]。页边距留白由 applyFrameGuide æŒ‰çº¸å¼ è®¾ç½®å†™å…¥ã€‚
        frameStyle: `body { background-color: ${PAPER_COLOR} }`,
      },
      // ç”»å¸ƒå°ºå¯¸ç”± setPageDevice æŒ‰çº¸å¼ è®¾ç½®åŠ¨æ€æ³¨å†Œï¼Œè¿™é‡Œä¸é¢„ç½®è®¾å¤‡
    };
    const instance = grapesjs.init(config);
    editor.value = instance;
    registerBindingTraitType(instance);
    instance.on('canvas:frame:load', () => {
      applyFrameGuide();
    });
    instance.on('component:selected', (component) => {
      const attrs = component?.getAttributes() ?? {};
      selectedLabel.value = String(attrs[QUALITY_TYPE_ATTR] ?? component?.get('tagName') ?? '');
    });
    instance.on('component:deselected', () => {
      selectedLabel.value = '';
    });
    ['undo', 'redo', 'update'].forEach((event) => {
      instance.on(event, syncHistoryState);
    });
    bindQualityCanvasSync(instance);
    setPageDevice(canvasSize.width, canvasSize.height);
    applyFrameGuide();
    syncHistoryState();
    return instance;
  }
  /** åªæŒ‰çº¸å¼ è®¾ç½®é‡æŽ’画布尺寸与页边距参考线,不动画布里的已有内容 */
  function applyPageSize(page?: QcReportVersionApi.SchemaPage) {
    canvasSize = resolveCanvasSize(page);
    setPageDevice(canvasSize.width, canvasSize.height);
    applyFrameGuide();
  }
  /** æŒ‰ Schema çš„纸张设置重排画布,并载入 GrapesJS åŽŸå§‹æ•°æ® */
  function loadSchema(schema?: QcReportVersionApi.ReportTemplateSchema) {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    // æ‹·ä¸€å±‚再放进去:抽屉里改的是这份副本,切版本时不该把改动带到另一个版本上。
    // åŒæ—¶æŠŠå¼•擎的缺省口径落成明确值(scope è¯†åˆ«ä¸å‡ºæŒ‰é€é¡¹ã€enabled ç¼ºçœå³å¯ç”¨ï¼‰ï¼Œ
    // å¦åˆ™ç•Œé¢ä¸Šçš„开关会与实际判定对不上,看起来停用了其实还在判。
    rules.value = (schema?.rules ?? []).map((rule) => ({
      ...rule,
      scope: rule.scope === 'report' ? 'report' : 'item',
      enabled: rule.enabled !== false,
    }));
    applyPageSize(schema?.page);
    // grapes å¿…须带 pages æ‰æ˜¯å¯ç”¨é¡¹ç›®æ•°æ®ï¼Œå¦åˆ™ç”»å¸ƒä¼šåŠ è½½å‡ºä¸€ä¸ªæ²¡æœ‰é¡µé¢çš„ç©ºç¼–è¾‘å™¨
    const projectData = schema?.grapes;
    const pages = projectData?.pages;
    if (Array.isArray(pages) && pages.length > 0) {
      instance.loadProjectData(projectData as Parameters<Editor['loadProjectData']>[0]);
    } else {
      instance.loadProjectData({ pages: [{ component: { style: { padding: '0' } } }] });
    }
    applyFrameGuide();
    // å¿…须在清空历史之前补属性字段:trait åˆå§‹åŒ–会写一次组件属性,留下的历史没有意义
    restoreQualityTraits(instance);
    instance.UndoManager.clear();
    syncHistoryState();
  }
  /**
   * ä»Žç”»å¸ƒæ”¶é›† Schema:grapes ä¿å­˜åŽŸå§‹æ•°æ®ï¼Œcomponents ä¿å­˜è´¨é‡ç»„件业务语义。
   * <p>
   * äº§å‡ºçš„æ˜¯<b>整份</b> Schema,后端保存时整体覆盖,所以设计器认识的字段必须在这里列全——
   * æ¼æŽ‰å“ªä¸ªå°±ç­‰äºŽæŠŠé‚£ä¸ªå­—段清空(rules æ›¾ç»å°±è¿™æ ·è¢«æ¸…掉过,见 RuleEditor çš„说明)。
   * å°†æ¥ Schema æ–°å¢žå­—段时,要么在这里产出,要么想清楚由谁负责保留。
   */
  function collectSchema(
    page: QcReportVersionApi.SchemaPage,
  ): QcReportVersionApi.ReportTemplateSchema {
    const instance = editor.value;
    if (!instance) {
      return { schemaVersion: SCHEMA_VERSION, page, rules: rules.value };
    }
    const projectData = instance.getProjectData() as unknown as Record<string, unknown>;
    const components: QcReportVersionApi.QualityComponentNode[] = [];
    // getWrapper åœ¨ç±»åž‹ä¸Šæ˜¯å¯ç©ºçš„,取不到就只收集不到业务语义,不能因此丢掉 grapes ç”»å¸ƒæ•°æ®
    instance
      .getWrapper()
      ?.find(`[${QUALITY_TYPE_ATTR}]`)
      .forEach((component, index) => {
        const attrs = component.getAttributes();
        components.push({
          id: component.getId(),
          // ç»„件在文档中的顺序,供后续数据绑定与渲染定位
          index,
          qualityType: attrs[QUALITY_TYPE_ATTR],
          attributes: attrs,
        });
      });
    return {
      schemaVersion: SCHEMA_VERSION,
      page,
      grapes: projectData,
      components,
      rules: rules.value,
    };
  }
  /**
   * æŠŠ AI è‰ç¨¿è£…到画布上。
   * <p>
   * components ä¼ ç©ºæ•°ç»„:画布才是真相,保存时由 collectSchema é‡æ–°äº§å‡ºè¯­ä¹‰æ¸…单。
   * å¡žè‰ç¨¿è¿›åŽ»ä¼šåˆ¶é€ ç¬¬äºŒä¸ªçœŸç›¸æ¥æºï¼Œè€Œä¸”å½¢çŠ¶ä¹Ÿä¸å¯¹ï¼ˆè‰ç¨¿æ˜¯ {type, props},
   * è¯­ä¹‰å±‚是 {id, index, qualityType, attributes})。
   * <p>
   * rules ä¼ å½“前值:loadSchema ä¼šç”¨ä¼ å…¥çš„ rules è¦†ç›–,传空数组等于把用户已写好的判定规则静默清空。
   * <p>
   * page å¿…须由调用方给定:草稿没猜出纸张时不能传 undefined,那会让 applyPageSize é€€å›žé»˜è®¤ A4,
   * æŠŠç”¨æˆ·å·²ç»è°ƒå¥½çš„纸张悄悄改掉。
   */
  function loadCanvasFromDraft(
    components: QualityDraftComponent[] | undefined,
    page: QcReportVersionApi.SchemaPage,
  ): { nodeCount: number; problems: string[]; skipped: QualityAssemblySkip[] } {
    const assembled = assembleQualityCanvas(components);
    const nodeCount = countCanvasNodes(assembled.grapes);
    // ä¸€ä¸ªç»„件都装不进去时不要动画布:loadSchema ä¼šæ¸…掉撤销栈与已有内容,
    // ç™½ç™½æ­è¿›åŽ»ä¸€ç‰ˆç”»å¸ƒå´ä»€ä¹ˆéƒ½æ¢ä¸æ¥
    if (nodeCount === 0) {
      return { nodeCount, problems: assembled.problems, skipped: assembled.skipped };
    }
    loadSchema({
      schemaVersion: SCHEMA_VERSION,
      page,
      grapes: assembled.grapes,
      components: [],
      rules: rules.value,
    });
    return { nodeCount, problems: assembled.problems, skipped: assembled.skipped };
  }
  function undo() {
    editor.value?.UndoManager.undo();
  }
  function redo() {
    editor.value?.UndoManager.redo();
  }
  function setZoom(next: number) {
    zoom.value = next;
    editor.value?.Canvas.setZoom(next);
  }
  function destroy() {
    editor.value?.destroy();
    editor.value = undefined;
  }
  return {
    canRedo,
    canUndo,
    editor,
    rules,
    zoom,
    selectedLabel,
    applyPageSize,
    collectSchema,
    destroy,
    init,
    loadCanvasFromDraft,
    loadSchema,
    redo,
    setZoom,
    undo,
  };
}
/** æ•°ä¸€ä¸‹è£…配产物里的顶层组件数;结构由 assembleQualityCanvas å›ºå®šï¼Œå–不到按 0 ç®— */
function countCanvasNodes(grapes: Record<string, unknown>): number {
  const pages = grapes.pages as
    | { component?: { components?: unknown[] } }[]
    | undefined;
  return pages?.[0]?.component?.components?.length ?? 0;
}
src/views/mes/qc/report/template/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,195 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { QcReportTemplateApi } from '#/api/mes/qc/report/template';
import { Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  copyTemplate,
  deleteTemplate,
  getTemplatePage,
} from '#/api/mes/qc/report/template';
import { $t } from '#/locales';
import { router } from '#/router';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const pageSizeOptions = getDictOptions(DICT_TYPE.QC_REPORT_PAGE_SIZE, 'string');
const orientationOptions = getDictOptions(
  DICT_TYPE.QC_REPORT_ORIENTATION,
  'string',
);
function getDictLabel(options: typeof pageSizeOptions, value?: string): string {
  return options.find((item) => item.value === value)?.label ?? value ?? '';
}
function getPageSetting(row: QcReportTemplateApi.Template): string {
  const size = getDictLabel(pageSizeOptions, row.pageSize);
  const orientation = getDictLabel(orientationOptions, row.orientation);
  return [size, orientation].filter(Boolean).join(' / ');
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** åˆ›å»ºæŠ¥å‘Šæ¨¡æ¿ */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑报告模板 */
function handleEdit(row: QcReportTemplateApi.Template) {
  formModalApi.setData(row).open();
}
/** è¿›å…¥æ¨¡æ¿è®¾è®¡å™¨ */
function handleDesign(row: QcReportTemplateApi.Template) {
  router.push({
    name: 'QcReportTemplateDesigner',
    query: { id: row.id },
  });
}
/** å¤åˆ¶æŠ¥å‘Šæ¨¡æ¿ */
async function handleCopy(row: QcReportTemplateApi.Template) {
  const hideLoading = message.loading({
    content: `正在复制【${row.templateName}】...`,
    duration: 0,
  });
  try {
    await copyTemplate(row.id!);
    message.success(`【${row.templateName}】复制成功`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** åˆ é™¤æŠ¥å‘Šæ¨¡æ¿ */
async function handleDelete(row: QcReportTemplateApi.Template) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.templateName]),
    duration: 0,
  });
  try {
    await deleteTemplate(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.templateName]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getTemplatePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<QcReportTemplateApi.Template>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="报告模板列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['报告模板']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['qc-report:template:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #pageSetting="{ row }">
        {{ getPageSetting(row) }}
      </template>
      <template #currentVersion="{ row }">
        <Tag v-if="row.currentVersion" color="green">
          {{ row.currentVersion }}
        </Tag>
        <span v-else class="text-gray-400">未发布</span>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '设计',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['qc-report:template:update'],
              onClick: handleDesign.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['qc-report:template:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '复制',
              type: 'link',
              icon: ACTION_ICON.COPY,
              auth: ['qc-report:template:create'],
              onClick: handleCopy.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['qc-report:template:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.templateName]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/qc/report/template/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { QcReportTemplateApi } from '#/api/mes/qc/report/template';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createTemplate,
  getTemplate,
  updateTemplate,
} from '#/api/mes/qc/report/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<QcReportTemplateApi.Template>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['报告模板'])
    : $t('ui.actionTitle.create', ['报告模板']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-2',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as QcReportTemplateApi.Template;
    try {
      await (formData.value?.id ? updateTemplate(data) : createTemplate(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema() });
    const data = modalApi.getData<QcReportTemplateApi.Template>();
    if (!data || !data.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getTemplate(data.id);
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-1/2">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/qc/rqc/index.vue
@@ -11,9 +11,13 @@
import { deleteRqc, exportRqc, getRqcPage } from '#/api/mes/qc/rqc';
import { $t } from '#/locales';
import QcExportRangeModal from '#/views/mes/qc/components/export-range-modal.vue';
import QcReportGenerateModal from '#/views/mes/qc/components/report-generate-modal.vue';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æœ¬é¡µå¯¹åº” mes_qc_type çš„值:4 é€€è´§æ£€éªŒ */
const QC_TYPE = 4;
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -22,6 +26,11 @@
const [ExportRangeModal, exportRangeModalApi] = useVbenModal({
  connectedComponent: QcExportRangeModal,
  destroyOnClose: true,
});
const [ReportModal, reportModalApi] = useVbenModal({
  connectedComponent: QcReportGenerateModal,
  destroyOnClose: true,
});
@@ -71,6 +80,13 @@
    .open();
}
/** å‡ºæŠ¥å‘Šï¼šæ•°æ®ç”±åŽç«¯ä»Žè¯¥è´¨æ£€å•取,前端只选模板 */
function handleReport(row: MesQcRqcApi.Rqc) {
  reportModalApi
    .setData({ qcType: QC_TYPE, qcId: row.id, qcNo: row.code })
    .open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
@@ -106,6 +122,7 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ExportRangeModal />
    <ReportModal />
    <Grid table-title="退货检验单列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +153,14 @@
        <TableAction
          :actions="[
            {
              label: '出报告',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['qc-report:instance:generate'],
              ifShow: row.status === MesQcStatusEnum.FINISHED,
              onClick: handleReport.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
vite.config.ts
@@ -83,7 +83,7 @@
        proxy: {
          "/admin-api": {
            changeOrigin: true,
            target: "http://192.168.0.10:48080",
            target: "http://localhost:48080",
            ws: true,
          },
        },