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;
|
}
|