云
2 天以前 a241b66efcc41e973290a6722fb7a4d79b833ea1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import type { Editor, EditorConfig } from 'grapesjs';
 
import type { QualityAssemblySkip, QualityDraftComponent } from '#/components/quality';
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
 
import { ref, shallowRef } from 'vue';
 
import grapesjs from 'grapesjs';
 
import {
  assembleQualityCanvas,
  bindQualityCanvasSync,
  buildQualityBlocks,
  registerAllQualityComponents,
  registerBindingTraitType,
  restoreQualityTraits,
  QUALITY_TYPE_ATTR,
} from '#/components/quality';
 
import { PAPER_COLOR, resolveCanvasSize, SCHEMA_VERSION } from './constants';
 
import 'grapesjs/dist/css/grapes.min.css';
 
/** GrapesJS 内部页面设备的标识前缀,后缀为画布尺寸,尺寸随纸张设置动态变化 */
const PAGE_DEVICE_PREFIX = 'qc-report-page';
 
/** 画布专用引导样式的 style 元素 id,重建画布帧后靠它判断有没有重复注入 */
const CANVAS_BINDING_GUIDE_ID = 'qc-canvas-binding-guide';
 
/**
 * 只覆盖「值还停在占位符」的 hidden,不动真正的 hidden="hidden"。
 * 收窄到 td 是因为这个属性目前只用在单元格上;哪天真用到别的标签上,
 * 也要按那个标签的 display 各写一条,不能一条通用规则糊过去。
 */
const CANVAS_BINDING_GUIDE_CSS = 'td[hidden*="{{"] { display: table-cell; }';
 
export interface DesignerInitOptions {
  /** 组件面板容器(左侧) */
  blockContainer: HTMLElement;
  /** 画布容器(中间) */
  canvasContainer: HTMLElement;
  /** 样式面板容器(右侧下半) */
  styleContainer: HTMLElement;
  /** 属性面板容器(右侧上半) */
  traitContainer: HTMLElement;
}
 
/**
 * GrapesJS 设计器封装
 * <p>
 * 只负责画布与 Schema 的双向转换,不承担保存/发布等业务动作,便于后续替换渲染内核。
 */
