云
4 天以前 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
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
<script lang="ts" setup>
import type { QcReportAiApi } from '#/api/mes/qc/report/ai';
import type { QcReportVersionApi } from '#/api/mes/qc/report/version';
import type { QualityAssemblySkip } from '#/components/quality';
 
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
 
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { useTabs } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
 
import {
  Button,
  Empty,
  InputNumber,
  message,
  Modal,
  Select,
  Spin,
  Tag,
  Tooltip,
} from 'ant-design-vue';
 
import {
  createVersion,
  getVersion,
  getVersionListByTemplate,
  publishVersion,
  updateVersion,
} from '#/api/mes/qc/report/version';
import { getTemplate, updateTemplate } from '#/api/mes/qc/report/template';
import { validateRule } from '#/components/quality';
import { router } from '#/router';
 
import { DEFAULT_PAGE } from './constants';
import AiImportModal from './modules/ai-import-modal.vue';
import RuleEditor from './modules/rule-editor.vue';
import { useDesigner } from './use-designer';
 
defineOptions({ name: 'QcReportTemplateDesigner' });
 
const props = defineProps<{
  templateId?: number | string;
}>();
 
const tabs = useTabs();
const { hasAccessByCodes } = useAccess();
 
/** AI 导入是独立权限码:能改模板不等于能用大模型识别文件(每次调用都要花钱) */
const canAiImport = computed(() =>
  hasAccessByCodes(['qc-report:template:ai-import']),
);
 
const loading = ref(true);
const saving = ref(false);
const publishing = ref(false);
const ruleDrawerOpen = ref(false);
const aiImportOpen = ref(false);
 
const template = ref<Awaited<ReturnType<typeof getTemplate>>>();
const versions = ref<QcReportVersionApi.Version[]>([]);
const activeVersionId = ref<number>();
const pageSetting = ref<QcReportVersionApi.SchemaPage>({ ...DEFAULT_PAGE });
 
const blockPanelRef = ref<HTMLElement>();
const canvasRef = ref<HTMLElement>();
const traitPanelRef = ref<HTMLElement>();
const stylePanelRef = ref<HTMLElement>();
 
const {
  canRedo,
  canUndo,
  editor,
  rules,
  selectedLabel,
  zoom,
  applyPageSize,
  collectSchema,
  destroy,
  init,
  loadCanvasFromDraft,
  loadSchema,
  redo,
  setZoom,
  undo,
} = useDesigner();
 
const activeVersion = computed(() =>
  versions.value.find((item) => item.id === activeVersionId.value),
);
const isDraft = computed(() => activeVersion.value?.status === 0);
const templateName = computed(() => template.value?.templateName ?? '');
 
/** 写不通的规则条数。规则写坏了要到出件时才炸,顶栏得先给出提示 */
const ruleErrorCount = computed(
  () => rules.value.filter((rule) => validateRule(rule).length > 0).length,
);
 
/**
 * 保存前先卡一道。
 * <p>
 * 后端也会拦,但报错弹到界面上时用户已经离开抽屉了,还得自己翻是哪条规则写坏的;
 * 这里直接把抽屉打开,人一进来就看到红字标在哪一条上。
 */
function blockOnInvalidRules(): boolean {
  if (ruleErrorCount.value === 0) {
    return false;
  }
  ruleDrawerOpen.value = true;
  message.warning(`有 ${ruleErrorCount.value} 条判定规则还没写完整,请先修正再保存`);
  return true;
}
 
const ruleButtonText = computed(() =>
  rules.value.length > 0 ? `判定规则(${rules.value.length})` : '判定规则',
);
 
/** 抽屉里改完的规则写回设计器状态,保存时由 collectSchema 一并产出 */
function handleRulesChange(next: QcReportVersionApi.SchemaRule[]) {
  rules.value = next;
}
 
/** 画布上已有的顶层组件数,用来判断 AI 导入会不会覆盖掉用户正在做的内容 */
const canvasNodeCount = computed(() => editor.value?.getComponents().length ?? 0);
 
