<script lang="ts" setup>
|
import type { QcReportAiApi } from '#/api/mes/qc/report/ai';
|
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
|
import type { QualityAssemblySkip } from '#/components/quality';
|
|
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { useAccess } from '@vben/access';
|
import { Page } from '@vben/common-ui';
|
import { useTabs } from '@vben/hooks';
|
import { IconifyIcon } from '@vben/icons';
|
|
import {
|
Button,
|
Empty,
|
InputNumber,
|
message,
|
Modal,
|
Select,
|
Spin,
|
Tag,
|
Tooltip,
|
} from 'ant-design-vue';
|
|
import {
|
createVersion,
|
getVersion,
|
getVersionListByTemplate,
|
publishVersion,
|
updateVersion,
|
} from '#/api/mes/qc/report/version';
|
import { getTemplate, updateTemplate } from '#/api/mes/qc/report/template';
|
import { validateRule } from '#/components/quality';
|
import { router } from '#/router';
|
|
import { DEFAULT_PAGE } from './constants';
|
import AiImportModal from './modules/ai-import-modal.vue';
|
import RuleEditor from './modules/rule-editor.vue';
|
import { useDesigner } from './use-designer';
|
|
defineOptions({ name: 'QcReportTemplateDesigner' });
|
|
const props = defineProps<{
|
templateId?: number | string;
|
}>();
|
|
const tabs = useTabs();
|
const { hasAccessByCodes } = useAccess();
|
|
/** AI 导入是独立权限码:能改模板不等于能用大模型识别文件(每次调用都要花钱) */
|
const canAiImport = computed(() =>
|
hasAccessByCodes(['qc-report:template:ai-import']),
|
);
|
|
const loading = ref(true);
|
const saving = ref(false);
|
const publishing = ref(false);
|
const ruleDrawerOpen = ref(false);
|
const aiImportOpen = ref(false);
|
|
const template = ref<Awaited<ReturnType<typeof getTemplate>>>();
|
const versions = ref<QcReportVersionApi.Version[]>([]);
|
const activeVersionId = ref<number>();
|
const pageSetting = ref<QcReportVersionApi.SchemaPage>({ ...DEFAULT_PAGE });
|
|
const blockPanelRef = ref<HTMLElement>();
|
const canvasRef = ref<HTMLElement>();
|
const traitPanelRef = ref<HTMLElement>();
|
const stylePanelRef = ref<HTMLElement>();
|
|
const {
|
canRedo,
|
canUndo,
|
editor,
|
rules,
|
selectedLabel,
|
zoom,
|
applyPageSize,
|
collectSchema,
|
destroy,
|
init,
|
loadCanvasFromDraft,
|
loadSchema,
|
redo,
|
setZoom,
|
undo,
|
} = useDesigner();
|
|
const activeVersion = computed(() =>
|
versions.value.find((item) => item.id === activeVersionId.value),
|
);
|
const isDraft = computed(() => activeVersion.value?.status === 0);
|
const templateName = computed(() => template.value?.templateName ?? '');
|
|
/** 写不通的规则条数。规则写坏了要到出件时才炸,顶栏得先给出提示 */
|
const ruleErrorCount = computed(
|
() => rules.value.filter((rule) => validateRule(rule).length > 0).length,
|
);
|
|
/**
|
* 保存前先卡一道。
|
* <p>
|
* 后端也会拦,但报错弹到界面上时用户已经离开抽屉了,还得自己翻是哪条规则写坏的;
|
* 这里直接把抽屉打开,人一进来就看到红字标在哪一条上。
|
*/
|
function blockOnInvalidRules(): boolean {
|
if (ruleErrorCount.value === 0) {
|
return false;
|
}
|
ruleDrawerOpen.value = true;
|
message.warning(`有 ${ruleErrorCount.value} 条判定规则还没写完整,请先修正再保存`);
|
return true;
|
}
|
|
const ruleButtonText = computed(() =>
|
rules.value.length > 0 ? `判定规则(${rules.value.length})` : '判定规则',
|
);
|
|
/** 抽屉里改完的规则写回设计器状态,保存时由 collectSchema 一并产出 */
|
function handleRulesChange(next: QcReportVersionApi.SchemaRule[]) {
|
rules.value = next;
|
}
|
|
/** 画布上已有的顶层组件数,用来判断 AI 导入会不会覆盖掉用户正在做的内容 */
|
const canvasNodeCount = computed(() => editor.value?.getComponents().length ?? 0);
|
|
/**
|
* AI 草稿落地。
|
* <p>
|
* 草稿猜出的纸张要并进 pageSetting:保存时写的是 pageSetting.value,
|
* 不同步的话 AI 认出来的纸张会在下一次保存时被旧值盖回去。
|
*/
|
function handleApplyDraft(payload: {
|
components?: QcReportAiApi.DraftComponent[];
|
page?: QcReportAiApi.DraftPage;
|
}) {
|
const components = payload.components ?? [];
|
if (components.length === 0) {
|
message.warning('草稿里没有可用的组件,请重新识别');
|
return;
|
}
|
const mergedPage: QcReportVersionApi.SchemaPage = {
|
size: payload.page?.size ?? pageSetting.value.size,
|
orientation: payload.page?.orientation ?? pageSetting.value.orientation,
|
margin: { ...pageSetting.value.margin, ...payload.page?.margin },
|
};
|
|
const applyDraft = () => {
|
pageSetting.value = mergedPage;
|
const { nodeCount, problems, skipped } = loadCanvasFromDraft(
|
components,
|
mergedPage,
|
);
|
reportDraftIssues(nodeCount, problems, skipped);
|
};
|
|
if (canvasNodeCount.value > 0) {
|
Modal.confirm({
|
title: '替换画布内容?',
|
content: `画布上已有 ${canvasNodeCount.value} 个组件,将被 AI 识别结果整体替换,且无法用撤销恢复。建议先把当前内容保存为新版本。`,
|
onOk: applyDraft,
|
});
|
return;
|
}
|
applyDraft();
|
}
|
|
/** 装配结果里的跳过项与问题项统一汇报;逐条弹 message 会刷满屏幕,攒成一个弹窗更好读 */
|
function reportDraftIssues(
|
nodeCount: number,
|
problems: string[],
|
skipped: QualityAssemblySkip[],
|
) {
|
if (nodeCount === 0) {
|
message.error('AI 识别出的组件设计器都不认识,画布未改动');
|
return;
|
}
|
message.success(`已生成 ${nodeCount} 个组件到画布,确认无误后点「保存」`);
|
if (problems.length === 0 && skipped.length === 0) {
|
return;
|
}
|
Modal.warning({
|
title: `有 ${skipped.length + problems.length} 处需要留意`,
|
width: 560,
|
content: h(
|
'div',
|
{ style: 'max-height: 40vh; overflow: auto; font-size: 12px;' },
|
[
|
...skipped.map((item) => h('div', `· ${item.reason}`)),
|
...problems.map((text) => h('div', `· ${text}`)),
|
],
|
),
|
});
|
}
|
|
const VERSION_STATUS_LABEL: Record<number, string> = {
|
0: '草稿',
|
1: '已发布',
|
2: '已停用',
|
};
|
|
const ZOOM_OPTIONS = [50, 75, 100, 125, 150].map((value) => ({
|
label: `${value}%`,
|
value,
|
}));
|
|
/** 应用纸张设置:只重排画布尺寸与页边距参考线,不覆盖画布上未保存的内容 */
|
function applyPageSetting() {
|
applyPageSize(pageSetting.value);
|
}
|
|
/** 加载模板与其版本列表 */
|
async function loadTemplate() {
|
const id = Number(props.templateId);
|
if (!Number.isFinite(id) || id <= 0) {
|
message.error('缺少模板编号,无法进入设计器');
|
loading.value = false;
|
return;
|
}
|
loading.value = true;
|
try {
|
const detail = await getTemplate(id);
|
// 模板可能已被他人删除,或链接是过期/伪造的,这里必须兜住空响应
|
if (!detail) {
|
message.error('该报告模板不存在或已被删除,无法进入设计器');
|
handleBack();
|
return;
|
}
|
template.value = detail;
|
pageSetting.value = {
|
size: detail.pageSize ?? DEFAULT_PAGE.size,
|
orientation: detail.orientation ?? DEFAULT_PAGE.orientation,
|
margin: { ...DEFAULT_PAGE.margin },
|
};
|
versions.value = await getVersionListByTemplate(id);
|
// 默认停在最新版本;没有版本时留空,由用户点击“保存”生成 v1.0
|
const latest = versions.value[0];
|
activeVersionId.value = latest?.id;
|
const schema = latest ? await getVersion(latest.id!) : undefined;
|
if (schema?.schema?.page) {
|
pageSetting.value = schema.schema.page;
|
}
|
await nextTick();
|
loadSchema(schema?.schema);
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
/** 切换版本(AntDV 的 Select 回调值类型比 number 宽,这里统一收敛) */
|
async function handleVersionChange(value: unknown) {
|
const id = Number(value);
|
if (!Number.isFinite(id)) {
|
return;
|
}
|
const detail = await getVersion(id);
|
if (!detail) {
|
message.error('该版本不存在或已被删除,请重新选择版本');
|
await refreshVersions();
|
return;
|
}
|
activeVersionId.value = id;
|
if (detail.schema?.page) {
|
pageSetting.value = detail.schema.page;
|
}
|
// 草稿可能还没写入过内容(schema 为空),按空白画布载入即可,不算错误
|
loadSchema(detail.schema ?? undefined);
|
}
|
|
/** 保存:草稿直接覆盖,已发布版本另存为新版本 */
|
async function handleSave() {
|
const id = Number(props.templateId);
|
if (!Number.isFinite(id) || id <= 0) {
|
return;
|
}
|
if (blockOnInvalidRules()) {
|
return;
|
}
|
saving.value = true;
|
try {
|
const baseSchema = collectSchema(pageSetting.value);
|
const current = activeVersion.value;
|
if (current?.id && current.status === 0) {
|
await updateVersion({ id: current.id, schema: baseSchema });
|
message.success(`已保存到 ${current.version}`);
|
} else {
|
const newId = await createVersion({
|
templateId: id,
|
schema: baseSchema,
|
description: current ? `基于 ${current.version} 创建` : undefined,
|
});
|
message.success('已保存为新版本草稿');
|
await refreshVersions();
|
activeVersionId.value = newId;
|
}
|
/*
|
* 纸张设置属于模板级属性,同步回模板,列表页据此展示。
|
* 这个接口是整体更新,模板编码/名称等必填字段不带上会被校验打回来
|
* (「模板编码不能为空」),所以要把模板原有字段一并带过去,只覆盖纸张两项。
|
*/
|
await updateTemplate({
|
...template.value,
|
id,
|
pageSize: pageSetting.value.size,
|
orientation: pageSetting.value.orientation,
|
});
|
template.value = await getTemplate(id);
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
async function refreshVersions() {
|
const id = Number(props.templateId);
|
versions.value = await getVersionListByTemplate(id);
|
}
|
|
/** 发布当前版本 */
|
function handlePublish() {
|
const current = activeVersion.value;
|
if (!current?.id) {
|
message.warning('请先保存草稿,再发布版本');
|
return;
|
}
|
if (current.status !== 0) {
|
message.warning(`当前版本已是「${VERSION_STATUS_LABEL[current.status ?? 0]}」,无需重复发布`);
|
return;
|
}
|
// 发布等于把当前内容固化成不可改的版本,规则写坏了这会儿拦下来最省事
|
if (blockOnInvalidRules()) {
|
return;
|
}
|
Modal.confirm({
|
title: '确认发布该版本?',
|
content: `发布后 ${current.version} 的内容将不可再修改,如需调整请另存新版本。`,
|
async onOk() {
|
publishing.value = true;
|
try {
|
// 发布前先把画布最新内容落库,避免发布的是旧内容
|
await updateVersion({ id: current.id!, schema: collectSchema(pageSetting.value) });
|
await publishVersion(current.id!);
|
await refreshVersions();
|
message.success(`版本 ${current.version} 已发布`);
|
} finally {
|
publishing.value = false;
|
}
|
},
|
});
|
}
|
|
/** 画布缩放(Select 回调值类型比 number 宽,这里统一收敛) */
|
function handleZoomChange(value: unknown) {
|
const next = Number(value);
|
if (Number.isFinite(next)) {
|
setZoom(next);
|
}
|
}
|
|
/** 返回列表页 */
|
function handleBack() {
|
tabs.closeCurrentTab();
|
router.push('/qc/report-template');
|
}
|
|
onMounted(async () => {
|
await nextTick();
|
if (!canvasRef.value || !blockPanelRef.value || !traitPanelRef.value || !stylePanelRef.value) {
|
return;
|
}
|
init({
|
canvasContainer: canvasRef.value,
|
blockContainer: blockPanelRef.value,
|
traitContainer: traitPanelRef.value,
|
styleContainer: stylePanelRef.value,
|
});
|
await loadTemplate();
|
});
|
|
onBeforeUnmount(() => {
|
destroy();
|
});
|
</script>
|
|
<template>
|
<Page auto-content-height>
|
<div class="qc-report-designer flex h-full flex-col">
|
<!-- 顶栏 -->
|
<div class="flex items-center justify-between border-b border-border bg-card px-3 py-2">
|
<div class="flex items-center gap-2">
|
<Button size="small" @click="handleBack">
|
<IconifyIcon icon="lucide:arrow-left" class="mr-1" />
|
返回
|
</Button>
|
<span class="font-medium">{{ templateName || '报告模板设计器' }}</span>
|
<Select
|
v-if="versions.length > 0"
|
:value="activeVersionId"
|
:options="
|
versions.map((item) => ({
|
label: `${item.version}(${VERSION_STATUS_LABEL[item.status ?? 0]})`,
|
value: item.id,
|
}))
|
"
|
class="w-44"
|
size="small"
|
@change="handleVersionChange"
|
/>
|
<Tag v-else color="default">尚无版本</Tag>
|
<Tag v-if="activeVersion" :color="isDraft ? 'blue' : 'green'">
|
{{ isDraft ? '可编辑' : '只读' }}
|
</Tag>
|
<Button v-if="canAiImport" size="small" @click="aiImportOpen = true">
|
<IconifyIcon icon="lucide:sparkles" class="mr-1" />
|
AI 导入
|
</Button>
|
<Button size="small" @click="ruleDrawerOpen = true">
|
<IconifyIcon icon="lucide:list-checks" class="mr-1" />
|
{{ ruleButtonText }}
|
</Button>
|
<Tag v-if="ruleErrorCount > 0" color="red">
|
{{ ruleErrorCount }} 条规则待修正
|
</Tag>
|
</div>
|
|
<div class="flex items-center gap-2">
|
<Tooltip title="撤销">
|
<Button size="small" :disabled="!canUndo" @click="undo">
|
<IconifyIcon icon="lucide:undo-2" />
|
</Button>
|
</Tooltip>
|
<Tooltip title="重做">
|
<Button size="small" :disabled="!canRedo" @click="redo">
|
<IconifyIcon icon="lucide:redo-2" />
|
</Button>
|
</Tooltip>
|
<Select
|
:value="zoom"
|
:options="ZOOM_OPTIONS"
|
size="small"
|
class="w-24"
|
@change="handleZoomChange"
|
/>
|
<span v-if="selectedLabel" class="text-xs text-gray-500">
|
已选中:{{ selectedLabel }}
|
</span>
|
<Button size="small" type="primary" :loading="saving" @click="handleSave">
|
<IconifyIcon icon="lucide:save" class="mr-1" />
|
保存
|
</Button>
|
<Button
|
size="small"
|
:loading="publishing"
|
:disabled="!isDraft"
|
@click="handlePublish"
|
>
|
<IconifyIcon icon="lucide:send" class="mr-1" />
|
发布
|
</Button>
|
</div>
|
</div>
|
|
<!-- 纸张设置 -->
|
<div class="flex items-center gap-3 border-b border-border bg-card px-3 py-1.5 text-xs">
|
<span class="text-gray-500">纸张</span>
|
<Select
|
v-model:value="pageSetting.size"
|
:options="[
|
{ label: 'A3', value: 'A3' },
|
{ label: 'A4', value: 'A4' },
|
{ label: 'A5', value: 'A5' },
|
{ label: 'Letter', value: 'Letter' },
|
]"
|
size="small"
|
class="w-24"
|
@change="applyPageSetting"
|
/>
|
<Select
|
v-model:value="pageSetting.orientation"
|
:options="[
|
{ label: '纵向', value: 'portrait' },
|
{ label: '横向', value: 'landscape' },
|
]"
|
size="small"
|
class="w-24"
|
@change="applyPageSetting"
|
/>
|
<span class="text-gray-500">页边距(mm)</span>
|
<InputNumber
|
v-model:value="pageSetting.margin!.top"
|
size="small"
|
:min="0"
|
:max="50"
|
class="w-20"
|
addon-before="上"
|
/>
|
<InputNumber
|
v-model:value="pageSetting.margin!.right"
|
size="small"
|
:min="0"
|
:max="50"
|
class="w-20"
|
addon-before="右"
|
/>
|
<InputNumber
|
v-model:value="pageSetting.margin!.bottom"
|
size="small"
|
:min="0"
|
:max="50"
|
class="w-20"
|
addon-before="下"
|
/>
|
<InputNumber
|
v-model:value="pageSetting.margin!.left"
|
size="small"
|
:min="0"
|
:max="50"
|
class="w-20"
|
addon-before="左"
|
/>
|
<Button size="small" type="link" @click="applyPageSetting">应用到画布</Button>
|
</div>
|
|
<!-- 设计区 -->
|
<Spin :spinning="loading" wrapper-class-name="flex-1 overflow-hidden">
|
<div class="flex h-full">
|
<div class="w-56 shrink-0 overflow-auto border-r border-border bg-card p-2">
|
<div class="mb-2 text-xs font-medium text-gray-500">组件</div>
|
<div ref="blockPanelRef" class="qc-report-blocks"></div>
|
</div>
|
<div class="flex-1 overflow-hidden bg-gray-100">
|
<div ref="canvasRef" class="h-full"></div>
|
</div>
|
<div class="w-72 shrink-0 overflow-auto border-l border-border bg-card p-2">
|
<div class="mb-2 text-xs font-medium text-gray-500">属性</div>
|
<div ref="traitPanelRef"></div>
|
<div class="mt-3 mb-2 text-xs font-medium text-gray-500">样式</div>
|
<div ref="stylePanelRef"></div>
|
<Empty
|
v-if="!selectedLabel"
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
description="选中画布中的组件以编辑属性"
|
class="mt-2"
|
/>
|
</div>
|
</div>
|
</Spin>
|
|
<RuleEditor
|
v-model:open="ruleDrawerOpen"
|
:rules="rules"
|
@update:rules="handleRulesChange"
|
/>
|
|
<AiImportModal
|
v-model:open="aiImportOpen"
|
:template-id="Number(props.templateId)"
|
@apply="handleApplyDraft"
|
/>
|
</div>
|
</Page>
|
</template>
|
|
<style scoped>
|
/*
|
* GrapesJS 自带的是深色主题,这里把它对齐 Ant Design 的浅色主题。
|
* 变量挂在页面根节点上:组件面板 / 属性面板挂在编辑器容器之外,只有从共同祖先继承才生效。
|
*/
|
.qc-report-designer {
|
--gjs-main-color: #f5f5f5;
|
--gjs-primary-color: #ffffff;
|
--gjs-secondary-color: #333333;
|
--gjs-tertiary-color: #1677ff;
|
--gjs-quaternary-color: #1677ff;
|
--gjs-font-color: #333333;
|
--gjs-font-color-active: #1677ff;
|
--gjs-main-dark-color: rgba(0, 0, 0, 6%);
|
--gjs-secondary-dark-color: rgba(0, 0, 0, 4%);
|
--gjs-main-light-color: rgba(0, 0, 0, 6%);
|
--gjs-secondary-light-color: rgba(0, 0, 0, 55%);
|
--gjs-soft-light-color: rgba(0, 0, 0, 2%);
|
--gjs-light-border: rgba(0, 0, 0, 8%);
|
--gjs-arrow-color: rgba(0, 0, 0, 55%);
|
--gjs-dark-text-shadow: rgba(0, 0, 0, 10%);
|
/* 顶栏与左侧组件面板都由本页面自绘,画布占满编辑器容器 */
|
--gjs-canvas-top: 0px;
|
--gjs-left-width: 0%;
|
}
|
|
/*
|
* Ant 的 Spin 在中间套了一层 .ant-spin-container,它默认是内容高度而非撑满父级,
|
* 会把左右两栏撑得比可视区高,导致两栏的 overflow:auto 永远不触发、底部被裁掉。
|
* 这里把它接回父级的 flex 高度,左右栏才能正常滚动。
|
*/
|
:deep(.ant-spin-container) {
|
height: 100%;
|
}
|
|
/* 让 GrapesJS 默认面板贴合右侧栏宽度 */
|
:deep(.gjs-trt-traits),
|
:deep(.gjs-sm-sectors) {
|
font-size: 12px;
|
}
|
|
:deep(.gjs-cv-canvas) {
|
overflow: auto;
|
background-color: #f0f0f0;
|
}
|
|
/*
|
* GrapesJS 的画布默认是一块 overflow:hidden 的绝对定位舞台,纸张比可视区大时会被裁掉。
|
* 这里把画布改成普通滚动容器:帧容器回归文档流,纸张用 margin 居中,超出即可滚动。
|
*/
|
:deep(.gjs-cv-canvas__frames),
|
:deep(.gjs-frames) {
|
position: static;
|
width: auto;
|
height: auto;
|
}
|
|
:deep(.gjs-frame-wrapper) {
|
position: relative;
|
top: auto !important;
|
left: auto !important;
|
margin: 24px auto;
|
}
|
|
:deep(.gjs-block) {
|
width: calc(50% - 6px);
|
min-height: auto;
|
padding: 6px 4px;
|
font-size: 12px;
|
}
|
|
:deep(.gjs-block-label) {
|
font-size: 12px;
|
}
|
</style>
|