export function useDesigner() {
  const editor = shallowRef<Editor>();
  const zoom = ref(100);
  const selectedLabel = ref('');
  const canUndo = ref(false);
  const canRedo = ref(false);
 
  let canvasSize = resolveCanvasSize();
 
  /**
   * 判定规则。随版本一起载入、随 collectSchema 一起产出。
   * <p>
   * 编辑动作在规则抽屉里,这里只负责持有这份数据。
   */
  const rules = ref<QcReportVersionApi.SchemaRule[]>([]);
 
  /**
   * 把画布调到指定纸张尺寸。
   * <p>
   * GrapesJS 只在 device 取值发生变化时才重排画布帧,所以设备 id 里带上尺寸,
   * 每次换纸张都是一次"真正的新设备",换完再把旧尺寸的设备清掉。
   */
  function setPageDevice(width: number, height: number) {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const id = `${PAGE_DEVICE_PREFIX}-${width}x${height}`;
    instance.Devices.add({
      id,
      name: '页面',
      width: `${width}px`,
      height: `${height}px`,
      // 纸张模板的样式不能挂在某个设备宽度的 @media 上,否则换纸张后样式会失效
      widthMedia: '',
    });
    instance.setDevice(id);
    instance.Devices.getDevices()
      .filter((device) => {
        const deviceId = device.get('id');
        return (
          typeof deviceId === 'string' &&
          deviceId !== id &&
          deviceId.startsWith(PAGE_DEVICE_PREFIX)
        );
      })
      .forEach((device) => instance.Devices.remove(device));
  }
 
  /**
   * 画布上的纸张外观:白底 + 页边距留白。
   * <p>
   * 仅作视觉参考,直接写在画布 body 上,不会进入 Schema;
   * frameStyle 只在首个画布帧生效,重建页面后要在这里补一次白底。
   */
  function applyFrameGuide() {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const doc = instance.Canvas.getDocument();
    if (!doc) {
      return;
    }
    const { marginPx } = canvasSize;
    doc.body.style.backgroundColor = PAPER_COLOR;
    doc.body.style.padding = `${marginPx.top}px ${marginPx.right}px ${marginPx.bottom}px ${marginPx.left}px`;
    doc.body.style.boxSizing = 'border-box';
    applyBindingGuide(doc);
  }
 
  /**
   * 画布把 {{...}} 原样留着不求解,于是组名格上的 hidden="{{item.group.hidden}}"
   * 在浏览器眼里就是「有 hidden 属性」——整格 display:none,这一行后面的格整体左移一格,
   * 末列只剩表头、被挤成竖排细条,设计器里看到的排版跟出件结果对不上。
   * 这里只在画布文档内把「值还停在占位符」的 hidden 还原成可见格;出件时该值已解成
   * 空串或 hidden,选择器不再命中,报告渲染一个字都不受影响。
   */
  function applyBindingGuide(doc: Document) {
    if (doc.getElementById(CANVAS_BINDING_GUIDE_ID)) {
      return;
    }
    const style = doc.createElement('style');
    style.id = CANVAS_BINDING_GUIDE_ID;
    style.textContent = CANVAS_BINDING_GUIDE_CSS;
    doc.head.append(style);
  }
 
  function syncHistoryState() {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    const undoManager = instance.UndoManager;
    canUndo.value = undoManager.hasUndo();
    canRedo.value = undoManager.hasRedo();
  }
 
  function init(options: DesignerInitOptions): Editor {
    // 组件面板的内容全部来自质量组件注册表,这里先保证内置组件已注册
    registerAllQualityComponents();
    const config: EditorConfig = {
      container: options.canvasContainer,
      height: '100%',
      width: 'auto',
      fromElement: false,
      // 由本平台自己的接口保存,不使用 GrapesJS 内置存储
      storageManager: false,
      // 顶栏由页面自绘,去掉 GrapesJS 默认面板
      panels: { defaults: [] },
      blockManager: {
        appendTo: options.blockContainer,
        blocks: buildQualityBlocks(),
      },
      traitManager: { appendTo: options.traitContainer },
      styleManager: {
        appendTo: options.styleContainer,
        sectors: [
          { name: '布局', open: false, properties: ['display', 'width', 'height', 'margin', 'padding'] },
          { name: '排版', open: false, properties: ['font-size', 'font-weight', 'color', 'text-align', 'line-height'] },
          { name: '背景与边框', open: false, properties: ['background-color', 'border', 'border-radius'] },
        ],
      },
      canvas: {
        // frameStyle 会被原样拼进画布 iframe 的 <style>,必须传 CSS 字符串;
        // 传对象只会得到字面量 [object Object]。页边距留白由 applyFrameGuide 按纸张设置写入。
        frameStyle: `body { background-color: ${PAPER_COLOR} }`,
      },
      // 画布尺寸由 setPageDevice 按纸张设置动态注册,这里不预置设备
    };
 
    const instance = grapesjs.init(config);
    editor.value = instance;
    registerBindingTraitType(instance);
 
    instance.on('canvas:frame:load', () => {
      applyFrameGuide();
    });
    instance.on('component:selected', (component) => {
      const attrs = component?.getAttributes() ?? {};
      selectedLabel.value = String(attrs[QUALITY_TYPE_ATTR] ?? component?.get('tagName') ?? '');
    });
    instance.on('component:deselected', () => {
      selectedLabel.value = '';
    });
    ['undo', 'redo', 'update'].forEach((event) => {
      instance.on(event, syncHistoryState);
    });
    bindQualityCanvasSync(instance);
 
    setPageDevice(canvasSize.width, canvasSize.height);
    applyFrameGuide();
    syncHistoryState();
    return instance;
  }
 
  /** 只按纸张设置重排画布尺寸与页边距参考线,不动画布里的已有内容 */
  function applyPageSize(page?: QcReportVersionApi.SchemaPage) {
    canvasSize = resolveCanvasSize(page);
    setPageDevice(canvasSize.width, canvasSize.height);
    applyFrameGuide();
  }
 
  /** 按 Schema 的纸张设置重排画布,并载入 GrapesJS 原始数据 */
  function loadSchema(schema?: QcReportVersionApi.ReportTemplateSchema) {
    const instance = editor.value;
    if (!instance) {
      return;
    }
    // 拷一层再放进去:抽屉里改的是这份副本,切版本时不该把改动带到另一个版本上。
    // 同时把引擎的缺省口径落成明确值(scope 识别不出按逐项、enabled 缺省即启用),
    // 否则界面上的开关会与实际判定对不上,看起来停用了其实还在判。
    rules.value = (schema?.rules ?? []).map((rule) => ({
      ...rule,
      scope: rule.scope === 'report' ? 'report' : 'item',
      enabled: rule.enabled !== false,
    }));
    applyPageSize(schema?.page);
 
    // grapes 必须带 pages 才是可用项目数据,否则画布会加载出一个没有页面的空编辑器
    const projectData = schema?.grapes;
    const pages = projectData?.pages;
    if (Array.isArray(pages) && pages.length > 0) {
      instance.loadProjectData(projectData as Parameters<Editor['loadProjectData']>[0]);
    } else {
      instance.loadProjectData({ pages: [{ component: { style: { padding: '0' } } }] });
    }
    applyFrameGuide();
    // 必须在清空历史之前补属性字段:trait 初始化会写一次组件属性,留下的历史没有意义
    restoreQualityTraits(instance);
    instance.UndoManager.clear();
    syncHistoryState();
  }
 
  /**
   * 从画布收集 Schema:grapes 保存原始数据,components 保存质量组件业务语义。
   * <p>
   * 产出的是<b>整份</b> Schema,后端保存时整体覆盖,所以设计器认识的字段必须在这里列全——
   * 漏掉哪个就等于把那个字段清空(rules 曾经就这样被清掉过,见 RuleEditor 的说明)。
   * 将来 Schema 新增字段时,要么在这里产出,要么想清楚由谁负责保留。
   */
  function collectSchema(
    page: QcReportVersionApi.SchemaPage,
  ): QcReportVersionApi.ReportTemplateSchema {
    const instance = editor.value;
    if (!instance) {
      return { schemaVersion: SCHEMA_VERSION, page, rules: rules.value };
    }
    const projectData = instance.getProjectData() as unknown as Record<string, unknown>;
    const components: QcReportVersionApi.QualityComponentNode[] = [];
    // getWrapper 在类型上是可空的,取不到就只收集不到业务语义,不能因此丢掉 grapes 画布数据
    instance
      .getWrapper()
      ?.find(`[${QUALITY_TYPE_ATTR}]`)
      .forEach((component, index) => {
        const attrs = component.getAttributes();
        components.push({
          id: component.getId(),
          // 组件在文档中的顺序,供后续数据绑定与渲染定位
          index,
          qualityType: attrs[QUALITY_TYPE_ATTR],
          attributes: attrs,
        });
      });
 
    return {
      schemaVersion: SCHEMA_VERSION,
      page,
      grapes: projectData,
      components,
      rules: rules.value,
    };
  }
 
  /**
   * 把 AI 草稿装到画布上。
   * <p>
   * components 传空数组:画布才是真相,保存时由 collectSchema 重新产出语义清单。
   * 塞草稿进去会制造第二个真相来源,而且形状也不对(草稿是 {type, props},
   * 语义层是 {id, index, qualityType, attributes})。
   * <p>
   * rules 传当前值:loadSchema 会用传入的 rules 覆盖,传空数组等于把用户已写好的判定规则静默清空。
   * <p>
   * page 必须由调用方给定:草稿没猜出纸张时不能传 undefined,那会让 applyPageSize 退回默认 A4,
   * 把用户已经调好的纸张悄悄改掉。
   */
  function loadCanvasFromDraft(
    components: QualityDraftComponent[] | undefined,
    page: QcReportVersionApi.SchemaPage,
  ): { nodeCount: number; problems: string[]; skipped: QualityAssemblySkip[] } {
    const assembled = assembleQualityCanvas(components);
    const nodeCount = countCanvasNodes(assembled.grapes);
    // 一个组件都装不进去时不要动画布:loadSchema 会清掉撤销栈与已有内容,
    // 白白搭进去一版画布却什么都换不来
    if (nodeCount === 0) {
      return { nodeCount, problems: assembled.problems, skipped: assembled.skipped };
    }
    loadSchema({
      schemaVersion: SCHEMA_VERSION,
      page,
      grapes: assembled.grapes,
      components: [],
      rules: rules.value,
    });
    return { nodeCount, problems: assembled.problems, skipped: assembled.skipped };
  }
 
  function undo() {
    editor.value?.UndoManager.undo();
  }
 
  function redo() {
    editor.value?.UndoManager.redo();
  }
 
  function setZoom(next: number) {
    zoom.value = next;
    editor.value?.Canvas.setZoom(next);
  }
 
  function destroy() {
    editor.value?.destroy();
    editor.value = undefined;
  }
 
  return {
    canRedo,
    canUndo,
    editor,
    rules,
    zoom,
    selectedLabel,
    applyPageSize,
    collectSchema,
    destroy,
    init,
    loadCanvasFromDraft,
    loadSchema,
    redo,
    setZoom,
    undo,
  };
}
 
/** 数一下装配产物里的顶层组件数;结构由 assembleQualityCanvas 固定,取不到按 0 算 */
function countCanvasNodes(grapes: Record<string, unknown>): number {
  const pages = grapes.pages as
    | { component?: { components?: unknown[] } }[]
    | undefined;
  return pages?.[0]?.component?.components?.length ?? 0;
}