/**
 * AI 草稿落地。
 * <p>
 * 草稿猜出的纸张要并进 pageSetting:保存时写的是 pageSetting.value,
 * 不同步的话 AI 认出来的纸张会在下一次保存时被旧值盖回去。
 */
function handleApplyDraft(payload: {
  components?: QcReportAiApi.DraftComponent[];
  page?: QcReportAiApi.DraftPage;
}) {
  const components = payload.components ?? [];
  if (components.length === 0) {
    message.warning('草稿里没有可用的组件,请重新识别');
    return;
  }
  const mergedPage: QcReportVersionApi.SchemaPage = {
    size: payload.page?.size ?? pageSetting.value.size,
    orientation: payload.page?.orientation ?? pageSetting.value.orientation,
    margin: { ...pageSetting.value.margin, ...payload.page?.margin },
  };
 
  const applyDraft = () => {
    pageSetting.value = mergedPage;
    const { nodeCount, problems, skipped } = loadCanvasFromDraft(
      components,
      mergedPage,
    );
    reportDraftIssues(nodeCount, problems, skipped);
  };
 
  if (canvasNodeCount.value > 0) {
    Modal.confirm({
      title: '替换画布内容?',
      content: `画布上已有 ${canvasNodeCount.value} 个组件,将被 AI 识别结果整体替换,且无法用撤销恢复。建议先把当前内容保存为新版本。`,
      onOk: applyDraft,
    });
    return;
  }
  applyDraft();
}
 
/** 装配结果里的跳过项与问题项统一汇报;逐条弹 message 会刷满屏幕,攒成一个弹窗更好读 */
function reportDraftIssues(
  nodeCount: number,
  problems: string[],
  skipped: QualityAssemblySkip[],
) {
  if (nodeCount === 0) {
    message.error('AI 识别出的组件设计器都不认识,画布未改动');
    return;
  }
  message.success(`已生成 ${nodeCount} 个组件到画布,确认无误后点「保存」`);
  if (problems.length === 0 && skipped.length === 0) {
    return;
  }
  Modal.warning({
    title: `有 ${skipped.length + problems.length} 处需要留意`,
    width: 560,
    content: h(
      'div',
      { style: 'max-height: 40vh; overflow: auto; font-size: 12px;' },
      [
        ...skipped.map((item) => h('div', `· ${item.reason}`)),
        ...problems.map((text) => h('div', `· ${text}`)),
      ],
    ),
  });
}
 
const VERSION_STATUS_LABEL: Record<number, string> = {
  0: '草稿',
  1: '已发布',
  2: '已停用',
};
 
const ZOOM_OPTIONS = [50, 75, 100, 125, 150].map((value) => ({
  label: `${value}%`,
  value,
}));
 
/** 应用纸张设置:只重排画布尺寸与页边距参考线,不覆盖画布上未保存的内容 */
function applyPageSetting() {
  applyPageSize(pageSetting.value);
}
 
/** 加载模板与其版本列表 */
async function loadTemplate() {
  const id = Number(props.templateId);
  if (!Number.isFinite(id) || id <= 0) {
    message.error('缺少模板编号,无法进入设计器');
    loading.value = false;
    return;
  }
  loading.value = true;
  try {
    const detail = await getTemplate(id);
    // 模板可能已被他人删除,或链接是过期/伪造的,这里必须兜住空响应
    if (!detail) {
      message.error('该报告模板不存在或已被删除,无法进入设计器');
      handleBack();
      return;
    }
    template.value = detail;
    pageSetting.value = {
      size: detail.pageSize ?? DEFAULT_PAGE.size,
      orientation: detail.orientation ?? DEFAULT_PAGE.orientation,
      margin: { ...DEFAULT_PAGE.margin },
    };
    versions.value = await getVersionListByTemplate(id);
    // 默认停在最新版本;没有版本时留空,由用户点击“保存”生成 v1.0
    const latest = versions.value[0];
    activeVersionId.value = latest?.id;
    const schema = latest ? await getVersion(latest.id!) : undefined;
    if (schema?.schema?.page) {
      pageSetting.value = schema.schema.page;
    }
    await nextTick();
    loadSchema(schema?.schema);
  } finally {
    loading.value = false;
  }
}
 
