云
2026-09-20 35722562e9e13f0504acc15b740d042ecb810199
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
<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>