Files
inquiry_robot/inquiry-backend/src/views/inquiry/template/index.vue
T

1635 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图)
* - 解析状态列可点:弹窗看已解析 / 未解析清单
*/
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 {
FIELD_LABELS,
summarizeMappingParse,
type MappingParseSummary,
} from "/@/views/inquiry/template/previewSample";
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
/** 清掉可能残留的 Ant Modal 遮罩(会导致整页点不了) */
function clearOrphanAntMasks() {
document.querySelectorAll(".ant-modal-mask, .ant-modal-wrap").forEach((el) => {
const node = el as HTMLElement;
if (node.style.display === "none" || node.getAttribute("aria-hidden") === "true") {
node.remove();
}
});
document.body.style.removeProperty("overflow");
document.body.classList.remove("ant-scrolling-effect");
}
const rows = ref<TemplateRow[]>([]);
const loading = ref(false);
const keyword = ref("");
const bizFilter = ref("");
const statusFilter = ref("");
const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
const previewRow = ref<TemplateRow | null>(null);
/** 空映射摘要 */
const emptyMappingSummary = (): MappingParseSummary => ({
fields: [],
regions: [],
gaps: [],
unmappedRequired: [],
unmappedLabels: [],
pointCount: 0,
});
/** 原表截图预览状态(不含映射清单) */
const previewState = reactive({
loading: false,
error: "",
objectUrl: "",
isImage: true,
mappedCount: 0,
fileName: "",
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
trimmed: false,
});
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
const parseDetailOpen = ref(false);
const parseDetailRow = ref<TemplateRow | null>(null);
const parseDetailState = reactive({
loading: false,
mappedCount: 0,
mapping: emptyMappingSummary() as MappingParseSummary,
});
/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */
const parseDetailUnparsed = computed(() => {
const m = parseDetailState.mapping;
if (Array.isArray(m.gaps)) {
return (m.gaps || []).filter(Boolean).map(String);
}
const list: string[] = [];
for (const c of m.unmappedRequired || []) {
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
}
return list;
});
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
/** 运营表单不含「分类」:主账上传时按业务自行补默认分类 */
const form = reactive({
name: "",
bizType: "海运" as TemplateRow["bizType"],
mode: "auto" as "auto" | "manual",
keywordItems: [""] as string[],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as "excel" | "pdf",
});
const parseState = reactive({
visible: false,
ok: true,
title: "",
desc: "",
/** 表上有、未映射上的中文说明 */
gaps: [] as string[],
});
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
const formSub = computed(() =>
editing.value
? "可改基本信息;更换文件将追加新版本并由 AI 异步解析映射"
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
);
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
const previewLightbox = ref(false);
const filtered = computed(() => rows.value);
function revokePreviewUrl() {
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewState.objectUrl);
}
previewState.objectUrl = "";
previewState.trimmed = false;
}
/**
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
*/
function onSnapshotImgLoad(ev: Event) {
if (previewState.trimmed || !previewState.isImage) return;
const img = ev.target as HTMLImageElement | null;
if (!img?.naturalWidth || !img.naturalHeight) return;
try {
const next = cropNearWhiteObjectUrl(img);
if (!next) {
previewState.trimmed = true;
return;
}
const old = previewState.objectUrl;
previewState.trimmed = true;
previewState.objectUrl = next;
if (old && old.startsWith("blob:")) {
URL.revokeObjectURL(old);
}
} catch {
previewState.trimmed = true;
}
}
/**
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
*/
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
const w = img.naturalWidth;
const h = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, w, h).data;
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
const rowMostlyWhite = (y: number) => {
const allow = Math.max(2, Math.floor(w / 200));
let dirty = 0;
const row = y * w * 4;
for (let x = 0; x < w; x++) {
if (!isWhite(row + x * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
const colMostlyWhite = (x: number, y0: number, y1: number) => {
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
let dirty = 0;
for (let y = y0; y <= y1; y++) {
if (!isWhite((y * w + x) * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
let top = 0;
let bottom = h - 1;
let left = 0;
let right = w - 1;
while (top < h && rowMostlyWhite(top)) top++;
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
while (left < w && colMostlyWhite(left, top, bottom)) left++;
while (right > left && colMostlyWhite(right, top, bottom)) right--;
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
const pad = 8;
const x = Math.max(0, left - pad);
const y = Math.max(0, top - pad);
const cw = Math.min(w - 1, right + pad) - x + 1;
const ch = Math.min(h - 1, bottom + pad) - y + 1;
if (cw >= w && ch >= h) return null;
const out = document.createElement("canvas");
out.width = cw;
out.height = ch;
const octx = out.getContext("2d");
if (!octx) return null;
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
return out.toDataURL("image/png");
}
/**
* 打开预览:仅原表截图。
* 副作用: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.trimmed = false;
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 || "原表截图尚未就绪,请稍后重试或下载原模板";
}
}
/**
* 列表点「解析状态」:弹窗展示已解析 / 未解析清单。
* 副作用:HTTP 读 version.mappingJson;不写库。
*/
async function openParseDetail(row: TemplateRow) {
if (row.parseStatus === "解析中" || row.parseStatus === "未上传") {
message.info(row.parseStatus === "解析中" ? "解析中,请稍候再看" : "请先上传模板文件");
return;
}
if (!row.versionId) {
message.warning("无可用版本");
return;
}
parseDetailRow.value = row;
parseDetailOpen.value = true;
parseDetailState.loading = true;
parseDetailState.mappedCount = row.mappingPointCount || 0;
parseDetailState.mapping = emptyMappingSummary();
try {
const ver: any = await getTemplateVersion(row.versionId);
const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson ?? null;
let summary = summarizeMappingParse(mappingJson);
if (!summary.gaps.length && row.mappingGaps?.length) {
summary = { ...summary, gaps: [...row.mappingGaps] };
}
if (!summary.unmappedRequired.length && row.unmappedRequired?.length) {
summary = { ...summary, unmappedRequired: [...row.unmappedRequired] };
}
parseDetailState.mapping = summary;
if (summary.pointCount > 0) {
parseDetailState.mappedCount = summary.pointCount;
}
} catch {
parseDetailState.mapping = {
...emptyMappingSummary(),
gaps: listMappingGaps(row),
unmappedRequired: row.unmappedRequired || [],
pointCount: row.mappingPointCount || 0,
};
} finally {
parseDetailState.loading = false;
}
}
function closeParseDetail() {
parseDetailOpen.value = false;
parseDetailRow.value = null;
parseDetailState.loading = false;
parseDetailState.mapping = emptyMappingSummary();
}
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";
return "tag-blue";
}
/** 列表展示用:关键词用顿号拼接;空则显示占位 */
function formatKeywords(keywords: string[] | undefined) {
if (!keywords?.length) return "";
return keywords.map((k) => String(k || "").trim()).filter(Boolean).join("、");
}
async function loadList() {
loading.value = true;
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);
scheduleParsePoll();
} catch (e: any) {
message.error(e?.message || "加载模板列表失败");
} finally {
loading.value = false;
}
}
/** 列表里仍有「解析中」时每 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: "",
bizType: "海运" as TemplateRow["bizType"],
mode: "auto" as const,
keywordItems: [""],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as const,
});
pendingFile.value = null;
parseState.visible = false;
parseState.ok = true;
parseState.title = "";
parseState.desc = "";
parseState.gaps = [];
}
function openCreate() {
editing.value = null;
resetForm();
formOpen.value = true;
}
function openEdit(row: TemplateRow) {
editing.value = row;
Object.assign(form, {
name: row.name,
bizType: row.bizType,
mode: row.mode || "auto",
keywordItems: row.keywords.length ? [...row.keywords] : [""],
isDefault: Boolean(row.isDefault),
fileName: row.fileName,
fileMeta: `${row.format === "pdf" ? "PDF" : "Excel"} · 已上传`,
format: row.format,
});
pendingFile.value = null;
parseState.visible = true;
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成" || row.parseStatus === "解析中";
parseState.title = row.parseStatus || "解析状态";
parseState.gaps = listMappingGaps(row);
parseState.desc =
row.parseMessage ||
`已识别 ${row.mappingPointCount} 个映射点` +
(parseState.gaps.length ? `;未解析到 ${parseState.gaps.length} 项` : "") +
(row.unmappedRequired?.length && !parseState.gaps.length
? `;未映射:${row.unmappedRequired.join(",")}`
: "");
formOpen.value = true;
}
/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */
function listMappingGaps(row: TemplateRow): string[] {
// 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标
if (Array.isArray(row.mappingGaps)) {
return row.mappingGaps.filter(Boolean).map(String);
}
const list: string[] = [];
for (const c of row.unmappedRequired || []) {
if (c) list.push(`字段未映射:${c}`);
}
return list;
}
/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */
function unparsedCount(row: TemplateRow): number {
if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount);
return listMappingGaps(row).length;
}
function normalizeKeywordItems(items: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
for (const raw of items) {
const kw = String(raw ?? "").trim();
if (!kw || seen.has(kw)) continue;
seen.add(kw);
unique.push(kw);
}
return unique;
}
function addKeyword() {
if (form.keywordItems.length >= 20) return;
form.keywordItems.push("");
}
function removeKeyword(index: number) {
if (form.keywordItems.length <= 1) return;
form.keywordItems.splice(index, 1);
}
async function toggleStatus(row: TemplateRow) {
try {
if (row.status === "enabled") {
await disableTemplate(row.id);
message.success("已停用");
} else {
if (!row.versionId) {
message.warning("无可用版本,请先上传文件");
return;
}
await enableTemplate(row.id, row.versionId);
message.success("已启用");
}
await loadList();
} catch (e: any) {
message.error(e?.message || "启停失败(自动模板须完成必填映射)");
}
}
/**
* 重新提交 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`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function applySelectedFile(file: File) {
const isPdf = /\.pdf$/i.test(file.name);
form.format = isPdf ? "pdf" : "excel";
form.fileName = file.name;
form.fileMeta = `${isPdf ? "PDF" : "Excel"} · ${formatFileSize(file.size)}`;
pendingFile.value = file;
if (!form.name.trim()) {
form.name = file.name.replace(/\.(xlsx|xls|pdf)$/i, "");
}
parseState.visible = true;
parseState.ok = true;
parseState.title = "待上传解析";
parseState.desc = "点击保存后提交 AI 异步解析;列表会显示解析中/成功/失败原因与未解析项";
parseState.gaps = [];
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) applySelectedFile(file);
input.value = "";
}
function onDrop(e: DragEvent) {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (file) applySelectedFile(file);
}
function triggerFilePick() {
fileInputRef.value?.click();
}
async function submitForm() {
const keywords = normalizeKeywordItems(form.keywordItems);
if (!form.name.trim()) {
message.warning("请填写模板名称");
return;
}
if (!keywords.length) {
message.warning("请至少填写一个关键词");
return;
}
// 模式不对运营展示:新建默认 auto,编辑保留原值;分类交主账按业务补默认
const mode = editing.value?.mode || "auto";
try {
if (pendingFile.value) {
const fd = new FormData();
if (editing.value?.id) fd.append("templateId", editing.value.id);
fd.append("name", form.name.trim());
fd.append("bizType", form.bizType);
fd.append("keywordsJson", JSON.stringify(keywords));
fd.append("mode", mode);
fd.append("isDefault", String(form.isDefault));
fd.append("file", pendingFile.value);
const row: any = await uploadTemplate(fd);
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,
name: form.name.trim(),
bizType: form.bizType,
keywordsJson: JSON.stringify(keywords),
mode,
isDefault: form.isDefault ? 1 : 0,
});
message.success("模板信息已保存");
} else {
message.warning("请先选择模板文件");
return;
}
formOpen.value = false;
await loadList();
} catch (e: any) {
message.error(e?.message || "保存失败");
}
}
async function downloadPreviewOrOriginal() {
const row = previewRow.value;
if (!row?.versionId) {
message.warning("无版本可下载");
return;
}
try {
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" });
}
}
</script>
<template>
<!-- 唯一上传入口:顶栏 -->
<Teleport defer to="#page-topbar-actions">
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
<ProtoIcon name="upload" :size="16" />上传模板
</button>
</Teleport>
<div class="section-card qt-template-page is-list-page">
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="keyword" placeholder="搜索模板名称…" @keyup.enter="loadList" />
</div>
<select v-model="bizFilter" @change="loadList">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
</select>
<select v-model="statusFilter" @change="loadList">
<option value="">全部状态</option>
<option value="enabled">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<div class="table-wrap qt-template-table-wrap">
<table class="data-table qt-template-table">
<thead>
<tr>
<th>模板名称</th>
<th>业务</th>
<th>关键词</th>
<th>解析状态</th>
<th>状态</th>
<th>总使用次数</th>
<th>创建时间</th>
<th>操作人</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filtered" :key="row.id">
<td class="qt-col-name">
<span>{{ row.name }}</span>
<span v-if="row.isDefault" class="tag tag-blue qt-default-tag">默认</span>
</td>
<td>
<span class="tag" :class="bizTag(row.bizType)">{{ row.bizType }}</span>
</td>
<td class="qt-col-keywords" :title="formatKeywords(row.keywords)">
{{ formatKeywords(row.keywords) || "—" }}
</td>
<td
class="qt-col-muted qt-parse-cell-click"
:class="{
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
'qt-parse-gaps': unparsedCount(row) > 0 && row.parseStatus === '已解析',
}"
:title="
unparsedCount(row) > 0
? `有 ${unparsedCount(row)} 项未解析,点击查看`
: '点击查看已解析 / 未解析内容'
"
@click.stop="openParseDetail(row)"
>
<div class="qt-parse-status-row">
<span class="qt-parse-status-link">{{ row.parseStatus }}</span>
<span v-if="unparsedCount(row) > 0" class="qt-gap-badge">未解析 {{ unparsedCount(row) }}</span>
</div>
<div v-if="unparsedCount(row) > 0" class="qt-parse-gap-hint">有未解析内容,点击查看</div>
<div
v-else-if="
row.parseMessage &&
(row.parseStatus === '失败' ||
row.parseStatus === '映射未完成' ||
row.parseStatus === '解析中')
"
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>
<td class="qt-col-muted">{{ row.operator }}</td>
<td class="col-actions">
<div class="qt-row-actions">
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
预览
</button>
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
<button
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
:title="
row.parseStatus === '解析中'
? '解析中,请稍候'
: '重新解析:对齐的保留,对不上的改坐标;换文件请用编辑上传'
"
@click.stop="reparseRow(row)"
>
重新解析
</button>
<button
type="button"
class="link-action"
:class="row.status === 'enabled' ? 'link-danger' : ''"
@click.stop="toggleStatus(row)"
>
{{ row.status === "enabled" ? "禁用" : "启用" }}
</button>
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<span>
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
</div>
<!-- 上传 / 编辑:原生遮罩,挂到 body -->
<Teleport to="body">
<div
v-if="formOpen"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closeForm"
>
<div class="modal-dialog modal-lg" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--blue">
<ProtoIcon :name="editing ? 'pencil' : 'upload'" :size="16" />
</div>
<div>
<div class="qt-modal-title">{{ formTitle }}</div>
<div class="qt-modal-sub">{{ formSub }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closeForm">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-create-modal-body">
<div class="qt-create-form">
<div class="qt-create-row">
<label>模板名称</label>
<input v-model="form.name" class="field" type="text" placeholder="上传后自动填充,可修改" />
</div>
<div class="qt-create-row">
<label>业务</label>
<select v-model="form.bizType" class="field">
<option value="海运">海运</option>
<option value="空运">空运</option>
<option value="陆运">陆运</option>
</select>
</div>
<div class="qt-create-row qt-create-row--keywords">
<label>关键词</label>
<div class="qt-keyword-list">
<div v-for="(_, index) in form.keywordItems" :key="index" class="qt-keyword-row">
<input
v-model="form.keywordItems[index]"
class="field"
type="text"
placeholder="请输入关键词,可包含逗号"
/>
<button
type="button"
class="qt-keyword-btn"
title="添加"
:disabled="form.keywordItems.length >= 20"
@click="addKeyword"
>
<ProtoIcon name="plus" :size="14" />
</button>
<button
type="button"
class="qt-keyword-btn is-danger"
title="删除"
:disabled="form.keywordItems.length <= 1"
@click="removeKeyword(index)"
>
<ProtoIcon name="minus" :size="14" />
</button>
</div>
</div>
</div>
</div>
<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 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>
<div class="qt-upload-zone">
<input
ref="fileInputRef"
type="file"
class="qt-file-input"
accept=".xlsx,.xls,.pdf"
@change="onFileChange"
/>
<div
class="qt-upload-drop"
:class="{ 'has-file': !!form.fileName }"
@dragover.prevent
@drop="onDrop"
@click="triggerFilePick"
>
<template v-if="!form.fileName">
<div class="qt-upload-empty">
<div class="qt-upload-icon">
<ProtoIcon name="uploadCloud" :size="20" />
</div>
<div class="qt-upload-title">点击或拖拽上传模板文件</div>
<div class="qt-upload-hint">.xlsx · .xls · .pdf,不超过 20 MB</div>
</div>
</template>
<div v-else class="qt-upload-file-card" @click.stop>
<div class="qt-file-type-icon" :class="form.format === 'pdf' ? 'is-pdf' : 'is-excel'">
<ProtoIcon :name="form.format === 'pdf' ? 'fileText' : 'fileSpreadsheet'" :size="16" />
</div>
<div class="qt-file-meta">
<div class="qt-file-name">{{ form.fileName }}</div>
<div class="qt-file-sub">{{ form.fileMeta }}</div>
</div>
<button type="button" class="btn btn-ghost qt-replace-btn" @click="fileInputRef?.click()">
更换
</button>
</div>
</div>
</div>
<label class="qt-default-flag">
<input v-model="form.isDefault" type="checkbox" />
<span>默认模板</span>
</label>
</div>
<div class="qt-create-modal-footer">
<button type="button" class="btn btn-secondary" @click="closeForm">取消</button>
<button type="button" class="btn btn-primary" @click="submitForm">
<ProtoIcon name="check" :size="16" />{{ formOkText }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- 预览 -->
<Teleport to="body">
<div
v-if="previewOpen && previewRow"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closePreview"
>
<div class="modal-dialog qt-preview-dialog" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--green">
<ProtoIcon name="eye" :size="16" />
</div>
<div>
<div class="qt-modal-sub">上传原表 · 截图预览</div>
<div class="qt-modal-title">{{ previewRow.name }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closePreview">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(previewRow.bizType)">{{
previewState.fileName || previewRow.fileName
}}</span>
<span class="tag tag-green">已映射 {{ previewState.mappedCount }} 项</span>
</div>
<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="点击放大"
@load="onSnapshotImgLoad"
@click="openLightbox"
/>
<iframe
v-else
class="qt-snapshot-iframe"
:src="previewState.objectUrl"
title="原表 PDF 预览"
/>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
</div>
<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" />下载原模板
</button>
</div>
</div>
</div>
</Teleport>
<!-- 解析状态弹窗:已解析 / 未解析清单 -->
<Teleport to="body">
<div
v-if="parseDetailOpen && parseDetailRow"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closeParseDetail"
>
<div class="modal-dialog qt-parse-detail-dialog" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--green">
<ProtoIcon name="fileSpreadsheet" :size="16" />
</div>
<div>
<div class="qt-modal-sub">解析结果</div>
<div class="qt-modal-title">{{ parseDetailRow.name }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closeParseDetail">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(parseDetailRow.bizType)">{{ parseDetailRow.bizType }}</span>
<span class="tag tag-green">已解析 {{ parseDetailState.mappedCount }} 项</span>
<span v-if="parseDetailUnparsed.length" class="tag tag-amber"
>未解析 {{ parseDetailUnparsed.length }} 项</span
>
</div>
<div class="qt-parse-panels">
<div class="qt-parse-panel qt-parse-panel--ok">
<div class="qt-parse-panel-title">已解析内容</div>
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
<template
v-else-if="parseDetailState.mapping.fields.length || parseDetailState.mapping.regions.length"
>
<ul class="qt-parse-panel-list">
<li v-for="f in parseDetailState.mapping.fields" :key="'f-' + f.code">
<span class="qt-parse-name">{{ f.label }}</span>
<code class="qt-parse-code">{{ f.code }}</code>
<span class="qt-parse-cell">{{ f.cell }}</span>
</li>
<li v-for="r in parseDetailState.mapping.regions" :key="'r-' + r.kind + r.id">
<span class="qt-parse-name"
>{{ r.kind === "fee" ? "费用区" : "线路区" }} · {{ r.title }}</span
>
<code v-if="r.id" class="qt-parse-code">{{ r.id }}</code>
</li>
</ul>
</template>
<div v-else class="qt-parse-panel-empty">暂无已映射字段(解析中或未完成)</div>
</div>
<div
class="qt-parse-panel"
:class="parseDetailUnparsed.length ? 'qt-parse-panel--gap' : 'qt-parse-panel--ok'"
>
<div class="qt-parse-panel-title">未解析内容</div>
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
<ul v-else-if="parseDetailUnparsed.length" class="qt-parse-panel-list">
<li v-for="(g, i) in parseDetailUnparsed" :key="'g-' + i">{{ g }}</li>
</ul>
<div v-else class="qt-parse-panel-empty">
暂无未解析项。若刚升级功能,可点「重新解析」刷新缺口清单。
</div>
</div>
</div>
</div>
<div class="qt-preview-modal-footer">
<button type="button" class="btn btn-secondary" @click="closeParseDetail">关闭</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-parse-detail-dialog {
max-width: min(96vw, 720px) !important;
width: min(96vw, 720px);
}
.qt-parse-cell-click {
cursor: pointer;
}
.qt-parse-cell-click:hover .qt-parse-status-link {
color: #155eef;
text-decoration: underline;
}
.qt-parse-status-link {
font-weight: 700;
}
.qt-parse-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 0;
}
@media (max-width: 800px) {
.qt-parse-panels {
grid-template-columns: 1fr;
}
}
.qt-parse-panel {
border-radius: 8px;
border: 1px solid #e4e7ec;
background: #f9fafb;
padding: 10px 12px;
min-height: 88px;
max-height: min(50vh, 360px);
overflow: auto;
}
.qt-parse-panel--ok {
border-color: #abefc6;
background: #f0fdf4;
}
.qt-parse-panel--gap {
border-color: #fec84b;
background: #fffaeb;
}
.qt-parse-panel-title {
font-size: 12px;
font-weight: 800;
color: #344054;
margin-bottom: 6px;
}
.qt-parse-panel--gap .qt-parse-panel-title {
color: #b54708;
}
.qt-parse-panel-empty {
font-size: 11px;
color: #98a2b3;
line-height: 1.4;
}
.qt-parse-panel-list {
margin: 0;
padding-left: 16px;
font-size: 11px;
color: #344054;
line-height: 1.45;
}
.qt-parse-panel-list li {
margin-bottom: 4px;
}
.qt-parse-name {
font-weight: 700;
margin-right: 6px;
}
.qt-parse-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
color: #475467;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 3px;
padding: 0 4px;
margin-right: 6px;
}
.qt-parse-cell {
color: #667085;
font-size: 10px;
}
.tag-amber {
background: #fffaeb;
color: #b54708;
border: 1px solid #fec84b;
}
.qt-col-name {
font-weight: 800;
color: #0f172a;
white-space: nowrap;
font-size: 11px;
}
.qt-col-name .qt-default-tag {
margin-left: 6px;
vertical-align: middle;
}
.qt-col-muted {
font-size: 11px;
color: #334155;
}
.qt-parse-fail {
color: #b91c1c;
}
.qt-parse-gaps {
color: #b45309;
}
.qt-parse-status-row {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.qt-parse-gap-hint {
margin-top: 3px;
font-size: 10px;
font-weight: 700;
color: #b54708;
line-height: 1.3;
}
.qt-gap-badge {
display: inline-block;
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;
color: #334155;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qt-col-count {
font-size: 11px;
color: #1e293b;
white-space: nowrap;
}
.qt-col-time {
font-size: 11px;
color: #64748b;
white-space: nowrap;
}
.qt-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
position: relative;
z-index: 2;
pointer-events: auto;
}
.qt-paper-loading {
padding: 16px 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
.qt-paper-loading--err {
color: #b42318;
}
/**
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
*/
.qt-snapshot-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
max-height: min(72vh, 760px);
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
padding: 6px;
/* 不设 min-height,避免图矮时底下留灰白空带 */
min-height: 0;
}
.qt-snapshot-img {
display: block;
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
object-fit: contain !important;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
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,单行时标签与输入框垂直对齐 */
align-items: center;
}
.qt-create-row--keywords > label {
line-height: 34px;
/* 多行关键词时仍贴齐首行输入框中线,避免整块垂直居中飘在中间 */
align-self: start;
}
.qt-keyword-list {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
min-width: 0;
}
.qt-keyword-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 34px;
}
.qt-keyword-row .field {
flex: 1;
min-width: 0;
height: 34px;
min-height: 34px;
}
.qt-keyword-btn {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex-shrink: 0;
border: 1px solid #d0d5dd;
border-radius: 50%;
background: #fff;
color: #475467;
cursor: pointer;
padding: 0;
box-sizing: border-box;
}
.qt-keyword-btn:hover:not(:disabled) {
border-color: #98a2b3;
background: #f9fafb;
color: #101828;
}
.qt-keyword-btn.is-danger:hover:not(:disabled) {
border-color: #fda29b;
background: #fef3f2;
color: #b42318;
}
.qt-keyword-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qt-default-flag {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: #344054;
cursor: pointer;
user-select: none;
}
.qt-default-flag input {
width: 14px;
height: 14px;
margin: 0;
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>