/** 切换版本(AntDV 的 Select 回调值类型比 number 宽,这里统一收敛) */
async function handleVersionChange(value: unknown) {
  const id = Number(value);
  if (!Number.isFinite(id)) {
    return;
  }
  const detail = await getVersion(id);
  if (!detail) {
    message.error('该版本不存在或已被删除,请重新选择版本');
    await refreshVersions();
    return;
  }
  activeVersionId.value = id;
  if (detail.schema?.page) {
    pageSetting.value = detail.schema.page;
  }
  // 草稿可能还没写入过内容(schema 为空),按空白画布载入即可,不算错误
  loadSchema(detail.schema ?? undefined);
}
 
/** 保存:草稿直接覆盖,已发布版本另存为新版本 */
async function handleSave() {
  const id = Number(props.templateId);
  if (!Number.isFinite(id) || id <= 0) {
    return;
  }
  if (blockOnInvalidRules()) {
    return;
  }
  saving.value = true;
  try {
    const baseSchema = collectSchema(pageSetting.value);
    const current = activeVersion.value;
    if (current?.id && current.status === 0) {
      await updateVersion({ id: current.id, schema: baseSchema });
      message.success(`已保存到 ${current.version}`);
    } else {
      const newId = await createVersion({
        templateId: id,
        schema: baseSchema,
        description: current ? `基于 ${current.version} 创建` : undefined,
      });
      message.success('已保存为新版本草稿');
      await refreshVersions();
      activeVersionId.value = newId;
    }
    /*
     * 纸张设置属于模板级属性,同步回模板,列表页据此展示。
     * 这个接口是整体更新,模板编码/名称等必填字段不带上会被校验打回来
     * (「模板编码不能为空」),所以要把模板原有字段一并带过去,只覆盖纸张两项。
     */
    await updateTemplate({
      ...template.value,
      id,
      pageSize: pageSetting.value.size,
      orientation: pageSetting.value.orientation,
    });
    template.value = await getTemplate(id);
  } finally {
    saving.value = false;
  }
}
 
async function refreshVersions() {
  const id = Number(props.templateId);
  versions.value = await getVersionListByTemplate(id);
}
 
/** 发布当前版本 */
function handlePublish() {
  const current = activeVersion.value;
  if (!current?.id) {
    message.warning('请先保存草稿,再发布版本');
    return;
  }
  if (current.status !== 0) {
    message.warning(`当前版本已是「${VERSION_STATUS_LABEL[current.status ?? 0]}」,无需重复发布`);
    return;
  }
  // 发布等于把当前内容固化成不可改的版本,规则写坏了这会儿拦下来最省事
  if (blockOnInvalidRules()) {
    return;
  }
  Modal.confirm({
    title: '确认发布该版本?',
    content: `发布后 ${current.version} 的内容将不可再修改,如需调整请另存新版本。`,
    async onOk() {
      publishing.value = true;
      try {
        // 发布前先把画布最新内容落库,避免发布的是旧内容
        await updateVersion({ id: current.id!, schema: collectSchema(pageSetting.value) });
        await publishVersion(current.id!);
        await refreshVersions();
        message.success(`版本 ${current.version} 已发布`);
      } finally {
        publishing.value = false;
      }
    },
  });
}
 
/** 画布缩放(Select 回调值类型比 number 宽,这里统一收敛) */
function handleZoomChange(value: unknown) {
  const next = Number(value);
  if (Number.isFinite(next)) {
    setZoom(next);
  }
}
 
/** 返回列表页 */
function handleBack() {
  tabs.closeCurrentTab();
  router.push('/qc/report-template');
}
 
onMounted(async () => {
  await nextTick();
  if (!canvasRef.value || !blockPanelRef.value || !traitPanelRef.value || !stylePanelRef.value) {
    return;
  }
  init({
    canvasContainer: canvasRef.value,
    blockContainer: blockPanelRef.value,
    traitContainer: traitPanelRef.value,
    styleContainer: stylePanelRef.value,
  });
  await loadTemplate();
});
 
onBeforeUnmount(() => {
  destroy();
});
</script>
 
