落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-18 17:42:46 +08:00
co-authored by Cursor
parent 982b3a2317
commit 527050bbf9
41 changed files with 4965 additions and 426 deletions
@@ -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>