1635 lines
48 KiB
Vue
1635 lines
48 KiB
Vue
<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>
|