From 35722562e9e13f0504acc15b740d042ecb810199 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期日, 20 九月 2026 09:06:11 +0800
Subject: [PATCH] feat(mes): 添加质检指标条目类型支持并实现报告生成功能
---
src/components/quality/header/sample-info.ts | 140
src/components/quality/core/trait-binding.ts | 161
.qc-conformance/verify-grouped-table.ts | 391 ++
src/components/quality/base/text.ts | 48
src/components/quality/core/icons.ts | 37
src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue | 341 +
src/components/quality/header/report-header.ts | 80
src/views/mes/qc/report/template/designer/use-designer.ts | 363 ++
src/views/mes/qc/report/template/data.ts | 229 +
src/api/mes/qc/report/ai/index.ts | 68
src/components/quality/core/canvas-sync.ts | 64
pnpm-lock.yaml | 104
src/components/quality/index.ts | 97
src/components/quality/header/report-footer.ts | 41
src/components/quality/engine/report-evaluator.ts | 277 +
src/components/quality/inspection/inspection-item.ts | 83
src/components/quality/base/table.ts | 72
src/views/mes/qc/report/template/designer/constants.ts | 54
src/components/quality/core/lookup.ts | 18
src/components/quality/base/divider.ts | 28
src/api/mes/qc/report/template/index.ts | 71
src/views/mes/qc/report/template/modules/form.vue | 83
.qc-conformance/render-ts.ts | 111
src/components/quality/base/spacer.ts | 20
src/components/quality/engine/context.ts | 169
src/views/mes/qc/report/instance/index.vue | 147
.qc-conformance/verify-columns.ts | 437 ++
CHDL.bat | 451 ++
src/components/quality/core/factory.ts | 129
src/components/quality/result/report-result.ts | 69
src/views/mes/qc/indicator/index.vue | 18
src/views/mes/qc/report/instance/data.ts | 165
src/views/mes/qc/report/template/designer/index.vue | 637 +++
src/components/quality/engine/rule-engine.ts | 711 ++++
KYZN.bat | 451 ++
src/views/mes/qc/report/instance/modules/detail.vue | 197 +
.qc-conformance/verify-undecidable.ts | 106
src/packages/constants/src/dict-enum.ts | 11
src/components/quality/core/serializer.ts | 56
pnpm-workspace.yaml | 1
src/components/quality/core/validator.ts | 94
src/views/mes/qc/iqc/index.vue | 25
.qc-conformance/group-table-render.html | 17
src/api/mes/qc/report/instance/index.ts | 117
.qc-conformance/group-probe-p5.html | 17
src/components/quality/core/catalog.ts | 90
src/components/quality/core/column-spec.ts | 137
src/components/quality/engine/render.ts | 333 +
src/views/mes/qc/indicator/data.ts | 112
src/views/mes/qc/pendinginspect/index.vue | 4
.qc-conformance/assemble-ts.ts | 337 +
vite.config.ts | 2
src/views/mes/qc/components/report-generate-modal.vue | 251 +
src/components/quality/engine/binding.ts | 60
src/views/mes/qc/rqc/index.vue | 25
src/components/quality/engine/page.ts | 50
.qc-conformance/group-table-degrade.html | 17
src/components/quality/base/image.ts | 48
.qc-conformance/verify-group-repeat.ts | 265 +
.qc-conformance/grouped-table-render.png | 0
src/api/mes/qc/indicator/index.ts | 12
src/api/mes/qc/report/version/index.ts | 141
src/views/mes/qc/report/template/designer/modules/rule-editor.vue | 156
src/components/quality/core/assembler.ts | 134
src/components/quality/engine/path.ts | 105
src/packages/constants/src/biz-mes-enum.ts | 6
.qc-conformance/dump-catalog.ts | 26
src/components/quality/base/heading.ts | 73
src/components/quality/core/registry.ts | 60
src/components/quality/core/html.ts | 30
src/views/mes/qc/ipqc/index.vue | 25
src/views/mes/qc/oqc/index.vue | 25
src/components/quality/core/attrs.ts | 47
.qc-conformance/group-probe-p4.html | 17
src/components/quality/inspection/grouped-quality-table.ts | 324 +
src/views/mes/qc/report/template/index.vue | 195 +
package.json | 1
src/components/quality/inspection/quality-table.ts | 139
src/components/quality/core/types.ts | 133
src/router/routes/modules/qcreport.ts | 28
80 files changed, 10,400 insertions(+), 14 deletions(-)
diff --git a/.qc-conformance/assemble-ts.ts b/.qc-conformance/assemble-ts.ts
new file mode 100644
index 0000000..35d8491
--- /dev/null
+++ b/.qc-conformance/assemble-ts.ts
@@ -0,0 +1,337 @@
+/**
+ * 瀵规媿鑴氭湰锛欰I 鑽夌 鈫� 鐢诲竷瑁呴厤鍣紙assembleQualityCanvas锛夌殑寰�杩斾竴鑷存�с��
+ *
+ * 鏍稿績鏂█鏄�岃閰嶅櫒涓� collectSchema 浜掍负閫嗚繍绠椼�嶏細
+ * 瑁呴厤鍐欒繘鐢诲竷鐨勫睘鎬э紝缁� attributesToProps 杩樺師鍚庯紝蹇呴』涓� resolveQualityProps 鐨勭粨鏋滃畬鍏ㄧ浉绛夈��
+ * 涓嶇浉绛夊氨鎰忓懗鐫�銆孉I 鐢熸垚 鈫� 淇濆瓨 鈫� 鍐嶆墦寮�銆嶄細鎺夊睘鎬ф垨鏀瑰彉绫诲瀷銆�
+ *
+ * 鐢ㄦ硶锛歯px 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}` : ''}`);
+}
+
+/** 鍙瘮杈冨0鏄庤繃鐨勫瓧娈碉紱undefined 瑙嗗悓缂哄腑锛坧ropsToAttributes 浼氳烦杩囧畠锛� */
+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') {
+ // 鍒楀畾涔夋槸鏈夎娉曠殑鏂囨湰灞炴�э紙鍒楁爣棰�=缁戝畾琛ㄨ揪寮忥級锛氬 `鍊糔-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 瀵煎叆璐ㄩ噺鐨勫湴鍩猴細灏戝啓涓�鏉★紝妯″瀷灏卞湪閭d釜缁勪欢涓婇��鍥炵瀻鐚�
+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('<br>'),
+ 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);
diff --git a/.qc-conformance/dump-catalog.ts b/.qc-conformance/dump-catalog.ts
new file mode 100644
index 0000000..81d447a
--- /dev/null
+++ b/.qc-conformance/dump-catalog.ts
@@ -0,0 +1,26 @@
+/**
+ * 涓存椂鑴氭湰锛氭妸鐪熷疄鐨勭Н鏈ㄦ竻鍗曪紙鍚� aiHint锛夊鍑烘垚 JSON 鏂囦欢銆�
+ *
+ * 瀛樺湪鐨勭悊鐢辨槸銆岃仈璋冩椂鍙戠粰鍚庣鐨勬竻鍗曞繀椤绘槸鐪熻揣銆嶏細鎵嬫暡涓�浠芥竻鍗曞幓娴嬶紝娴嬬殑鏄垜鏁茬殑閭d唤锛�
+ * 鑰屼笉鏄墠绔湡姝d細鍙戝嚭鍘荤殑閭d唤鈥斺�攈int 鏈夋病鏈夌湡鐨勮 catalog 甯﹀嚭鏉ワ紝鍙湁杩欐潯璺緞鑳借瘉鏄庛��
+ * 鐢ㄦ硶锛歯px 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('鐢ㄦ硶锛歯px 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}`);
diff --git a/.qc-conformance/group-probe-p4.html b/.qc-conformance/group-probe-p4.html
new file mode 100644
index 0000000..6596851
--- /dev/null
+++ b/.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">姝d竵閱囧惈閲�</td><td rowspan="2">绾害 = 100% - 姘村垎 - 閰稿惈閲� - 鍏跺畠鏉傝川</td></tr><tbody><tr><td>姝d竵閱囧惈閲� /%</td><td>鈮�99.5</td><td>99.7</td><td>%</td><td></td><td></td></tr></tbody></tbody></tbody></table></div></body>
+</html>
\ No newline at end of file
diff --git a/.qc-conformance/group-probe-p5.html b/.qc-conformance/group-probe-p5.html
new file mode 100644
index 0000000..7112686
--- /dev/null
+++ b/.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">姝d竵閱囧惈閲�</td><td rowspan="1">绾害 = 100% - 姘村垎 - 閰稿惈閲� - 鍏跺畠鏉傝川</td><td>姝d竵閱囧惈閲� /%</td><td>鈮�99.5</td><td>99.7</td><td>%</td><td>鏃犲垽瀹氳鍒�</td></tr></tbody></table></div></body>
+</html>
\ No newline at end of file
diff --git a/.qc-conformance/group-table-degrade.html b/.qc-conformance/group-table-degrade.html
new file mode 100644
index 0000000..6a33ecf
--- /dev/null
+++ b/.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>
\ No newline at end of file
diff --git a/.qc-conformance/group-table-render.html b/.qc-conformance/group-table-render.html
new file mode 100644
index 0000000..6276c77
--- /dev/null
+++ b/.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>
\ No newline at end of file
diff --git a/.qc-conformance/grouped-table-render.png b/.qc-conformance/grouped-table-render.png
new file mode 100644
index 0000000..02a7270
--- /dev/null
+++ b/.qc-conformance/grouped-table-render.png
Binary files differ
diff --git a/.qc-conformance/render-ts.ts b/.qc-conformance/render-ts.ts
new file mode 100644
index 0000000..6e334d8
--- /dev/null
+++ b/.qc-conformance/render-ts.ts
@@ -0,0 +1,111 @@
+/**
+ * 涓存椂瀵规媿鑴氭湰锛氱敤鍓嶇娓叉煋/瑙勫垯寮曟搸璺戝悓涓�浠� fixture锛屼骇鍑烘湡鏈涗骇鐗╋紝
+ * 渚涘悗绔� Java 寮曟搸鐨勪竴鑷存�ф祴璇曢�愬瓧姣斿銆傜敤瀹屽嵆鍒狅紝涓嶅叆搴撱��
+ *
+ * 鐢ㄦ硶锛歯px 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(', ')}`);
\ No newline at end of file
diff --git a/.qc-conformance/verify-columns.ts b/.qc-conformance/verify-columns.ts
new file mode 100644
index 0000000..3fd71ee
--- /dev/null
+++ b/.qc-conformance/verify-columns.ts
@@ -0,0 +1,437 @@
+/**
+ * 鎺㈤拡锛氬彲鑷畾鍒楃殑妫�楠岃〃锛圙roupedQualityTable.columns / headerSpans锛変笌鍙厤瀛楁鐨勬牱鍝佷俊鎭�
+ * 锛圫ampleInfo.fields锛夈��
+ *
+ * 鍙锛屼笉鏀逛唬鐮併�佷笉纰版暟鎹簱銆傜敤娉曪細npx tsx .qc-conformance/verify-columns.ts
+ *
+ * 瀹堜笁浠朵簨锛�
+ * 1. **灞炴�х己甯椂浜х墿閫愬瓧涓嶅彉**鈥斺�旇繖鏄湰杞殑绗竴浼樺厛绾э細瀛橀噺鍜屽凡鍙戝竷妯℃澘涓�涓瓧鑺傞兘涓嶈鍔ㄣ��
+ * 杩欓噷姣斿鐨勬槸鏁存浜х墿瀛楃涓茬殑 sha256锛岃�屼笉鏄�屽垪鏁拌繕鏄� 7銆嶈繖绉嶅急鏂█锛�
+ * 鍒楁暟涓嶅彉浣� style 椤哄簭鍙樹簡銆佸睘鎬ф惉瀹朵簡锛屼竴鏍蜂細璁╁凡鍙戝竷鎶ュ憡閲嶅嚭鏃跺彉鏍枫��
+ * 2. 灞炴�у湪鍦烘椂鍒楁暟銆佸垪搴忋�佸悎骞跺睘鎬ц惤鐐广�佷袱绾ц〃澶磋法鍒楅兘瀵瑰緱涓婏紱
+ * 3. 鍐欓敊鏃� validate 鎶ュ緱鍑恒�佷笖鎶ョ殑鏄兘鏀圭殑閭d竴澶勶紙涓嶆槸绗肩粺涓�鍙ャ�屾牸寮忛敊璇�嶏級銆�
+ *
+ * 鐩存帴鎵撳湪鐪熷疄 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. 缂哄腑 = 閫愬瓧涓嶅彉锛堢涓�浼樺厛绾э級
+
+// 鏀硅繖杞箣鍓嶇殑浜х墿鎸囩汗銆傚垪鏁般�乻tyle 椤哄簭銆佸悎骞跺睘鎬ц惤鐐癸紝浠讳綍涓�澶勫姩浜嗚繖涓� 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锛坉efaults 閲屽埢鎰忎笉鏀� 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('鏍峰搧淇℃伅锛歞efaults 閲屾病鏈� 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('鏍峰搧淇℃伅锛歠ields 浼犵┖涓蹭粛鍑洪粯璁� 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 鍒楃畻锛岄潬鎬绘暟鍏滃簳锛坧arser 鐨勬棦鏈夌害瀹氾級
+ 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 姝f枃瀹藉彧鏈� 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 鎶ョ殑鏄兘鏀圭殑閭d竴澶� =====');
+{
+ 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));
+
+ // 鍒楀畾涔夋湁閿欐椂涓嶅啀鍙犲姞璺ㄥ垪鏁版牎楠岋細閭f椂鍒楁暟涓嶅彲淇★紝鎶ヤ簡鍙細鎶婁汉寰�閿欑殑鏂瑰悜甯�
+ const both = validate(groupedQualityTableDefinition, {
+ columns: '搴忓彿={{index}}|妫�楠岄」鐩�',
+ headerSpans: '妫�楠岀粨鏋淾5',
+ });
+ expect('鍒楀畾涔夐敊 + 琛ㄥご璺ㄥ垪涔熼敊 鈬� 鍙姤鍒楀畾涔�', both.length, 1);
+ expectTrue('鎶ョ殑鏄垪瀹氫箟閭f潯', 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 灏变笉鍐嶆姤閭f潯璇姤', 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,
+ );
+
+ // 鏁磋鍑戞弧鏃朵竴鏍奸兘涓嶈琛ワ細榛樿瀛楁姝f槸 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('"><script>alert(1)</script>'),
+ 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('"><script>'), sample.slice(0, 400));
+ expectTrue('娌℃湁閫冨嚭灞炴�у鐨� script', !sample.includes('<script>'), sample.slice(0, 400));
+}
+
+// ------------------------------------------------- 7. 瑙f瀽鍣ㄨ竟鐣岋紙涓や釜缁勪欢鍏辩敤鐨勯偅涓�浠斤級
+
+// eslint-disable-next-line no-console
+console.log('\n===== 7. 鍏辩敤瑙f瀽鍣細绌烘璺宠繃銆�= 鍙垏绗竴涓�乛 浠庢渶鍚庡垏 =====');
+{
+ 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;
diff --git a/.qc-conformance/verify-group-repeat.ts b/.qc-conformance/verify-group-repeat.ts
new file mode 100644
index 0000000..d936426
--- /dev/null
+++ b/.qc-conformance/verify-group-repeat.ts
@@ -0,0 +1,265 @@
+/**
+ * 鎺㈤拡锛氬垎缁勬楠岄」锛堢埗椤� + 瀛愰」锛夊湪鐜版湁娓叉煋寮曟搸涓嬭兘琛ㄨ揪鎴愪粈涔堟牱銆�
+ *
+ * 鐩殑鍙湁涓�涓細鎶娿�宺owspan 鍚堝苟鍗曞厓鏍艰涓嶈鍔ㄥ紩鎿庛�嶈繖浠朵簨浠庢帹鏂彉鎴愬疄娴嬨��
+ * 鍙锛屼笉鏀逛唬鐮併�佷笉纰版暟鎹簱銆�
+ *
+ * 鍏抽敭鍓嶆彁锛堣 report-evaluator.ts:237 寰楀埌锛夛細鍒ゅ畾闃舵浼氭妸涓婁笅鏂囬噸寤烘垚
+ * `{ report, inspectionItems }` 涓や釜閿紝浠讳綍鑷畾涔夌殑椤跺眰鏁扮粍閮借繘涓嶄簡娓叉煋浣滅敤鍩熴��
+ * 鎵�浠ユ湰鎺㈤拡鍚屾椂楠岃瘉銆岄《灞傛暟缁勪笉鍙敤銆嶄笌銆屾寕鍦ㄦ瘡涓楠岄」涓婄殑瀛楁鍙敤銆嶄袱鏉°��
+ *
+ * 鐢ㄦ硶锛歯px 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: '姝d竵閱囧惈閲�', standardValue: '绾害 = 100% - 姘村垎 - 閰稿惈閲� - 鍏跺畠鏉傝川', actualValue: '', unit: '', groupSpan: 2 },
+ { itemName: '姝d竵閱囧惈閲� /%', 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 鎵佸钩锛歳epeat over inspectionItems锛岀粍澶翠俊鎭寕鍦ㄨ涓�
+
+const p1 = run(
+ 'P1 鎵佸钩锛歳epeat 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 椤跺眰鑷畾涔夋暟缁勶紙棰勬湡涓虹┖锛岃瘉鏄庝笂涓嬫枃琚敹鍙o級
+
+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: '姝d竵閱囧惈閲�',
+ 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: '姝d竵閱囧惈閲� /%', standardValue: '鈮�99.5', actualValue: '99.7', unit: '%', group: { name: '姝d竵閱囧惈閲�', 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=')}`,
+ );
+});
diff --git a/.qc-conformance/verify-grouped-table.ts b/.qc-conformance/verify-grouped-table.ts
new file mode 100644
index 0000000..0941bc4
--- /dev/null
+++ b/.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 缁撴瀯灞傞潰鍙兘楠屽埌銆宼d 鏁般�嶏紝鐪熸鐨勮〃鏍煎竷灞�鐢辨祻瑙堝櫒瀹氾紝鎵�浠ヨ剼鏈妸娓叉煋浜х墿鍐欏埌
+ * .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>锛孏rapesJS 瑙f瀽鎴愬甫 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 浜х墿閫愭牸鍚屾瀯鐨勮妯℃澘锛圙rapesJS 鎶婇偅娈� HTML 瑙f瀽鎴愮殑鏍戝氨鏄繖涓舰鐘讹級 */
+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: '' },
+});
+
+/** 缁勫唴瀛愰」琛岋細鑷繁鐨勫悕瀛楄繘銆屽瓙椤广�嶅垪锛屽悎骞舵牸璁╃粰缁勫ご閭d竴琛� */
+const child = (name: string, actual: string, unit: string): Item => ({
+ itemName: name,
+ requirement: '',
+ actualValue: actual,
+ unit,
+ group: { childName: name, span: 1, hidden: 'hidden' },
+});
+
+/** 缁勫唴瀛愰」閲岀湡姝e甫瑙勬牸銆佽兘鍒ゅ畾鐨勪竴鏉� */
+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 瀵煎叆璧拌嚜瀹氬垪锛坈olumns 闈炵┖锛夛細椤圭洰鍒椾粛瑕佹寜缁勭旱鍚戝悎骞� =====');
+{
+ // 绗� 1~3 鑺傛墦鐨勬槸**榛樿鍒�**鍒嗘敮锛坱ableSchema 鎵嬪伐鍚屾瀯锛夛紝鑰� AI 瀵煎叆涓�寰嬪~ columns锛�
+ // 璧扮殑鏄� effectiveColumns 鐨勫彟涓�鏉″垎鏀�斺�斾袱鏉″垎鏀悇鑷嫾 row锛屽悎骞跺睘鎬ф湁娌℃湁璺熺潃璧帮紝
+ // 榛樿鍒楃豢涓嶄唬琛ㄨ嚜瀹氬垪缁裤�傝繖閲岀敤妯″瀷鐪熷疄浜у嚭锛堟ā鏉� 33 閭d唤鑽夌鐨勬渶鍚庝竴杞級閫愬瓧澶嶇幇銆�
+ //
+ // 鍙獙鍒扮粨鏋勪负姝細瑁呴厤浜х墿浜ょ粰 GrapesJS 瑙f瀽鍓嶅悗鐨勫睘鎬ф槸涓�瀛椾笉宸殑锛岃�屻�宻pan=N + hidden=''
+ // 浼氭覆鎴愬彲瑙佺殑 rowspan=N銆乻pan=1 + hidden='hidden' 浼氳浣嶃�嶈繖灞傝涔夌敱绗� 2 鑺傝礋璐b�斺��
+ // 閭e灞炴�у绾︿笌瀹冭惤鍦ㄥ摢涓�鍒楁棤鍏筹紝涓嶅繀鍦ㄨ嚜瀹氬垪涓婇噸璺戜竴閬嶆覆鏌撱��
+ 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('涓婂眰銆屾楠岀粨鏋溿�峜olspan=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);
+
+ // 鍘绘帀 # 灏辫涓嶅悎骞� 鈥斺�� 椤圭洰鍒楁瘡琛岄兘閲嶅鎵撲竴閬嶇粍鍚嶏紝姝f槸銆屾湁鐐逛笐銆嶇殑閭g鐗堝紡銆�
+ // 鏂█瀹冪‘瀹炲彉浜嗭紝闃叉 # 琚綋鎴愯楗般�佹湁鏃犻兘涓嶅奖鍝嶇粨鏋�
+ 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;
diff --git a/.qc-conformance/verify-undecidable.ts b/.qc-conformance/verify-undecidable.ts
new file mode 100644
index 0000000..4adb53f
--- /dev/null
+++ b/.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;
diff --git a/CHDL.bat b/CHDL.bat
new file mode 100644
index 0000000..f83060d
--- /dev/null
+++ b/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
\ No newline at end of file
diff --git a/KYZN.bat b/KYZN.bat
new file mode 100644
index 0000000..c47cde3
--- /dev/null
+++ b/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
\ No newline at end of file
diff --git a/package.json b/package.json
index f8cce41..a6a2e45 100644
--- a/package.json
+++ b/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:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 81f3c62..fa4be39 100644
--- a/pnpm-lock.yaml
+++ b/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:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6105520..838e297 100644
--- a/pnpm-workspace.yaml
+++ b/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
diff --git a/src/api/mes/qc/indicator/index.ts b/src/api/mes/qc/indicator/index.ts
index 163f11d..f658676 100644
--- a/src/api/mes/qc/indicator/index.ts
+++ b/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);
diff --git a/src/api/mes/qc/report/ai/index.ts b/src/api/mes/qc/report/ai/index.ts
new file mode 100644
index 0000000..4ce64a7
--- /dev/null
+++ b/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 瀵硅繖浠芥枃妗g殑涓�鍙ヨ瘽璇存槑 */
+ summary?: string;
+ /** 杞彁绀猴細琚烦杩囩殑椤点�佹棤娉曡〃杈剧殑鍖哄潡绛夛紝蹇呴』灞曠ず缁欑敤鎴� */
+ warnings?: string[];
+ durationMs?: number;
+ }
+
+ export interface DraftReq {
+ /**
+ * 涓婁紶鏂囦欢瀵瑰簲鐨� blobId锛�**椤哄簭鍗虫枃妗i〉搴�**銆�
+ * <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 },
+ );
+}
diff --git a/src/api/mes/qc/report/instance/index.ts b/src/api/mes/qc/report/instance/index.ts
new file mode 100644
index 0000000..9f9024b
--- /dev/null
+++ b/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 鐩存帴娓叉煋鍗充负銆岄瑙堛�嶏紝
+ * 涓嶉渶瑕侀噸鏂板嚭浠躲�傛暟鎹揩鐓т笉鍦ㄨ繖涓帴鍙i噷锛岃鐪嬪緱鍗曠嫭璋� {@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[]; // 娓叉煋鏈熸暟鎹己鍙o紙妯℃澘缁戝畾浜嗕絾娌℃暟鎹殑瀛楁锛�
+ }
+}
+
+/** 鏌ヨ鎶ュ憡瀹炰緥鍒嗛〉 */
+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锛堟墦鍗板疄渚嬪凡瀛樼殑娓叉煋浜х墿骞跺綊妗e埌闄勪欢搴擄紝鍙噸澶嶈皟鐢級 */
+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,
+ );
+}
diff --git a/src/api/mes/qc/report/template/index.ts b/src/api/mes/qc/report/template/index.ts
new file mode 100644
index 0000000..34e1327
--- /dev/null
+++ b/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>
+ * 鍚庣宸叉寜銆屾姤鍛婄被鍨嬩竴鑷� + 妯℃澘鍚敤 + 褰撳墠鐗堟湰宸插彂甯冦�嶈繃婊ゅ畬姣曪紝
+ * 璋冪敤鏂逛笉瑕佸啀鑷繁鎺ㄥ杩欏瑙勫垯锛歝urrentVersion 闈炵┖骞朵笉浠h〃閭d釜鐗堟湰杩樿兘鐢�
+ * 鈥斺�斿仠鐢ㄤ竴涓増鏈笉浼氭竻绌烘ā鏉跨殑 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}`);
+}
\ No newline at end of file
diff --git a/src/api/mes/qc/report/version/index.ts b/src/api/mes/qc/report/version/index.ts
new file mode 100644
index 0000000..4085416
--- /dev/null
+++ b/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;
+ /** 缁勪欢鍦ㄦ枃妗d腑鐨勯『搴忥紝渚涚粦瀹氫笌瀹氫綅浣跨敤 */
+ index?: number;
+ /** 缁勪欢绫诲瀷锛屽嵆鐢诲竷鑺傜偣涓婄殑 data-quality-type */
+ qualityType?: string;
+ /** 缁勪欢鐨勪笟鍔″睘鎬э紝鍙栧�兼槸 data-qc-* / data-quality-type 杩欑被灞炴�� */
+ attributes?: Record<string, unknown>;
+ }
+
+ /** 鍒ゅ畾瑙勫垯锛歴cope 涓� item 鏃堕�愰」鍒ゅ畾锛屼负 report 鏃跺喅瀹氭暣浠芥姤鍛婄殑缁撹 */
+ export interface SchemaRule {
+ id?: string;
+ name?: string;
+ scope?: 'item' | 'report';
+ expression?: string;
+ enabled?: boolean;
+ }
+
+ /**
+ * 妯℃澘 Schema 鈥斺�� 璁捐鍣ㄤ骇鐗╋紝涔熸槸鎶ュ憡鍑轰欢鐨勫敮涓�杈撳叆
+ *
+ * 瀛楁鍒嗗伐锛歡rapes 鏄� GrapesJS 鍘熷椤圭洰鏁版嵁锛堣璁″櫒杩樺師鐢诲竷鐢紝娓叉煋寮曟搸涔熻瀹冿紝
+ * 姝f枃缁撴瀯鍦ㄨ繖閲岋級锛沜omponents 鏄川閲忕粍浠朵笟鍔¤涔夋竻鍗曪紙娓叉煋涓嶈锛夛紱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;
+ }
+
+ /** 鍒涘缓鐗堟湰鍏ュ弬锛歷ersion 鐣欑┖鐢卞悗绔寜鏈�鏂扮増鏈嚜澧� */
+ 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}`);
+}
\ No newline at end of file
diff --git a/src/components/quality/base/divider.ts b/src/components/quality/base/divider.ts
new file mode 100644
index 0000000..a3b084c
--- /dev/null
+++ b/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')}`,
+ },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/base/heading.ts b/src/components/quality/base/heading.ts
new file mode 100644
index 0000000..4b2430f
--- /dev/null
+++ b/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: '姝f枃閲岀殑鍒嗚妭灏忔爣棰橈紙濡傘�屾楠岀粨鏋溿�嶃�岃川閲忚瘎瀹氥�嶏級銆傛枃妗f渶椤堕儴鐨勬姤鍛婃爣棰樹笉瑕佺敤鎴戯紝鐢� 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'),
+ },
+ };
+ },
+};
\ No newline at end of file
diff --git a/src/components/quality/base/image.ts b/src/components/quality/base/image.ts
new file mode 100644
index 0000000..7e008cd
--- /dev/null
+++ b/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)}%`,
+ },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/base/spacer.ts b/src/components/quality/base/spacer.ts
new file mode 100644
index 0000000..98efd5a
--- /dev/null
+++ b/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` },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/base/table.ts b/src/components/quality/base/table.ts
new file mode 100644
index 0000000..582a02b
--- /dev/null
+++ b/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' })}"> </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>`;
+}
+
+/** 瑙f瀽閫楀彿鍒嗛殧鐨勮〃澶存枃瀛� */
+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' },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/base/text.ts b/src/components/quality/base/text.ts
new file mode 100644
index 0000000..2930066
--- /dev/null
+++ b/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'),
+ },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/core/assembler.ts b/src/components/quality/core/assembler.ts
new file mode 100644
index 0000000..d780407
--- /dev/null
+++ b/src/components/quality/core/assembler.ts
@@ -0,0 +1,134 @@
+/**
+ * AI 缁勪欢鑽夌 鈫� 鐢诲竷鏁版嵁锛堣涔夊眰鐨勯�嗚繍绠楋級
+ * <p>
+ * 涓� use-designer 鐨� collectSchema 鏋勬垚寰�杩旓細collectSchema 浠庣敾甯冩敹闆� {type, props}锛�
+ * 鏈ā鍧楁妸 {type, props} 瑁呭洖鐢诲竷銆備袱鑰呭繀椤婚�愰」瀵归綈锛屽惁鍒欍�孉I 鐢熸垚 鈫� 淇濆瓨 鈫� 鍐嶆墦寮�銆�
+ * 浼氭帀缁勪欢鎴栭敊浣嶅睘鎬с��
+ * <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銆俻rops 鐨� 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锛�
+ * 閮戒笉浼氳鏁存壒澶辫触鈥斺�擜I 鐨勪骇鐗╂湰鏉ュ氨涓嶅彲淇★紝鍏ㄦ湁鎴栧叏鏃犲彧浼氳鐢ㄦ埛鐧界瓑涓�娆¤瘑鍒��
+ */
+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 鍋氱殑姝f槸寰�杩旀墍闇�鐨勫洓浠朵簨锛歞efaults 鎵撳簳銆乥uildContent 鐢熸垚缁撴瀯銆�
+ // propsToAttributes 鍙緭鍑哄0鏄庤繃鐨� key 骞跺瓧绗︿覆鍖栥�佸啓鍏� data-quality-type銆�
+ const content = buildQualityContent(definition, normalized.props) as Record<string, unknown>;
+ // traits 涓嶈繘鐢诲竷鏁版嵁锛氳浇鍏ュ悗鐢� restoreQualityTraits 鎸� data-quality-type 閲嶅缓锛�
+ // 涓庛�屾墦寮�涓�浠藉凡淇濆瓨鐨勭増鏈�嶈蛋鐨勬槸鍚屼竴鏉¤矾锛坱raits 鏈潵涔熶笉鍙備笌椤圭洰鏁版嵁搴忓垪鍖栵級銆�
+ 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 鏄惁澹版槑杩囷細鏈0鏄庣殑 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 };
+}
diff --git a/src/components/quality/core/attrs.ts b/src/components/quality/core/attrs.ts
new file mode 100644
index 0000000..a0fc8f1
--- /dev/null
+++ b/src/components/quality/core/attrs.ts
@@ -0,0 +1,47 @@
+/**
+ * 璐ㄩ噺缁勪欢鍦ㄧ敾甯冧笂鐨勬爣璁扮害瀹氥��
+ * <p>
+ * 鐢诲竷鑺傜偣闈犲睘鎬ц嚜鎻忚堪锛歞ata-quality-type 澹版槑瀹冩槸鍝釜缁勪欢锛�
+ * data-qc-<key> 鎵胯浇涓氬姟灞炴�у�硷紝涓よ�呬竴璧疯鐢诲竷锛坓rapes 鍘熷鏁版嵁锛夋湰韬氨鑳借繕鍘熶笟鍔¤涔夈��
+ */
+
+/** 鏍囪缁勪欢绫诲瀷鐨勫睘鎬у悕 */
+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);
+}
\ No newline at end of file
diff --git a/src/components/quality/core/canvas-sync.ts b/src/components/quality/core/canvas-sync.ts
new file mode 100644
index 0000000..95fd2f9
--- /dev/null
+++ b/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-* 涓哄噯
+ * 锛坱rait 鍒濆鍖栦紭鍏堣缁勪欢灞炴�э紝璇讳笉鍒版墠鐢ㄩ粯璁ゅ�硷級锛屾墍浠ヤ笉浼氭妸鐢ㄦ埛鏀硅繃鐨勫�煎啿鎺夈��
+ */
+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);
+ });
+}
\ No newline at end of file
diff --git a/src/components/quality/core/catalog.ts b/src/components/quality/core/catalog.ts
new file mode 100644
index 0000000..6bcb9ca
--- /dev/null
+++ b/src/components/quality/core/catalog.ts
@@ -0,0 +1,90 @@
+/**
+ * 璐ㄩ噺缁勪欢娉ㄥ唽琛� 鈫� AI 绉湪娓呭崟
+ * <p>
+ * 娓呭崟鏄敞鍐岃〃鐨�**娲剧敓瑙嗗浘**锛氬墺鎺� icon / buildContent / validate 杩欎簺涓嶅彲搴忓垪鍖栫殑鎴愬憳锛�
+ * 鍙暀涓嬫ā鍨嬮渶瑕佺煡閬撶殑銆屾湁鍝簺缁勪欢銆佹瘡涓粍浠惰兘濉摢浜涘瓧娈点�嶃��
+ * <p>
+ * 杩欎唤娓呭崟闅忚姹備紶缁欏悗绔嫾杩涙彁绀鸿瘝锛屼絾瀹�<b>涓嶆槸</b>鍚堟硶鎬х殑鍒ゆ嵁锛氱湡姝f妸鍏崇殑鏄閰嶅櫒
+ * 瀵圭潃娲绘敞鍐岃〃鏌� 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;
+}
diff --git a/src/components/quality/core/column-spec.ts b/src/components/quality/core/column-spec.ts
new file mode 100644
index 0000000..d6a5e8c
--- /dev/null
+++ b/src/components/quality/core/column-spec.ts
@@ -0,0 +1,137 @@
+/**
+ * 缁勪欢鍒楀畾涔� / 琛ㄥご璺ㄥ垪鐨勫瓧绗︿覆瑙f瀽銆�
+ * <p>
+ * 銆屽垪銆嶈繖浠朵簨蹇呴』鑳借缂栫爜鎴�**涓�涓爣閲忓瓧绗︿覆**锛氱粍浠朵笟鍔″睘鎬х殑绫诲瀷鍙湁
+ * boolean / enum / number / string / text锛堣 {@link QualityFieldSchema}锛夛紝
+ * 鏈�缁堣繕浼氳惤鍒扮敾甯冭妭鐐圭殑 attributes 涓婏紝鑰岄偅閲屼篃鏄瓧绗︿覆鏄犲皠銆�
+ * 鐢� JSON 缂栫爜鍒楁暟缁勪細瑕佹眰妯″瀷杈撳嚭杞箟鍚庣殑宓屽 JSON锛屾槸瀹冨嚭閿欑殑閲嶇伨鍖猴紱
+ * 涓�琛� `鏍囬=缁戝畾|鏍囬=缁戝畾` 淇℃伅閲忕浉鍚岋紝妯″瀷銆佷汉閮藉ソ鍐欍��
+ * <p>
+ * 蹇呴』鍗曡锛氳繖涓蹭笢瑗挎渶缁堟槸 HTML 灞炴�у�硷紝鎹㈣鍦ㄥ睘鎬ч噷鍚堟硶浣嗗鏄撹
+ * 缂栬緫鍣�/搴忓垪鍖栫幆鑺傛敼鍐欙紝缁熶竴鎸夊崟琛屽鐞嗐��
+ * <p>
+ * 瑙f瀽鏀惧湪杩欓噷鑰屼笉鏄悇缁勪欢鍐呴儴锛屾槸鍥犱负鏍¢獙鍒椾笌琛ㄥご璺ㄥ垪閮戒緷璧栧悓涓�濂楄娉曪紝
+ * 涓や唤瀹炵幇杩熸棭婕傜Щ锛岃�屾紓绉荤殑琛ㄧ幇鏄�屽垪鏁板涓嶄笂銆嶈繖绉嶅緢闅炬煡鐨勬覆鏌撲簨鏁呫��
+ */
+
+/** 涓�鍒楋細琛ㄥご鏂囧瓧 + 琛屽唴鍙栧�艰〃杈惧紡 */
+export interface QualityColumnSpec {
+ /** 琛ㄥご鏂囧瓧锛屽凡鍓ユ帀鍚堝苟鏍囪鍓嶇紑 */
+ label: string;
+ /** 琛屽唴鍙栧�艰〃杈惧紡锛屽 {{item.itemName}} */
+ expr: string;
+ /** 鏄惁绾靛悜鍚堝苟璺ㄤ綇鏁寸粍锛堟爣棰樺墠鍐欎簡 #锛� */
+ merge: boolean;
+}
+
+/** 涓婂眰琛ㄥご鐨勪竴涓崟鍏冩牸 */
+export interface QualityHeaderSpan {
+ label: string;
+ span: number;
+}
+
+/** 瑙f瀽缁撴灉锛氳兘瑙f瀽鍑哄灏戠畻澶氬皯锛岄敊璇�愭潯鍥炴姤锛岀敱璋冪敤鏂瑰喅瀹氭槸鍚︽嫤涓嬩繚瀛� */
+export interface QualitySpecParseResult<T> {
+ items: T[];
+ errors: string[];
+}
+
+/** 鍒椾笌鍒椾箣闂寸殑鍒嗛殧绗� */
+const SEGMENT_SEPARATOR = '|';
+
+/** 鍚堝苟鍒楃殑鏍囪鍓嶇紑 */
+const MERGE_MARK = '#';
+
+/** 琛ㄥご璺ㄥ垪鐨勮鏁版爣璁� */
+const SPAN_MARK = '^';
+
+/**
+ * 瑙f瀽鍒楀畾涔変覆銆�
+ *
+ * @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 };
+}
+
+/**
+ * 瑙f瀽涓婂眰琛ㄥご涓层��
+ *
+ * @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);
+}
diff --git a/src/components/quality/core/factory.ts b/src/components/quality/core/factory.ts
new file mode 100644
index 0000000..edf1629
--- /dev/null
+++ b/src/components/quality/core/factory.ts
@@ -0,0 +1,129 @@
+/**
+ * 璐ㄩ噺缁勪欢 鈫� GrapesJS 浜х墿
+ * <p>
+ * 鍗忚锛圦ualityComponentDefinition锛変笌 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 + 灞炴�� 鈫� 鐢诲竷鍐呭锛宎ttributes 閲屽甫涓婄被鍨嬫爣璁颁笌鍏ㄩ儴涓氬姟灞炴�� */
+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锛宼rait 鍊肩洿鎺ュ啓鍥炵粍浠剁殑 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',
+ // 鏄惧紡澹版槑鐪�/鍋囧瓧闈㈤噺锛孌OM 灞炴�у彧鑳芥槸瀛楃涓诧紝闈犲畠鎵嶈兘绋冲畾寰�杩�
+ 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'],
+ // 鐐瑰嚮缁勪欢鍧楄拷鍔犲埌绾稿紶鏈熬銆侴rapesJS 榛樿杩藉姞杩涖�屽綋鍓嶉�変腑鐨勭粍浠躲�嶅唴閮紝
+ // 鍦ㄦ姤鍛婅繖绉嶆墎骞崇粨鏋勯噷浼氭妸鏂扮粍浠跺杩涙爣棰樸�佽〃鏍煎崟鍏冩牸绛夊厓绱狅紝杩樹細椤舵帀瀹冧滑鍘熸湁鐨勬枃鏈��
+ // 闇�瑕佸祵濂楁椂浠嶅彲鐢ㄦ嫋鎷界簿纭惤鐐广��
+ onClick: (block, editor) => {
+ const content = block.get('content') as ComponentAdd;
+ editor.getWrapper()?.append(content);
+ },
+ };
+}
+
+/** 娉ㄥ唽琛ㄩ噷鐨勫叏閮ㄧ粍浠� 鈫� GrapesJS 缁勪欢鍧楀垪琛� */
+export function buildQualityBlocks(): BlockProperties[] {
+ return listQualityComponents().map(buildQualityBlock);
+}
\ No newline at end of file
diff --git a/src/components/quality/core/html.ts b/src/components/quality/core/html.ts
new file mode 100644
index 0000000..e8fd5c7
--- /dev/null
+++ b/src/components/quality/core/html.ts
@@ -0,0 +1,30 @@
+/**
+ * 鐢诲竷鍐呭涓庢覆鏌撲骇鐗╅兘璧� HTML 瀛楃涓诧紝浠讳綍鏉ヨ嚜鐢ㄦ埛锛堣璁″櫒灞炴�ч潰鏉匡級鐨勬枃鏈�
+ * 閮藉繀椤诲厛杞箟鍐嶆嫾杩涙爣绛撅紝鍚﹀垯鍦ㄦ覆鏌撴湡浼氬彉鎴愭敞鍏ョ偣銆�
+ */
+export function escapeHtml(value: unknown): string {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+/**
+ * 杞箟涔嬪悗鍐嶆妸鎹㈣鎶樻垚 <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(';');
+}
\ No newline at end of file
diff --git a/src/components/quality/core/icons.ts b/src/components/quality/core/icons.ts
new file mode 100644
index 0000000..bf83191
--- /dev/null
+++ b/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;
\ No newline at end of file
diff --git a/src/components/quality/core/lookup.ts b/src/components/quality/core/lookup.ts
new file mode 100644
index 0000000..ce3f61e
--- /dev/null
+++ b/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] ?? ''));
+}
\ No newline at end of file
diff --git a/src/components/quality/core/registry.ts b/src/components/quality/core/registry.ts
new file mode 100644
index 0000000..f8ede31
--- /dev/null
+++ b/src/components/quality/core/registry.ts
@@ -0,0 +1,60 @@
+/**
+ * 璐ㄩ噺缁勪欢娉ㄥ唽琛�
+ * <p>
+ * 鍏ㄥ钩鍙板敮涓�鐨勭粍浠舵潵婧愶細璁捐鍣ㄥ乏渚ч潰鏉裤�佸睘鎬ч潰鏉裤�佹覆鏌撳紩鎿庛�佹牎楠岄兘浠庤繖閲屽彇瀹氫箟銆�
+ * 鏂板缁勪欢鍙渶瑕佸湪 index.ts 閲屽娉ㄥ唽涓�涓� definition锛屼笉鏀瑰姩 GrapesJS 闆嗘垚浠g爜銆�
+ */
+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;
+}
\ No newline at end of file
diff --git a/src/components/quality/core/serializer.ts b/src/components/quality/core/serializer.ts
new file mode 100644
index 0000000..ec479c5
--- /dev/null
+++ b/src/components/quality/core/serializer.ts
@@ -0,0 +1,56 @@
+/**
+ * 璐ㄩ噺灞炴�� 鈫� 鐢诲竷 attributes 鐨勫弻鍚戣浆鎹�
+ * <p>
+ * 涓氬姟灞炴�х粺涓�钀藉湪鐢诲竷鑺傜偣鐨� data-qc-<key> 涓婏紝鐢诲竷鏁版嵁锛坓rapes锛夋湰韬嵆鑷弿杩帮紝
+ * 淇濆瓨銆佸洖鏄俱�佹覆鏌撻兘涓嶉渶瑕佸啀棰濆缁存姢涓�浠芥槧灏勮〃銆�
+ */
+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 鈫� 涓氬姟灞炴�э紙鎸夊瓧娈靛0鏄庤繕鍘熺被鍨嬶級 */
+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;
+}
\ No newline at end of file
diff --git a/src/components/quality/core/trait-binding.ts b/src/components/quality/core/trait-binding.ts
new file mode 100644
index 0000000..70ef886
--- /dev/null
+++ b/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}} 浜ゆ覆鏌撴湡瑙f瀽锛宲ath 蹇呴』淇濇寔瑁歌矾寰� */
+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);
+ }
+ },
+ });
+}
\ No newline at end of file
diff --git a/src/components/quality/core/types.ts b/src/components/quality/core/types.ts
new file mode 100644
index 0000000..47f2857
--- /dev/null
+++ b/src/components/quality/core/types.ts
@@ -0,0 +1,133 @@
+/**
+ * 璐ㄩ噺缁勪欢鍗忚锛圦uality Component Protocol锛�
+ * <p>
+ * 璁捐鍣ㄥ彧璁よ繖濂楀崗璁紝涓嶈鍏蜂綋涓氬姟缁勪欢锛氭柊澧炶川閲忕粍浠讹紳娉ㄥ唽涓�涓柊 definition锛�
+ * 涓嶉渶瑕佹敼鍔� GrapesJS 闆嗘垚浠g爜锛屼篃涓嶉渶瑕佹敼鍔ㄨ璁″櫒椤甸潰锛堟柟妗堣璁� 搂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 鏃跺睘鎬ч潰鏉夸笉鎻愪緵銆岀粦瀹氭暟鎹�嶅叆鍙c��
+ */
+ 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;
+ /** 鑻辨枃鍚嶏紝鐢ㄤ簬浠g爜鍐呭紩鐢� */
+ name: string;
+ /** 璁捐鍣ㄥ乏渚ч潰鏉夸笌灞炴�ч潰鏉跨殑鏄剧ず鍚� */
+ label: string;
+ category: QualityCategory;
+ /** 宸︿晶闈㈡澘鐨勫浘鏍囷紙鍐呰仈 SVG 瀛楃涓诧級 */
+ icon: string;
+ /** 鏂扮粍浠惰惤鍒扮敾甯冩椂鐨勫垵濮嬩笟鍔″睘鎬� */
+ defaults: QualityProps;
+ /** 璁捐鏈熷彲缂栬緫鐨勪笟鍔″睘鎬� */
+ propertySchema: QualityFieldSchema[];
+ /** 娓叉煋鏈熷彲缁戝畾鐨勬暟鎹瓧娈� */
+ dataSchema: QualityFieldSchema[];
+ /**
+ * 缁勪欢鐨勫彲瑙佹枃鏈搴旂殑灞炴�� key銆�
+ * <p>
+ * 澹版槑鍚庯紝鐢ㄦ埛鍦ㄧ敾甯冨唴鐩存帴鏀规枃瀛椾細鍚屾鍥炶灞炴�э紝灞炴�ч潰鏉挎敼鍊间篃浼氬悓姝ュ洖鐢诲竷锛�
+ * 鍐呭鐢卞涓厓绱犳嫾鎴愮殑缁勪欢锛堥〉鐪夈�佽〃鏍硷級涓嶅0鏄庯紝閬垮厤鏁村潡 HTML 琚綋鎴愭枃鏈��
+ */
+ contentKey?: string;
+ /**
+ * 缁� AI 瀵煎叆鐢ㄧ殑閫夊瀷璇存槑锛氳繖涓粍浠剁敤鏉ユ斁浠�涔堛�佷笉瑕佺敤鏉ユ斁浠�涔堛��
+ * <p>
+ * 涓嶈繘璁捐鍣ㄧ晫闈紝鍙殢绉湪娓呭崟杩涙彁绀鸿瘝銆傚啓瀹冩槸鍥犱负娓呭崟閲屽彧鏈� label 鏃讹紝
+ * 妯″瀷闈㈠銆屾姤鍛婃姮澶淬�嶈繖绫诲唴瀹逛細鍦� Heading / Text / ReportHeader 涔嬮棿鐬庣寽锛�
+ * 鑰屽彧浼氱湅鍒颁腑鏂囨爣绛剧殑瀹冩病鏈変换浣曠悊鐢变笉鎸戞渶閫氱敤鐨勯偅涓��
+ * <p>
+ * 蹇呴』鍖呭惈銆屼笉瑕佺敤鏉ユ斁浠�涔堛�嶏細鍙鐢ㄩ�旀尅涓嶄綇璇敤锛岃娓呰竟鐣屾墠鏈夌敤銆�
+ */
+ aiHint?: string;
+ /**
+ * 鐢熸垚鐢诲竷鍐呭銆俻roperties 宸茬粡鏀舵暃杩囬粯璁ゅ�硷紝瀹炵幇閲屽彲浠ョ洿鎺ョ敤銆�
+ */
+ buildContent: (properties: QualityProps) => QualityComponentContent;
+ /**
+ * 鏍¢獙涓氬姟灞炴�э紝杩斿洖閿欒淇℃伅鏁扮粍锛涚┖鏁扮粍琛ㄧず閫氳繃銆�
+ * 浠呭湪淇濆瓨/鍙戝竷鍓嶈皟鐢紝姝e父璁捐杩囩▼涓嶅簲瑙﹀彂銆�
+ */
+ validate?: (properties: QualityProps) => string[];
+}
\ No newline at end of file
diff --git a/src/components/quality/core/validator.ts b/src/components/quality/core/validator.ts
new file mode 100644
index 0000000..e4b613b
--- /dev/null
+++ b/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;
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/binding.ts b/src/components/quality/engine/binding.ts
new file mode 100644
index 0000000..1966492
--- /dev/null
+++ b/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];
+}
+
+/** 瑙f瀽鍗曚釜缁戝畾璺緞锛屽彇涓嶅埌杩斿洖 undefined */
+export function resolveBinding(path: string, context: unknown): unknown {
+ return readPath(context, path.trim());
+}
+
+/**
+ * 鏇挎崲鏂囨湰閲岀殑鍏ㄩ儴缁戝畾琛ㄨ揪寮忥紝骞跺洖鎶ュ彇涓嶅埌鍊肩殑璺緞銆�
+ * <p>
+ * 娓叉煋鏃剁粦瀹氫笉涓婇�氬父鎰忓懗鐫�鏁版嵁缂哄彛锛岄潤榛樻覆鏌撴垚绌轰細璁╂姤鍛婄湅璧锋潵"姝e父浣嗙己鍐呭"锛�
+ * 鎵�浠ユ妸缂哄彛浜ょ粰璋冪敤鏂癸紝鐢辨覆鏌撳紩鎿庢眹鎬绘垚鍙鐨勯棶棰樻竻鍗曘��
+ */
+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();
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/context.ts b/src/components/quality/engine/context.ts
new file mode 100644
index 0000000..273691f
--- /dev/null
+++ b/src/components/quality/engine/context.ts
@@ -0,0 +1,169 @@
+/**
+ * 鎶ュ憡涓婁笅鏂囷紙Context锛�
+ * <p>
+ * 妯℃澘閲岀殑 {{path}} 鍏ㄩ儴鐩稿杩欎釜缁撴瀯瑙f瀽锛歳eport 鏄姤鍛婄骇瀛楁锛宨nspectionItems 鏄楠岄」鏁扮粍銆�
+ * 涓氬姟鍗曟嵁 鈫� 涓婁笅鏂囩殑鏄犲皠鐢变笂涓嬫枃鏋勫缓鍣ㄨ礋璐o紝妯℃澘鏈韩鍙杩欎釜鏍囧噯缁撴瀯锛�
+ * 鎹㈣涓氥�佹崲鍗曟嵁鏉ユ簮閮戒笉闇�瑕佹敼妯℃澘涓庣粍浠躲��
+ */
+
+/** 鍒ゅ畾缁撴灉鍙栧�硷紝缁勪欢涓庤鍒欏紩鎿庡叡鐢� */
+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,
+ };
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/page.ts b/src/components/quality/engine/page.ts
new file mode 100644
index 0000000..a0d4a17
--- /dev/null
+++ b/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;
+}
+
+/** 瑙f瀽绾稿紶灏哄涓庨〉杈硅窛锛堟绫筹級锛屾í鍚戞椂缈昏浆瀹介珮 */
+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,
+ },
+ };
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/path.ts b/src/components/quality/engine/path.ts
new file mode 100644
index 0000000..29d4769
--- /dev/null
+++ b/src/components/quality/engine/path.ts
@@ -0,0 +1,105 @@
+/**
+ * 涓婁笅鏂囧彇鍊�
+ * <p>
+ * 鍙仛鐧藉悕鍗曞紡鐨勫睘鎬у鑸紝涓嶈В鏋愩�佷笉鎵ц浠讳綍浠g爜锛�
+ * 鏀寔 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] : [];
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/render.ts b/src/components/quality/engine/render.ts
new file mode 100644
index 0000000..3ae07a2
--- /dev/null
+++ b/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;
+ /** 姝f枃鐗囨锛屼究浜庡祵鍏ラ〉闈㈤瑙� */
+ 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銆乻pan銆乭2锛夛紝
+ * 琛ㄦ牸缁撴瀯锛坱able/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');
+
+/** 娓叉煋鎶ュ憡锛氬垽瀹� 鈫� 鐢熸垚姝f枃 鈫� 鎷兼牱寮� 鈫� 缁勮鏂囨。 */
+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)}"` : '';
+}
+
+/** 鍙栨枃鏈噷鐨勭粦瀹氬�硷紝鍙栦笉鍒扮殑璺緞璁拌繘闂娓呭崟锛堟湭瑙f瀽鐨� {{}} 涓嶄細鐣欏湪浜х墿閲岋級 */
+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 鏂囨湰锛汫rapesJS 鐢� #缁勪欢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"]`);
+}
+
+/** 缁勮鏈�缁堟枃妗o細绾稿紶涓庨〉杈硅窛浜ょ粰 @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');
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/report-evaluator.ts b/src/components/quality/engine/report-evaluator.ts
new file mode 100644
index 0000000..55b45ac
--- /dev/null
+++ b/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 閫愰」鍒ゅ畾锛宺eport 鎶ュ憡绾ф眹鎬� */
+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>
+ * 鍗曚釜妫�楠岄」鐨勫垽瀹氫紭鍏堢骇锛歩tem 浣滅敤鍩熻鍒� 鈫� 瑙勬牸涓婁笅闄� 鈫� 鏁版嵁鑷甫缁撴灉銆�
+ * 鎶ュ憡绾у垽瀹氶粯璁ゅ彇銆屽叏閮ㄦ楠岄」鍚堟牸銆嶏紝鏈� 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;
+}
\ No newline at end of file
diff --git a/src/components/quality/engine/rule-engine.ts b/src/components/quality/engine/rule-engine.ts
new file mode 100644
index 0000000..9581512
--- /dev/null
+++ b/src/components/quality/engine/rule-engine.ts
@@ -0,0 +1,711 @@
+/**
+ * 鍒ゅ畾瑙勫垯寮曟搸
+ * <p>
+ * 閫掑綊涓嬮檷瑙f瀽鎴� 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} 鐨勪綅缃笉姝g‘`, token.position);
+ }
+ return { type: 'path', path: token.value };
+ }
+
+ throw new RuleSyntaxError(
+ token.type === 'eof' ? '琛ㄨ揪寮忎笉瀹屾暣' : `鏃犳硶瑙f瀽鐨勫唴瀹癸細${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;
+ }
+}
+
+/** 瑙f瀽瑙勫垯鏂囨湰锛岃娉曢敊璇細鎶� 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);
+ },
+ /** 涓嶅悎鏍肩巼锛堢櫨鍒嗘瘮锛夛紝鍙e緞涓� 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));
+}
+
+/** 瀵瑰凡瑙f瀽鐨勮娉曟爲姹傚�硷紝閬垮厤鍚屼竴鏉¤鍒欏湪澶氳涓婇噸澶嶈В鏋� */
+export function evaluateRuleNode(node: RuleNode, context: unknown): unknown {
+ return evaluateNode(node, context);
+}
+
+/** 瀵瑰凡瑙f瀽鐨勮娉曟爲鍋氬竷灏斿垽瀹� */
+export function evaluateConditionNode(node: RuleNode, context: unknown): boolean {
+ return truthy(evaluateRuleNode(node, context));
+}
+
+/** 瑙f瀽鍚庣殑璺緞渚濊禆锛屾覆鏌撳墠鍙嵁姝ゆ鏌ユ暟鎹槸鍚﹂綈澶� */
+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('.')))];
+}
\ No newline at end of file
diff --git a/src/components/quality/header/report-footer.ts b/src/components/quality/header/report-footer.ts
new file mode 100644
index 0000000..abaafd3
--- /dev/null
+++ b/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',
+ },
+ }),
+};
\ No newline at end of file
diff --git a/src/components/quality/header/report-header.ts b/src/components/quality/header/report-header.ts
new file mode 100644
index 0000000..1cc291a
--- /dev/null
+++ b/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' },
+ };
+ },
+};
\ No newline at end of file
diff --git a/src/components/quality/header/sample-info.ts b/src/components/quality/header/sample-info.ts
new file mode 100644
index 0000000..87346c7
--- /dev/null
+++ b/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}锛夛紝
+ * 鍏嶅緱鍚屼竴涓�岄敭鍊煎鍒楄〃銆嶉暱鍑轰袱绉嶅啓娉曘�傚悎骞舵爣璁� `#` 鍦ㄨ繖閲屾病鏈夋剰涔夛紝瑙f瀽鍑烘潵涔熷彧鏄涓㈡帀锛�
+ * 鎵�浠� 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;
+ },
+};
diff --git a/src/components/quality/index.ts b/src/components/quality/index.ts
new file mode 100644
index 0000000..38b6ce0
--- /dev/null
+++ b/src/components/quality/index.ts
@@ -0,0 +1,97 @@
+/**
+ * 璐ㄩ噺缁勪欢缁熶竴娉ㄥ唽鍏ュ彛
+ * <p>
+ * 璁捐鍣ㄣ�佹覆鏌撳紩鎿庨兘浠庤繖閲屽彇缁勪欢锛涙柊澧炰竴涓川閲忕粍浠讹紳鍦ㄤ笅闈㈢殑娓呭崟閲屽姞涓�涓� definition锛�
+ * 涓嶆敼鍔� GrapesJS 闆嗘垚浠g爜锛屼篃涓嶆敼鍔ㄨ璁″櫒椤甸潰銆�
+ */
+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';
\ No newline at end of file
diff --git a/src/components/quality/inspection/grouped-quality-table.ts b/src/components/quality/inspection/grouped-quality-table.ts
new file mode 100644
index 0000000..b4d5d81
--- /dev/null
+++ b/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 姝f枃瀹藉害锛屾祻瑙堝櫒鍙兘鎶婅〃鏍兼拺鍑虹焊寮狅紝鏈�鏈竴鍒楄鎸ゆ垚鍗佸嚑涓儚绱犵殑绔栨潯銆�
+ * <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 瑙f瀽鎴愯妭鐐� style 瀵硅薄锛�
+ * 鑰屾覆鏌撴湡瀵� style 瀵硅薄涓嶈窇缁戝畾锛坮enderStyle锛夛紝缁戝畾涓蹭細琚暣鏉′涪鎺夛紱
+ * 灞炴�у垯鍘熸牱淇濈暀锛屼袱渚ф覆鏌撳櫒閮戒細鎶� {{}} 瑙f垚鍊笺��
+ * <p>
+ * hidden 鐢ㄥ瓧绗︿覆鑰岄潪甯冨皵锛氳繖涓睘鎬ч潬銆屽湪涓嶅湪銆嶈捣浣滅敤锛坔idden="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>
+ * 鑷畾涔夊垪鏃�**涓嶅啀鐪嬪悇鏄剧ず寮�鍏�**锛歝olumns 宸茬粡鎶婂垪涓庨『搴忚鍏ㄤ簡锛�
+ * 鍐嶅彔涓�灞傘�屽簭鍙疯闅愯棌浜嗕絾鐢ㄦ埛鍙堝啓鍥炴潵浜嗐�嶇殑寮�鍏冲垽鏂紝鍙細璁╀袱杈规墦鏋躲��
+ *
+ * @return custom 涓虹湡琛ㄧず杩欏紶琛ㄧ収 columns 鏉ワ紙鎷夸笉鍒颁换浣曞搴﹀0鏄庯紝鍗曞厓鏍艰鍏佽鎶樿锛�
+ */
+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();
+}
+
+/** 涓婂眰鐨勫嚭琛ㄦ柟妗堬細涓婂眰閭d竴琛岀殑鍗曞厓鏍硷紝浠ュ強涓嬪眰瑕佽烦杩囧摢浜涘垪 */
+interface HeaderPlan {
+ cells: string;
+ covered: Set<number>;
+}
+
+/**
+ * 瑙勫垝涓婂眰琛ㄥご銆�
+ * <p>
+ * 琛ㄥご閲岀殑鏍煎瓙鏈変袱绉嶅舰鎬侊紝閮戒粠鍚屼竴涓� `鏍囬^璺ㄥ垪鏁癭 閲岃鍑烘潵锛�
+ * <ul>
+ * <li>鍒嗙粍鏍囬锛氭í鍚戣法浣忎笅闈㈣嫢骞插垪锛屽 `妫�楠岀粨鏋淾5`锛�</li>
+ * <li>绾靛悜鍚堝苟鏍硷細鏌愪竴鍒楄嚜宸辨棦鏄垪鍚嶅張鏄笂鏍硷紝鍘熶欢閲屾槸涓�鏍艰法浣忎笂涓嬩袱灞�
+ * 锛堟彁鍙栨枃鏈噷琛ㄧ幇涓轰笅灞傝〃澶撮偅涓�鏍煎啓鐫�銆屸啈鍚屼笂銆嶏級锛屽 `缁撹^1`銆�
+ * 璁ゅ嚭瀹冮潬銆岃娈垫爣棰樹笌瀹冩涓嬫柟閭d竴鍒楃殑鍒楁爣棰樹竴瀛椾笉宸�佷笖鍙法 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:
+ '鍙湁琛ㄥご鍒嗕笂涓嬩袱灞傛椂鎵嶅~锛屽啓鐨勬槸涓婇潰閭d竴灞傦細姣忎釜鍗曞厓鏍煎啓浣� 鏍囬^璺ㄨ秺鍒楁暟锛屽娈电敤 | 鍒嗛殧锛�' +
+ '濡� 妫�楠岀粨鏋淾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;
+ },
+};
diff --git a/src/components/quality/inspection/inspection-item.ts b/src/components/quality/inspection/inspection-item.ts
new file mode 100644
index 0000000..5f9a3b4
--- /dev/null
+++ b/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',
+ },
+ };
+ },
+};
\ No newline at end of file
diff --git a/src/components/quality/inspection/quality-table.ts b/src/components/quality/inspection/quality-table.ts
new file mode 100644
index 0000000..8ef52a4
--- /dev/null
+++ b/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;
+ },
+};
\ No newline at end of file
diff --git a/src/components/quality/result/report-result.ts b/src/components/quality/result/report-result.ts
new file mode 100644
index 0000000..81ca884
--- /dev/null
+++ b/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>
+ * 缁撹涓庡悎鏍肩巼鏉ヨ嚜鎶ュ憡涓婁笅鏂囷紝鐢辫鍒欏紩鎿庡湪娓叉煋鍓嶇畻濂斤紱
+ * 杩欓噷鍙礋璐e睍绀猴紝涓嶅湪缁勪欢閲屽仛浠讳綍鍒ゅ畾璁$畻銆�
+ */
+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',
+ },
+ };
+ },
+};
\ No newline at end of file
diff --git a/src/packages/constants/src/biz-mes-enum.ts b/src/packages/constants/src/biz-mes-enum.ts
index c84f3fc..0d7693a 100644
--- a/src/packages/constants/src/biz-mes-enum.ts
+++ b/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)
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index 2a62e5a..53e2121 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/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;
diff --git a/src/router/routes/modules/qcreport.ts b/src/router/routes/modules/qcreport.ts
new file mode 100644
index 0000000..7c5e05e
--- /dev/null
+++ b/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;
\ No newline at end of file
diff --git a/src/views/mes/qc/components/report-generate-modal.vue b/src/views/mes/qc/components/report-generate-modal.vue
new file mode 100644
index 0000000..5dbb09c
--- /dev/null
+++ b/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"锛夛紝鎷裤�孖QC銆嶈繖绫昏嫳鏂囧悕鍘绘瘮浼氫竴涓兘鏌ヤ笉鍒帮紱
+ * 涓� 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>
diff --git a/src/views/mes/qc/indicator/data.ts b/src/views/mes/qc/indicator/data.ts
index a20cc84..947d209 100644
--- a/src/views/mes/qc/indicator/data.ts
+++ b/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,
diff --git a/src/views/mes/qc/indicator/index.vue b/src/views/mes/qc/indicator/index.vue
index ac34922..b49abd3 100644
--- a/src/views/mes/qc/indicator/index.vue
+++ b/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) }}
diff --git a/src/views/mes/qc/ipqc/index.vue b/src/views/mes/qc/ipqc/index.vue
index 832a85a..2d47360 100644
--- a/src/views/mes/qc/ipqc/index.vue
+++ b/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,
diff --git a/src/views/mes/qc/iqc/index.vue b/src/views/mes/qc/iqc/index.vue
index 761305f..5759607 100644
--- a/src/views/mes/qc/iqc/index.vue
+++ b/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,
diff --git a/src/views/mes/qc/oqc/index.vue b/src/views/mes/qc/oqc/index.vue
index f9043b1..9a62cdd 100644
--- a/src/views/mes/qc/oqc/index.vue
+++ b/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,
diff --git a/src/views/mes/qc/pendinginspect/index.vue b/src/views/mes/qc/pendinginspect/index.vue
index 486e3ad..cc33a44 100644
--- a/src/views/mes/qc/pendinginspect/index.vue
+++ b/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'],
diff --git a/src/views/mes/qc/report/instance/data.ts b/src/views/mes/qc/report/instance/data.ts
new file mode 100644
index 0000000..b41b555
--- /dev/null
+++ b/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' },
+ },
+ ];
+}
diff --git a/src/views/mes/qc/report/instance/index.vue b/src/views/mes/qc/report/instance/index.vue
new file mode 100644
index 0000000..2503bae
--- /dev/null
+++ b/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锛岄濉埌鎼滅储鏉′欢閲屽苟鐩存帴鏌ヤ竴娆★紝
+ * 鐢ㄦ埛杩涙潵灏辩湅鍒板垰鍑虹殑閭d唤鎶ュ憡锛屼笉鐢ㄨ嚜宸卞啀绛涗竴閬嶃��
+ * <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>
diff --git a/src/views/mes/qc/report/instance/modules/detail.vue b/src/views/mes/qc/report/instance/modules/detail.vue
new file mode 100644
index 0000000..6147261
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/qc/report/template/data.ts b/src/views/mes/qc/report/template/data.ts
new file mode 100644
index 0000000..fe46ea3
--- /dev/null
+++ b/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' },
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/views/mes/qc/report/template/designer/constants.ts b/src/views/mes/qc/report/template/designer/constants.ts
new file mode 100644
index 0000000..fcae0e6
--- /dev/null
+++ b/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 },
+};
+
+/** 璁$畻鐢诲竷灏哄锛坧x锛夛紝鎸夌焊寮犳柟鍚戣嚜鍔ㄧ炕杞楂� */
+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: [],
+ };
+}
\ No newline at end of file
diff --git a/src/views/mes/qc/report/template/designer/index.vue b/src/views/mes/qc/report/template/designer/index.vue
new file mode 100644
index 0000000..f07a6ef
--- /dev/null
+++ b/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} 鏉″垽瀹氳鍒欒繕娌″啓瀹屾暣锛岃鍏堜慨姝e啀淇濆瓨`);
+ return true;
+}
+
+const ruleButtonText = computed(() =>
+ rules.value.length > 0 ? `鍒ゅ畾瑙勫垯锛�${rules.value.length}锛塦 : '鍒ゅ畾瑙勫垯',
+);
+
+/** 鎶藉眽閲屾敼瀹岀殑瑙勫垯鍐欏洖璁捐鍣ㄧ姸鎬侊紝淇濆瓨鏃剁敱 collectSchema 涓�骞朵骇鍑� */
+function handleRulesChange(next: QcReportVersionApi.SchemaRule[]) {
+ rules.value = next;
+}
+
+/** 鐢诲竷涓婂凡鏈夌殑椤跺眰缁勪欢鏁帮紝鐢ㄦ潵鍒ゆ柇 AI 瀵煎叆浼氫笉浼氳鐩栨帀鐢ㄦ埛姝e湪鍋氱殑鍐呭 */
+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;
+ }
+}
+
+/** 鍒囨崲鐗堟湰锛圓ntDV 鐨� 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;
+ }
+ },
+ });
+}
+
+/** 鐢诲竷缂╂斁锛圫elect 鍥炶皟鍊肩被鍨嬫瘮 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 楂樺害锛屽乏鍙虫爮鎵嶈兘姝e父婊氬姩銆�
+ */
+: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 鐨勭粷瀵瑰畾浣嶈垶鍙帮紝绾稿紶姣斿彲瑙嗗尯澶ф椂浼氳瑁佹帀銆�
+ * 杩欓噷鎶婄敾甯冩敼鎴愭櫘閫氭粴鍔ㄥ鍣細甯у鍣ㄥ洖褰掓枃妗f祦锛岀焊寮犵敤 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>
\ No newline at end of file
diff --git a/src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue b/src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue
new file mode 100644
index 0000000..b90d793
--- /dev/null
+++ b/src/views/mes/qc/report/template/designer/modules/ai-import-modal.vue
@@ -0,0 +1,341 @@
+<script lang="ts" setup>
+/**
+ * AI 瀵煎叆寮圭獥锛氫笂浼犱竴浠藉凡鏈夌殑妫�楠屾姤鍛� 鈫� 璇嗗埆鎴愭ā鏉胯崏绋� 鈫� 浜ょ粰璁捐鍣ㄣ��
+ * <p>
+ * 寮圭獥鍙礋璐c�屾嬁鑽夌銆嶈繖涓�娈碉細涓婁紶銆佺粦闄勪欢銆佽姹傝瘑鍒�佺粰鐢ㄦ埛鐪嬬粨鏋溿��
+ * 瑁呭埌鐢诲竷涓婃槸璁捐鍣ㄧ殑浜嬶紝鐢辩埗缁勪欢鍦ㄧ敤鎴风‘璁ゅ悗鏉ュ仛鈥斺��
+ * 鐢诲竷闈炵┖鏃惰鍏堝脊鏇挎崲纭锛岄偅浠藉垽鏂睘浜庤璁″櫒鑰屼笉鏄繖閲屻��
+ */
+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']>;
+
+/** 宸查�夋枃浠舵暟锛汚ntDV 鐨� 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锛屾病鏈夊綊妗e畠浠殑蹇呰銆�
+ * <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">
+ 鏀寔鎵弿浠�/鐓х墖锛坧ng銆乯pg銆乯peg銆乥mp銆乬if銆亀ebp锛夈�丳DF銆乄ord(.docx)銆丒xcel(.xlsx)銆佹枃鏈�(txt銆乧sv)锛�
+ 涓�娆℃渶澶� {{ 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">
+ 姝e湪璇嗗埆锛岄璁� 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>
diff --git a/src/views/mes/qc/report/template/designer/modules/rule-editor.vue b/src/views/mes/qc/report/template/designer/modules/rule-editor.vue
new file mode 100644
index 0000000..4a54d80
--- /dev/null
+++ b/src/views/mes/qc/report/template/designer/modules/rule-editor.vue
@@ -0,0 +1,156 @@
+<script lang="ts" setup>
+/**
+ * 鍒ゅ畾瑙勫垯鎶藉眽銆�
+ * <p>
+ * 瑙勫垯鍒楄〃鐢辫璁″櫒鎸佹湁锛坲se-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>
diff --git a/src/views/mes/qc/report/template/designer/use-designer.ts b/src/views/mes/qc/report/template/designer/use-designer.ts
new file mode 100644
index 0000000..f82a89a
--- /dev/null
+++ b/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锛屼笉鍔ㄧ湡姝g殑 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>
+ * 鍙礋璐g敾甯冧笌 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);
+ }
+
+ /**
+ * 鐢诲竷鎶� {{...}} 鍘熸牱鐣欑潃涓嶆眰瑙o紝浜庢槸缁勫悕鏍间笂鐨� hidden="{{item.group.hidden}}"
+ * 鍦ㄦ祻瑙堝櫒鐪奸噷灏辨槸銆屾湁 hidden 灞炴�с�嶁�斺�旀暣鏍� display:none锛岃繖涓�琛屽悗闈㈢殑鏍兼暣浣撳乏绉讳竴鏍硷紝
+ * 鏈垪鍙墿琛ㄥご銆佽鎸ゆ垚绔栨帓缁嗘潯锛岃璁″櫒閲岀湅鍒扮殑鎺掔増璺熷嚭浠剁粨鏋滃涓嶄笂銆�
+ * 杩欓噷鍙湪鐢诲竷鏂囨。鍐呮妸銆屽�艰繕鍋滃湪鍗犱綅绗︺�嶇殑 hidden 杩樺師鎴愬彲瑙佹牸锛涘嚭浠舵椂璇ュ�煎凡瑙f垚
+ * 绌轰覆鎴� 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,
+ // 鐢辨湰骞冲彴鑷繁鐨勬帴鍙d繚瀛橈紝涓嶄娇鐢� 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;
+ }
+ // 鎷蜂竴灞傚啀鏀捐繘鍘伙細鎶藉眽閲屾敼鐨勬槸杩欎唤鍓湰锛屽垏鐗堟湰鏃朵笉璇ユ妸鏀瑰姩甯﹀埌鍙︿竴涓増鏈笂銆�
+ // 鍚屾椂鎶婂紩鎿庣殑缂虹渷鍙e緞钀芥垚鏄庣‘鍊硷紙scope 璇嗗埆涓嶅嚭鎸夐�愰」銆乪nabled 缂虹渷鍗冲惎鐢級锛�
+ // 鍚﹀垯鐣岄潰涓婄殑寮�鍏充細涓庡疄闄呭垽瀹氬涓嶄笂锛岀湅璧锋潵鍋滅敤浜嗗叾瀹炶繕鍦ㄥ垽銆�
+ 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锛歡rapes 淇濆瓨鍘熷鏁版嵁锛宑omponents 淇濆瓨璐ㄩ噺缁勪欢涓氬姟璇箟銆�
+ * <p>
+ * 浜у嚭鐨勬槸<b>鏁翠唤</b> Schema锛屽悗绔繚瀛樻椂鏁翠綋瑕嗙洊锛屾墍浠ヨ璁″櫒璁よ瘑鐨勫瓧娈靛繀椤诲湪杩欓噷鍒楀叏鈥斺��
+ * 婕忔帀鍝釜灏辩瓑浜庢妸閭d釜瀛楁娓呯┖锛坮ules 鏇剧粡灏辫繖鏍疯娓呮帀杩囷紝瑙� RuleEditor 鐨勮鏄庯級銆�
+ * 灏嗘潵 Schema 鏂板瀛楁鏃讹紝瑕佷箞鍦ㄨ繖閲屼骇鍑猴紝瑕佷箞鎯虫竻妤氱敱璋佽礋璐d繚鐣欍��
+ */
+ 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(),
+ // 缁勪欢鍦ㄦ枃妗d腑鐨勯『搴忥紝渚涘悗缁暟鎹粦瀹氫笌娓叉煋瀹氫綅
+ 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);
+ // 涓�涓粍浠堕兘瑁呬笉杩涘幓鏃朵笉瑕佸姩鐢诲竷锛歭oadSchema 浼氭竻鎺夋挙閿�鏍堜笌宸叉湁鍐呭锛�
+ // 鐧界櫧鎼繘鍘讳竴鐗堢敾甯冨嵈浠�涔堥兘鎹笉鏉�
+ 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;
+}
\ No newline at end of file
diff --git a/src/views/mes/qc/report/template/index.vue b/src/views/mes/qc/report/template/index.vue
new file mode 100644
index 0000000..9e46275
--- /dev/null
+++ b/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: `姝e湪澶嶅埗銆�${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>
\ No newline at end of file
diff --git a/src/views/mes/qc/report/template/modules/form.vue b/src/views/mes/qc/report/template/modules/form.vue
new file mode 100644
index 0000000..ac75b6d
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/mes/qc/rqc/index.vue b/src/views/mes/qc/rqc/index.vue
index 2771b4c..64564a9 100644
--- a/src/views/mes/qc/rqc/index.vue
+++ b/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,
diff --git a/vite.config.ts b/vite.config.ts
index 2d42c23..f8ca9af 100644
--- a/vite.config.ts
+++ b/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,
},
},
--
Gitblit v1.9.3