落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,26 +3,23 @@
|
||||
* 报价模板页:对接主账 /inquiry/quoteTemplate。
|
||||
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
|
||||
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
|
||||
* - 预览:弹窗内按映射回填合同样例事实(接近原型可读性);权威版式仍以下载 Excel 为准
|
||||
* - 预览:原表截图(LibreOffice 转图),不是 AI 画格子
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
||||
import { Modal, message } from "ant-design-vue";
|
||||
import { mapApiRow, type TemplateRow } from "/@/views/inquiry/mock/templates";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import {
|
||||
deleteTemplate,
|
||||
disableTemplate,
|
||||
downloadTemplateFile,
|
||||
editTemplateMeta,
|
||||
enableTemplate,
|
||||
fetchTemplateSnapshotBlob,
|
||||
getTemplateList,
|
||||
getTemplateVersion,
|
||||
reparseTemplate,
|
||||
uploadTemplate,
|
||||
} from "/@/views/inquiry/template/template.api";
|
||||
import {
|
||||
buildPreviewViewModel,
|
||||
emptyPreviewViewModel,
|
||||
type PreviewViewModel,
|
||||
} from "/@/views/inquiry/template/previewSample";
|
||||
|
||||
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
||||
|
||||
@@ -48,8 +45,15 @@ const formOpen = ref(false);
|
||||
const editing = ref<TemplateRow | null>(null);
|
||||
const previewOpen = ref(false);
|
||||
const previewRow = ref<TemplateRow | null>(null);
|
||||
/** 弹窗内映射回填一览(合同样例事实);下载 Excel 仍走 previewSample */
|
||||
const previewVm = ref<PreviewViewModel>(emptyPreviewViewModel());
|
||||
/** 原表截图预览状态 */
|
||||
const previewState = reactive({
|
||||
loading: false,
|
||||
error: "",
|
||||
objectUrl: "",
|
||||
isImage: true,
|
||||
mappedCount: 0,
|
||||
fileName: "",
|
||||
});
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const pendingFile = ref<File | null>(null);
|
||||
|
||||
@@ -70,30 +74,85 @@ const parseState = reactive({
|
||||
ok: true,
|
||||
title: "",
|
||||
desc: "",
|
||||
/** 表上有、未映射上的中文说明 */
|
||||
gaps: [] as string[],
|
||||
});
|
||||
|
||||
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
|
||||
const formSub = computed(() =>
|
||||
editing.value
|
||||
? "可改基本信息;更换文件将追加新版本并重新解析映射"
|
||||
: "上传 Excel,系统按黄区与标签自动建议单元格映射",
|
||||
? "可改基本信息;更换文件将追加新版本并由 AI 异步解析映射"
|
||||
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
|
||||
);
|
||||
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
|
||||
const previewFootHint =
|
||||
"用户端生成时将复制本版 Excel 并按映射写入已冻结事实(黄区不改),不改变原有 Excel / PDF 样式;模板改版请重新上传";
|
||||
|
||||
const previewTotalText = computed(() => {
|
||||
const vm = previewVm.value;
|
||||
if (!vm.totalAmount) return "—";
|
||||
const n = Number(vm.totalAmount);
|
||||
const amt = Number.isFinite(n)
|
||||
? n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: vm.totalAmount;
|
||||
return `${vm.currency || "USD"} ${amt}`;
|
||||
});
|
||||
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
|
||||
const previewLightbox = ref(false);
|
||||
|
||||
const filtered = computed(() => rows.value);
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewState.objectUrl) {
|
||||
URL.revokeObjectURL(previewState.objectUrl);
|
||||
previewState.objectUrl = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开预览:读取上传后已缓存的截图(未就绪则现转)。
|
||||
* 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。
|
||||
*/
|
||||
async function openPreview(row: TemplateRow) {
|
||||
previewRow.value = row;
|
||||
previewOpen.value = true;
|
||||
previewLightbox.value = false;
|
||||
revokePreviewUrl();
|
||||
previewState.loading = true;
|
||||
previewState.error = "";
|
||||
previewState.isImage = true;
|
||||
previewState.mappedCount = row.mappingPointCount || 0;
|
||||
previewState.fileName = row.fileName || "";
|
||||
if (!row.versionId) {
|
||||
previewState.loading = false;
|
||||
previewState.error = "无可用版本,请先上传模板文件";
|
||||
return;
|
||||
}
|
||||
if (row.format === "pdf") {
|
||||
previewState.loading = false;
|
||||
previewState.error = "PDF 模板请直接下载原文件查看";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { blob, contentType } = await fetchTemplateSnapshotBlob(row.versionId);
|
||||
previewState.isImage = contentType.includes("image/") || blob.type.includes("image/");
|
||||
previewState.objectUrl = URL.createObjectURL(blob);
|
||||
previewState.loading = false;
|
||||
} catch (e: any) {
|
||||
previewState.loading = false;
|
||||
previewState.error = e?.message || "原表截图尚未就绪,请稍后重试或下载原模板";
|
||||
}
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen.value = false;
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewOpen.value = false;
|
||||
previewLightbox.value = false;
|
||||
revokePreviewUrl();
|
||||
previewState.loading = false;
|
||||
previewState.error = "";
|
||||
}
|
||||
|
||||
function openLightbox() {
|
||||
if (!previewState.objectUrl || !previewState.isImage) return;
|
||||
previewLightbox.value = true;
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
previewLightbox.value = false;
|
||||
}
|
||||
|
||||
function bizTag(biz: string) {
|
||||
if (biz === "海运") return "tag-teal";
|
||||
if (biz === "陆运") return "tag-amber";
|
||||
@@ -118,6 +177,7 @@ async function loadList() {
|
||||
});
|
||||
const records = res?.records || res?.result?.records || [];
|
||||
rows.value = (records as any[]).map(mapApiRow);
|
||||
scheduleParsePoll();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "加载模板列表失败");
|
||||
} finally {
|
||||
@@ -125,11 +185,50 @@ async function loadList() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表里仍有「解析中」时每 4 秒刷新,直到全部结束 */
|
||||
let parsePollTimer: number | null = null;
|
||||
|
||||
function hasPendingParse() {
|
||||
return rows.value.some((r) => r.parseStatus === "解析中");
|
||||
}
|
||||
|
||||
function scheduleParsePoll() {
|
||||
if (parsePollTimer != null) {
|
||||
window.clearTimeout(parsePollTimer);
|
||||
parsePollTimer = null;
|
||||
}
|
||||
if (!hasPendingParse()) return;
|
||||
parsePollTimer = window.setTimeout(async () => {
|
||||
parsePollTimer = null;
|
||||
try {
|
||||
const res: any = await getTemplateList({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
bizType: bizFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
});
|
||||
const records = res?.records || res?.result?.records || [];
|
||||
rows.value = (records as any[]).map(mapApiRow);
|
||||
} catch {
|
||||
/* 轮询失败不打断页面 */
|
||||
}
|
||||
scheduleParsePoll();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clearOrphanAntMasks();
|
||||
loadList();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (parsePollTimer != null) {
|
||||
window.clearTimeout(parsePollTimer);
|
||||
parsePollTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
name: "",
|
||||
@@ -146,6 +245,7 @@ function resetForm() {
|
||||
parseState.ok = true;
|
||||
parseState.title = "";
|
||||
parseState.desc = "";
|
||||
parseState.gaps = [];
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -168,15 +268,28 @@ function openEdit(row: TemplateRow) {
|
||||
});
|
||||
pendingFile.value = null;
|
||||
parseState.visible = true;
|
||||
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成";
|
||||
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成" || row.parseStatus === "解析中";
|
||||
parseState.title = row.parseStatus || "解析状态";
|
||||
parseState.gaps = listMappingGaps(row);
|
||||
parseState.desc =
|
||||
row.parseMessage ||
|
||||
`已识别 ${row.mappingPointCount} 个映射点` +
|
||||
(row.unmappedRequired?.length ? `;未映射:${row.unmappedRequired.join(",")}` : "");
|
||||
(parseState.gaps.length ? `;未解析到 ${parseState.gaps.length} 项` : "") +
|
||||
(row.unmappedRequired?.length && !parseState.gaps.length
|
||||
? `;未映射:${row.unmappedRequired.join(",")}`
|
||||
: "");
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */
|
||||
function listMappingGaps(row: TemplateRow): string[] {
|
||||
if (row.mappingGaps?.length) return [...row.mappingGaps];
|
||||
if (row.unmappedRequired?.length) {
|
||||
return row.unmappedRequired.map((c) => `字段未映射:${c}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeKeywordItems(items: string[]): string[] {
|
||||
const unique: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -199,50 +312,6 @@ function removeKeyword(index: number) {
|
||||
form.keywordItems.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开预览:先展示列表摘要,再拉 version.mappingJson 组装样例回填一览。
|
||||
* 副作用:HTTP 读版本详情;不写库、不调 LLM。
|
||||
*/
|
||||
async function openPreview(row: TemplateRow) {
|
||||
previewRow.value = row;
|
||||
previewOpen.value = true;
|
||||
previewVm.value = {
|
||||
...emptyPreviewViewModel(),
|
||||
loading: true,
|
||||
mappedCount: row.mappingPointCount || 0,
|
||||
};
|
||||
if (!row.versionId) {
|
||||
previewVm.value = {
|
||||
...emptyPreviewViewModel(),
|
||||
loading: false,
|
||||
error: "无可用版本,请先上传模板文件",
|
||||
mappedCount: row.mappingPointCount || 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ver: any = await getTemplateVersion(row.versionId);
|
||||
const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson;
|
||||
previewVm.value = buildPreviewViewModel(mappingJson, row.mappingPointCount || 0);
|
||||
} catch (e: any) {
|
||||
previewVm.value = {
|
||||
...emptyPreviewViewModel(),
|
||||
loading: false,
|
||||
error: e?.message || "加载映射失败,仍可下载预览文件",
|
||||
mappedCount: row.mappingPointCount || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen.value = false;
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewOpen.value = false;
|
||||
previewVm.value = emptyPreviewViewModel();
|
||||
}
|
||||
|
||||
async function toggleStatus(row: TemplateRow) {
|
||||
try {
|
||||
if (row.status === "enabled") {
|
||||
@@ -262,6 +331,56 @@ async function toggleStatus(row: TemplateRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
|
||||
* 副作用:主账入队;列表进入解析中并轮询。
|
||||
*/
|
||||
async function reparseRow(row: TemplateRow) {
|
||||
if (row.parseStatus === "解析中") {
|
||||
message.info("已在解析中,请稍候");
|
||||
return;
|
||||
}
|
||||
if (!row.versionId) {
|
||||
message.warning("无可用版本,请先上传文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg: any = await reparseTemplate(row.id);
|
||||
message.success(typeof msg === "string" ? msg : msg?.message || "已提交重新解析");
|
||||
await loadList();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "重新解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板:二次确认后调主账物理删除。
|
||||
* 副作用:列表刷新;对象存储文件尽量清理;已出单附件不受影响。
|
||||
*/
|
||||
function removeTemplate(row: TemplateRow) {
|
||||
const tips: string[] = [];
|
||||
if (row.status === "enabled") tips.push("当前为启用状态");
|
||||
if (row.isDefault) tips.push("当前为默认模板");
|
||||
const extra = tips.length ? `(${tips.join(",")})` : "";
|
||||
Modal.confirm({
|
||||
title: `删除模板「${row.name}」?`,
|
||||
content: `将删除该模板及全部版本${extra},此操作不可恢复。`,
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteTemplate(row.id);
|
||||
message.success("已删除");
|
||||
await loadList();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "删除失败");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (!bytes) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -281,7 +400,8 @@ function applySelectedFile(file: File) {
|
||||
parseState.visible = true;
|
||||
parseState.ok = true;
|
||||
parseState.title = "待上传解析";
|
||||
parseState.desc = "点击保存后由主账解析黄区并自动建议映射点";
|
||||
parseState.desc = "点击保存后提交 AI 异步解析;列表会显示解析中/成功/失败原因与未解析项";
|
||||
parseState.gaps = [];
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
@@ -325,7 +445,12 @@ async function submitForm() {
|
||||
fd.append("isDefault", String(form.isDefault));
|
||||
fd.append("file", pendingFile.value);
|
||||
const row: any = await uploadTemplate(fd);
|
||||
message.success(row?.parseMessage || "已上传并解析");
|
||||
const msg = row?.parseMessage || "已提交解析";
|
||||
if (String(row?.parseStatus || "").includes("失败") || String(row?.parseStatus || "") === "failed") {
|
||||
message.error(msg);
|
||||
} else {
|
||||
message.success(msg.includes("解析中") ? "已提交 AI 解析,列表将自动刷新状态" : msg);
|
||||
}
|
||||
} else if (editing.value) {
|
||||
await editTemplateMeta({
|
||||
id: editing.value.id,
|
||||
@@ -353,17 +478,12 @@ async function downloadPreviewOrOriginal() {
|
||||
message.warning("无版本可下载");
|
||||
return;
|
||||
}
|
||||
const kind = row.format === "excel" ? "previewSample" : "download";
|
||||
try {
|
||||
message.loading({ content: "正在生成预览文件…", key: "tpl-dl", duration: 0 });
|
||||
const hint =
|
||||
kind === "previewSample"
|
||||
? (row.fileName || "preview").replace(/\.xlsx$/i, "-preview.xlsx")
|
||||
: row.fileName || "template.bin";
|
||||
await downloadTemplateFile(kind, row.versionId, hint);
|
||||
message.loading({ content: "正在下载原模板…", key: "tpl-dl", duration: 0 });
|
||||
await downloadTemplateFile("download", row.versionId, row.fileName || "template.bin");
|
||||
message.success({ content: "已开始下载", key: "tpl-dl" });
|
||||
} catch (e: any) {
|
||||
message.error({ content: e?.message || "下载预览文件失败", key: "tpl-dl" });
|
||||
message.error({ content: e?.message || "下载原模板失败", key: "tpl-dl" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -422,7 +542,31 @@ async function downloadPreviewOrOriginal() {
|
||||
<td class="qt-col-keywords" :title="formatKeywords(row.keywords)">
|
||||
{{ formatKeywords(row.keywords) || "—" }}
|
||||
</td>
|
||||
<td class="qt-col-muted">{{ row.parseStatus }}</td>
|
||||
<td
|
||||
class="qt-col-muted"
|
||||
:class="{
|
||||
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
|
||||
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
|
||||
}"
|
||||
:title="row.parseMessage || row.parseStatus"
|
||||
>
|
||||
<span>{{ row.parseStatus }}</span>
|
||||
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
|
||||
未解析 {{ listMappingGaps(row).length }}
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
row.parseMessage &&
|
||||
(row.parseStatus === '失败' ||
|
||||
row.parseStatus === '映射未完成' ||
|
||||
row.parseStatus === '解析中' ||
|
||||
listMappingGaps(row).length > 0)
|
||||
"
|
||||
class="qt-parse-msg"
|
||||
>
|
||||
{{ row.parseMessage }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="qt-col-muted">{{ row.status === "enabled" ? "启用" : "禁用" }}</td>
|
||||
<td class="qt-col-count">{{ row.useCount }} 次</td>
|
||||
<td class="qt-col-time">{{ row.createdAt }}</td>
|
||||
@@ -433,6 +577,15 @@ async function downloadPreviewOrOriginal() {
|
||||
预览
|
||||
</button>
|
||||
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link-action"
|
||||
:disabled="row.parseStatus === '解析中'"
|
||||
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
|
||||
@click.stop="reparseRow(row)"
|
||||
>
|
||||
重新解析
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link-action"
|
||||
@@ -441,6 +594,9 @@ async function downloadPreviewOrOriginal() {
|
||||
>
|
||||
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
||||
</button>
|
||||
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -529,13 +685,26 @@ async function downloadPreviewOrOriginal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="parseState.visible" class="qt-parse-banner" :class="{ 'is-error': !parseState.ok }">
|
||||
<div class="qt-parse-banner-icon" :class="parseState.ok ? 'is-ok' : 'is-err'">
|
||||
<div
|
||||
v-if="parseState.visible"
|
||||
class="qt-parse-banner"
|
||||
:class="{ 'is-error': !parseState.ok, 'is-warn': parseState.ok && parseState.gaps.length > 0 }"
|
||||
>
|
||||
<div
|
||||
class="qt-parse-banner-icon"
|
||||
:class="!parseState.ok ? 'is-err' : parseState.gaps.length ? 'is-warn' : 'is-ok'"
|
||||
>
|
||||
<ProtoIcon :name="parseState.ok ? 'circleCheck' : 'circleX'" :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="qt-parse-banner-body">
|
||||
<div class="qt-parse-banner-title">{{ parseState.title }}</div>
|
||||
<div class="qt-parse-banner-desc">{{ parseState.desc }}</div>
|
||||
<div v-if="parseState.gaps.length" class="qt-gap-block">
|
||||
<div class="qt-gap-block-title">表上有、尚未映射到的项({{ parseState.gaps.length }})</div>
|
||||
<ul class="qt-gap-list">
|
||||
<li v-for="(g, i) in parseState.gaps" :key="i">{{ g }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -610,7 +779,7 @@ async function downloadPreviewOrOriginal() {
|
||||
<ProtoIcon name="eye" :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="qt-modal-sub">基于上传模板渲染 · 用户端预览</div>
|
||||
<div class="qt-modal-sub">上传原表 · 截图预览</div>
|
||||
<div class="qt-modal-title">{{ previewRow.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -621,73 +790,32 @@ async function downloadPreviewOrOriginal() {
|
||||
|
||||
<div class="qt-preview-modal-body">
|
||||
<div class="qt-preview-tags">
|
||||
<span class="tag" :class="bizTag(previewRow.bizType)">{{ previewRow.fileName }}</span>
|
||||
<span class="tag tag-green"
|
||||
>已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点</span
|
||||
>
|
||||
<span class="tag" :class="bizTag(previewRow.bizType)">{{
|
||||
previewState.fileName || previewRow.fileName
|
||||
}}</span>
|
||||
<span class="tag tag-green">已映射 {{ previewState.mappedCount }} 项</span>
|
||||
</div>
|
||||
<div class="qt-paper">
|
||||
<div class="qt-paper-head">
|
||||
<div>
|
||||
<div class="qt-paper-title">
|
||||
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
|
||||
</div>
|
||||
<div class="qt-paper-cap">模板款式 · 由上传 Excel 决定(黄区固定)</div>
|
||||
</div>
|
||||
<div class="qt-paper-meta">
|
||||
<div v-if="previewVm.quoteNo">{{ previewVm.quoteNo }}</div>
|
||||
<div v-if="previewVm.quoteDate">{{ previewVm.quoteDate }}</div>
|
||||
<div v-else>版本 {{ previewRow.currentVersion || "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div v-if="previewVm.loading" class="qt-paper-loading">正在加载映射回填…</div>
|
||||
<div v-else-if="previewVm.error" class="qt-paper-loading qt-paper-loading--err">
|
||||
{{ previewVm.error }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="qt-paper-grid">
|
||||
<div v-for="f in previewVm.fields" :key="f.code">
|
||||
<span class="qt-paper-label">{{ f.label }}</span>
|
||||
<span class="qt-paper-value">{{ f.value }}</span>
|
||||
</div>
|
||||
<div v-if="!previewVm.fields.length">
|
||||
<span class="qt-paper-label">映射</span>
|
||||
<span class="qt-paper-value">暂无已映射字段,请先确认映射或下载 Excel 查看</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewVm.totalAmount" class="qt-paper-total">
|
||||
<div class="qt-paper-total-label">报价合计(样例)</div>
|
||||
<div class="qt-paper-amt">{{ previewTotalText }}</div>
|
||||
<div class="qt-paper-total-hint">
|
||||
按映射回填合同样例事实
|
||||
<template v-if="previewVm.validUntil"> · 有效期至 {{ previewVm.validUntil }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<table v-if="previewVm.fees.length" class="qt-paper-fee">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>费用项</th>
|
||||
<th style="text-align: right">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(fee, idx) in previewVm.fees" :key="idx">
|
||||
<td>{{ fee.name }}</td>
|
||||
<td style="text-align: right">{{ fee.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>合计</td>
|
||||
<td style="text-align: right">{{ previewVm.totalAmount }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</template>
|
||||
<p class="qt-paper-note">
|
||||
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。
|
||||
</p>
|
||||
<div v-if="previewState.loading" class="qt-paper-loading">
|
||||
正在加载原表截图…
|
||||
</div>
|
||||
<div v-else-if="previewState.error" class="qt-paper-loading qt-paper-loading--err">
|
||||
{{ previewState.error }}
|
||||
</div>
|
||||
<div v-else-if="previewState.objectUrl" class="qt-snapshot-wrap">
|
||||
<img
|
||||
v-if="previewState.isImage"
|
||||
class="qt-snapshot-img"
|
||||
:src="previewState.objectUrl"
|
||||
alt="原表截图预览"
|
||||
title="点击放大"
|
||||
@click="openLightbox"
|
||||
/>
|
||||
<iframe
|
||||
v-else
|
||||
class="qt-snapshot-iframe"
|
||||
:src="previewState.objectUrl"
|
||||
title="原表 PDF 预览"
|
||||
/>
|
||||
</div>
|
||||
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
|
||||
</div>
|
||||
@@ -695,15 +823,38 @@ async function downloadPreviewOrOriginal() {
|
||||
<div class="qt-preview-modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closePreview">关闭</button>
|
||||
<button type="button" class="btn btn-primary" @click="downloadPreviewOrOriginal">
|
||||
<ProtoIcon name="download" :size="16" />下载预览文件
|
||||
<ProtoIcon name="download" :size="16" />下载原模板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="previewLightbox && previewState.objectUrl"
|
||||
class="qt-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button type="button" class="qt-lightbox-close" @click="closeLightbox">关闭</button>
|
||||
<img
|
||||
class="qt-lightbox-img"
|
||||
:src="previewState.objectUrl"
|
||||
alt="原表截图放大"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.qt-preview-dialog),
|
||||
.qt-preview-dialog {
|
||||
max-width: min(96vw, 980px) !important;
|
||||
width: min(96vw, 980px);
|
||||
}
|
||||
.qt-col-name {
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
@@ -718,6 +869,54 @@ async function downloadPreviewOrOriginal() {
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
}
|
||||
.qt-parse-fail {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.qt-parse-gaps {
|
||||
color: #b45309;
|
||||
}
|
||||
.qt-gap-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #b54708;
|
||||
background: #fffaeb;
|
||||
border: 1px solid #fec84b;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.qt-parse-msg {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #b45309;
|
||||
max-width: 220px;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.qt-parse-fail .qt-parse-msg {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.qt-gap-block {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.qt-gap-block-title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #b54708;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qt-gap-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #93370d;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.qt-col-keywords {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -757,6 +956,71 @@ async function downloadPreviewOrOriginal() {
|
||||
.qt-paper-loading--err {
|
||||
color: #b42318;
|
||||
}
|
||||
.qt-snapshot-wrap {
|
||||
max-height: min(70vh, 720px);
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.qt-snapshot-img {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
vertical-align: top;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.qt-snapshot-iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: min(70vh, 720px);
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.qt-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.qt-lightbox-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2001;
|
||||
min-width: 72px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.qt-lightbox-close:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.qt-lightbox-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 72px);
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.qt-create-row--keywords {
|
||||
/* 与名称/业务同用 center,单行时标签与输入框垂直对齐 */
|
||||
@@ -843,4 +1107,129 @@ async function downloadPreviewOrOriginal() {
|
||||
accent-color: #155eef;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qt-parse-miss {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fef3f2;
|
||||
border: 1px solid #fecdca;
|
||||
}
|
||||
.qt-parse-miss-title {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #b42318;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qt-parse-miss-list {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #912018;
|
||||
}
|
||||
.qt-excel-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #667085;
|
||||
}
|
||||
.qt-leg {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 6px;
|
||||
vertical-align: -2px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.qt-leg--yellow {
|
||||
background: #fff2cc;
|
||||
border-color: #f5d565;
|
||||
}
|
||||
.qt-leg--mapped {
|
||||
background: #eff8ff;
|
||||
border-color: #84caff;
|
||||
box-shadow: inset 0 0 0 1px #2e90fa;
|
||||
}
|
||||
.qt-excel-wrap {
|
||||
max-height: min(62vh, 560px);
|
||||
overflow: auto;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.qt-excel {
|
||||
border-collapse: collapse;
|
||||
min-width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
.qt-excel-corner,
|
||||
.qt-excel-col,
|
||||
.qt-excel-row {
|
||||
position: sticky;
|
||||
background: #f2f4f7;
|
||||
color: #667085;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border: 1px solid #e4e7ec;
|
||||
z-index: 1;
|
||||
}
|
||||
.qt-excel-corner {
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
min-width: 36px;
|
||||
top: 0;
|
||||
}
|
||||
.qt-excel-col {
|
||||
top: 0;
|
||||
min-width: 88px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.qt-excel-row {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
padding: 0 6px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.qt-excel-cell {
|
||||
border: 1px solid #e4e7ec;
|
||||
padding: 4px 6px;
|
||||
vertical-align: top;
|
||||
min-width: 88px;
|
||||
max-width: 160px;
|
||||
background: #fff;
|
||||
color: #101828;
|
||||
}
|
||||
.qt-excel-cell.is-yellow {
|
||||
background: #fff2cc;
|
||||
}
|
||||
.qt-excel-cell.is-mapped {
|
||||
outline: 2px solid #2e90fa;
|
||||
outline-offset: -2px;
|
||||
background: #f5faff;
|
||||
}
|
||||
.qt-excel-cell.is-yellow.is-mapped {
|
||||
background: #fff8e0;
|
||||
}
|
||||
.qt-excel-map-tag {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
color: #175cd3;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.qt-excel-text {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-red {
|
||||
background: #fef3f2;
|
||||
color: #b42318;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user