<template>
  <Page auto-content-height>
    <div class="qc-report-designer flex h-full flex-col">
      <!-- 顶栏 -->
      <div class="flex items-center justify-between border-b border-border bg-card px-3 py-2">
        <div class="flex items-center gap-2">
          <Button size="small" @click="handleBack">
            <IconifyIcon icon="lucide:arrow-left" class="mr-1" />
            返回
          </Button>
          <span class="font-medium">{{ templateName || '报告模板设计器' }}</span>
          <Select
            v-if="versions.length > 0"
            :value="activeVersionId"
            :options="
              versions.map((item) => ({
                label: `${item.version}(${VERSION_STATUS_LABEL[item.status ?? 0]})`,
                value: item.id,
              }))
            "
            class="w-44"
            size="small"
            @change="handleVersionChange"
          />
          <Tag v-else color="default">尚无版本</Tag>
          <Tag v-if="activeVersion" :color="isDraft ? 'blue' : 'green'">
            {{ isDraft ? '可编辑' : '只读' }}
          </Tag>
          <Button v-if="canAiImport" size="small" @click="aiImportOpen = true">
            <IconifyIcon icon="lucide:sparkles" class="mr-1" />
            AI 导入
          </Button>
          <Button size="small" @click="ruleDrawerOpen = true">
            <IconifyIcon icon="lucide:list-checks" class="mr-1" />
            {{ ruleButtonText }}
          </Button>
          <Tag v-if="ruleErrorCount > 0" color="red">
            {{ ruleErrorCount }} 条规则待修正
          </Tag>
        </div>
 
        <div class="flex items-center gap-2">
          <Tooltip title="撤销">
            <Button size="small" :disabled="!canUndo" @click="undo">
              <IconifyIcon icon="lucide:undo-2" />
            </Button>
          </Tooltip>
          <Tooltip title="重做">
            <Button size="small" :disabled="!canRedo" @click="redo">
              <IconifyIcon icon="lucide:redo-2" />
            </Button>
          </Tooltip>
          <Select
            :value="zoom"
            :options="ZOOM_OPTIONS"
            size="small"
            class="w-24"
            @change="handleZoomChange"
          />
          <span v-if="selectedLabel" class="text-xs text-gray-500">
            已选中:{{ selectedLabel }}
          </span>
          <Button size="small" type="primary" :loading="saving" @click="handleSave">
            <IconifyIcon icon="lucide:save" class="mr-1" />
            保存
          </Button>
          <Button
            size="small"
            :loading="publishing"
            :disabled="!isDraft"
            @click="handlePublish"
          >
            <IconifyIcon icon="lucide:send" class="mr-1" />
            发布
          </Button>
        </div>
      </div>
 
      <!-- 纸张设置 -->
      <div class="flex items-center gap-3 border-b border-border bg-card px-3 py-1.5 text-xs">
        <span class="text-gray-500">纸张</span>
        <Select
          v-model:value="pageSetting.size"
          :options="[
            { label: 'A3', value: 'A3' },
            { label: 'A4', value: 'A4' },
            { label: 'A5', value: 'A5' },
            { label: 'Letter', value: 'Letter' },
          ]"
          size="small"
          class="w-24"
          @change="applyPageSetting"
        />
        <Select
          v-model:value="pageSetting.orientation"
          :options="[
            { label: '纵向', value: 'portrait' },
            { label: '横向', value: 'landscape' },
          ]"
          size="small"
          class="w-24"
          @change="applyPageSetting"
        />
        <span class="text-gray-500">页边距(mm)</span>
        <InputNumber
          v-model:value="pageSetting.margin!.top"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="上"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.right"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="右"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.bottom"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="下"
        />
        <InputNumber
          v-model:value="pageSetting.margin!.left"
          size="small"
          :min="0"
          :max="50"
          class="w-20"
          addon-before="左"
        />
        <Button size="small" type="link" @click="applyPageSetting">应用到画布</Button>
      </div>
 
      <!-- 设计区 -->
      <Spin :spinning="loading" wrapper-class-name="flex-1 overflow-hidden">
        <div class="flex h-full">
          <div class="w-56 shrink-0 overflow-auto border-r border-border bg-card p-2">
            <div class="mb-2 text-xs font-medium text-gray-500">组件</div>
            <div ref="blockPanelRef" class="qc-report-blocks"></div>
          </div>
          <div class="flex-1 overflow-hidden bg-gray-100">
            <div ref="canvasRef" class="h-full"></div>
          </div>
          <div class="w-72 shrink-0 overflow-auto border-l border-border bg-card p-2">
            <div class="mb-2 text-xs font-medium text-gray-500">属性</div>
            <div ref="traitPanelRef"></div>
            <div class="mt-3 mb-2 text-xs font-medium text-gray-500">样式</div>
            <div ref="stylePanelRef"></div>
            <Empty
              v-if="!selectedLabel"
              :image="Empty.PRESENTED_IMAGE_SIMPLE"
              description="选中画布中的组件以编辑属性"
              class="mt-2"
            />
          </div>
        </div>
      </Spin>
 
      <RuleEditor
        v-model:open="ruleDrawerOpen"
        :rules="rules"
        @update:rules="handleRulesChange"
      />
 
      <AiImportModal
        v-model:open="aiImportOpen"
        :template-id="Number(props.templateId)"
        @apply="handleApplyDraft"
      />
    </div>
  </Page>
</template>
 
<style scoped>
/*
 * GrapesJS 自带的是深色主题,这里把它对齐 Ant Design 的浅色主题。
 * 变量挂在页面根节点上:组件面板 / 属性面板挂在编辑器容器之外,只有从共同祖先继承才生效。
 */
.qc-report-designer {
  --gjs-main-color: #f5f5f5;
  --gjs-primary-color: #ffffff;
  --gjs-secondary-color: #333333;
  --gjs-tertiary-color: #1677ff;
  --gjs-quaternary-color: #1677ff;
  --gjs-font-color: #333333;
  --gjs-font-color-active: #1677ff;
  --gjs-main-dark-color: rgba(0, 0, 0, 6%);
  --gjs-secondary-dark-color: rgba(0, 0, 0, 4%);
  --gjs-main-light-color: rgba(0, 0, 0, 6%);
  --gjs-secondary-light-color: rgba(0, 0, 0, 55%);
  --gjs-soft-light-color: rgba(0, 0, 0, 2%);
  --gjs-light-border: rgba(0, 0, 0, 8%);
  --gjs-arrow-color: rgba(0, 0, 0, 55%);
  --gjs-dark-text-shadow: rgba(0, 0, 0, 10%);
  /* 顶栏与左侧组件面板都由本页面自绘,画布占满编辑器容器 */
  --gjs-canvas-top: 0px;
  --gjs-left-width: 0%;
}
 
/*
 * Ant 的 Spin 在中间套了一层 .ant-spin-container,它默认是内容高度而非撑满父级,
 * 会把左右两栏撑得比可视区高,导致两栏的 overflow:auto 永远不触发、底部被裁掉。
 * 这里把它接回父级的 flex 高度,左右栏才能正常滚动。
 */
:deep(.ant-spin-container) {
  height: 100%;
}
 
/* 让 GrapesJS 默认面板贴合右侧栏宽度 */
:deep(.gjs-trt-traits),
:deep(.gjs-sm-sectors) {
  font-size: 12px;
}
 
:deep(.gjs-cv-canvas) {
  overflow: auto;
  background-color: #f0f0f0;
}
 
/*
 * GrapesJS 的画布默认是一块 overflow:hidden 的绝对定位舞台,纸张比可视区大时会被裁掉。
 * 这里把画布改成普通滚动容器:帧容器回归文档流,纸张用 margin 居中,超出即可滚动。
 */
:deep(.gjs-cv-canvas__frames),
:deep(.gjs-frames) {
  position: static;
  width: auto;
  height: auto;
}
 
:deep(.gjs-frame-wrapper) {
  position: relative;
  top: auto !important;
  left: auto !important;
  margin: 24px auto;
}
 
:deep(.gjs-block) {
  width: calc(50% - 6px);
  min-height: auto;
  padding: 6px 4px;
  font-size: 12px;
}
 
:deep(.gjs-block-label) {
  font-size: 12px;
}
</style>