落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 14:09:45 +08:00
co-authored by Cursor
parent 8bdf4990a2
commit a70bce3c8f
138 changed files with 7239 additions and 1085 deletions
@@ -1,20 +1,35 @@
<script setup lang="ts">
/**
* 报价模板页:对齐原型 admin-quote-templates.html。
* - 上传按钮只在顶栏一处(禁止双按钮)
* - 弹窗用原生 overlay(不依赖 a-modal,避免遮罩残留导致整页点不了)
* - 操作:预览 / 编辑 / 启用|禁用
* - 本阶段仅 mock,暂不对接主账上传/启用接口(页面与侧栏必须保留)
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:弹窗内按映射回填合同样例事实(接近原型可读性);权威版式仍以下载 Excel 为准
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
import {
ALL_CATEGORIES,
CATEGORY_BY_BIZ,
mockTemplates,
mapApiRow,
type TemplateRow,
} from "/@/views/inquiry/mock/templates";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import {
disableTemplate,
downloadTemplateFile,
editTemplateMeta,
enableTemplate,
getTemplateList,
getTemplateVersion,
uploadTemplate,
} from "/@/views/inquiry/template/template.api";
import {
buildPreviewViewModel,
emptyPreviewViewModel,
type PreviewViewModel,
} from "/@/views/inquiry/template/previewSample";
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
/** 清掉可能残留的 Ant Modal 遮罩(会导致整页点不了) */
function clearOrphanAntMasks() {
@@ -28,11 +43,8 @@ function clearOrphanAntMasks() {
document.body.classList.remove("ant-scrolling-effect");
}
onMounted(() => {
clearOrphanAntMasks();
});
const rows = ref<TemplateRow[]>([...mockTemplates]);
const rows = ref<TemplateRow[]>([]);
const loading = ref(false);
const keyword = ref("");
const bizFilter = ref("");
const categoryFilter = ref("");
@@ -42,12 +54,16 @@ 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 fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
const form = reactive({
name: "",
bizType: "海运" as TemplateRow["bizType"],
/** 多行关键词;每行可含逗号,加减行数(对齐旧仓 keywordItems) */
category: "",
mode: "auto" as "auto" | "manual",
keywordItems: [""] as string[],
isDefault: false,
fileName: "",
@@ -64,30 +80,29 @@ const parseState = reactive({
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
const formSub = computed(() =>
editing.value ? "修改基本信息或更换模板文件后重新解析" : "上传 Excel / PDF,系统自动解析占位符",
editing.value
? "可改基本信息;更换文件将追加新版本并重新解析映射"
: "上传 Excel,系统按黄区与标签自动建议单元格映射",
);
const formOkText = computed(() => (editing.value ? "保存修改" : "发布模板"));
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint =
"用户端生成时将复制模板文件并替换 {{占位符}},不改变原有 Excel / PDF 样式";
"用户端生成时将复制本版 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}`;
});
/** 表单不再手选分类:按业务取默认分类写入数据(列表筛选仍可用) */
function defaultCategoryForBiz(biz: TemplateRow["bizType"]) {
return (CATEGORY_BY_BIZ[biz] || [])[0] || "";
}
const filtered = computed(() =>
rows.value.filter((r) => {
const kw = keyword.value.trim().toLowerCase();
if (kw && !`${r.name}${r.bizType}${r.fileLabel}${r.category}${r.keywords.join(" ")}`.toLowerCase().includes(kw)) {
return false;
}
if (bizFilter.value && r.bizType !== bizFilter.value) return false;
if (categoryFilter.value && r.category !== categoryFilter.value) return false;
if (statusFilter.value === "enabled" && r.status !== "enabled") return false;
if (statusFilter.value === "disabled" && r.status !== "disabled") return false;
return true;
}),
);
const filtered = computed(() => rows.value);
function bizTag(biz: string) {
if (biz === "海运") return "tag-teal";
@@ -95,16 +110,44 @@ function bizTag(biz: string) {
return "tag-blue";
}
async function loadList() {
loading.value = true;
try {
const res: any = await getTemplateList({
keyword: keyword.value.trim() || undefined,
bizType: bizFilter.value || undefined,
category: categoryFilter.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 (e: any) {
message.error(e?.message || "加载模板列表失败");
} finally {
loading.value = false;
}
}
onMounted(() => {
clearOrphanAntMasks();
loadList();
});
function resetForm() {
Object.assign(form, {
name: "",
bizType: "海运" as TemplateRow["bizType"],
category: defaultCategoryForBiz("海运"),
mode: "auto" as const,
keywordItems: [""],
isDefault: false,
fileName: "",
fileMeta: "",
format: "excel" as const,
});
pendingFile.value = null;
parseState.visible = false;
parseState.ok = true;
parseState.title = "";
@@ -122,22 +165,25 @@ function openEdit(row: TemplateRow) {
Object.assign(form, {
name: row.name,
bizType: row.bizType,
category: row.category || defaultCategoryForBiz(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 === "已解析";
parseState.title = parseState.ok ? "解析成功" : "解析失败";
parseState.desc = parseState.ok
? `识别 ${row.placeholderCount} 个占位符,已映射至工单字段`
: "请更换模板文件后重试";
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成";
parseState.title = row.parseStatus || "解析状态";
parseState.desc =
row.parseMessage ||
`已识别 ${row.mappingPointCount} 个映射点` +
(row.unmappedRequired?.length ? `;未映射:${row.unmappedRequired.join(",")}` : "");
formOpen.value = true;
}
/** 去空去重,保留框内逗号;至少一条才算有效 */
function normalizeKeywordItems(items: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
@@ -150,32 +196,49 @@ function normalizeKeywordItems(items: string[]): string[] {
return unique;
}
/** 加一行关键词,最多 20 行,避免误点刷屏 */
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);
}
/**
* 同业务只保留一个默认模板:勾选本条时清掉同业务其它默认。
* 演示环境本地互斥;接主账后由接口保证。
* 打开预览:先展示列表摘要,再拉 version.mappingJson 组装样例回填一览。
* 副作用:HTTP 读版本详情;不写库、不调 LLM。
*/
function applyDefaultExclusive(targetId: string, bizType: TemplateRow["bizType"], isDefault: boolean) {
if (!isDefault) return;
for (const row of rows.value) {
if (row.bizType === bizType && row.id !== targetId) row.isDefault = false;
}
}
function openPreview(row: TemplateRow) {
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() {
@@ -184,11 +247,26 @@ function closeForm() {
function closePreview() {
previewOpen.value = false;
previewVm.value = emptyPreviewViewModel();
}
function toggleStatus(row: TemplateRow) {
row.status = row.status === "enabled" ? "disabled" : "enabled";
message.success(row.status === "enabled" ? "已启用" : "已禁用");
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 || "启停失败(自动模板须完成必填映射)");
}
}
function formatFileSize(bytes: number) {
@@ -203,14 +281,14 @@ function applySelectedFile(file: File) {
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, "");
}
const total = file.name.includes("拼柜") ? 12 : isPdf ? 9 : 14;
parseState.visible = true;
parseState.ok = true;
parseState.title = "解析成功";
parseState.desc = `识别 ${total} 个占位符,${total - 2} 个已自动映射至工单字段`;
parseState.title = "待上传解析";
parseState.desc = "点击保存后由主账解析黄区并自动建议映射点";
}
function onFileChange(e: Event) {
@@ -227,71 +305,76 @@ function onDrop(e: DragEvent) {
}
function triggerFilePick() {
if (!form.fileName) fileInputRef.value?.click();
fileInputRef.value?.click();
}
function submitForm() {
if (!form.fileName) {
message.warning("请先上传模板文件");
return;
}
if (!parseState.ok || !parseState.visible) {
message.warning("模板解析失败,请更换文件后重试");
return;
}
async function submitForm() {
const keywords = normalizeKeywordItems(form.keywordItems);
if (!form.name.trim()) {
message.warning("请填写模板名称");
return;
}
const keywords = normalizeKeywordItems(form.keywordItems);
if (!keywords.length) {
message.warning("请至少填写一个关键词");
return;
}
const placeholders = form.fileName.includes("拼柜") ? 12 : form.format === "pdf" ? 9 : 14;
const category = editing.value?.category || defaultCategoryForBiz(form.bizType);
if (editing.value) {
Object.assign(editing.value, {
name: form.name.trim(),
bizType: form.bizType,
category,
keywords,
isDefault: form.isDefault,
fileLabel: form.name.trim(),
fileName: form.fileName,
format: form.format,
parseStatus: "已解析" as const,
placeholderCount: placeholders,
});
applyDefaultExclusive(editing.value.id, form.bizType, form.isDefault);
message.success("模板已更新");
} else {
const id = `t${Date.now()}`;
rows.value.unshift({
id,
name: form.name.trim(),
bizType: form.bizType,
category,
keywords,
isDefault: form.isDefault,
fileLabel: form.name.trim(),
fileName: form.fileName,
format: form.format,
parseStatus: "已解析",
placeholderCount: placeholders,
status: "enabled",
useCount: 0,
createdAt: new Date().toISOString().slice(0, 10),
operator: "当前用户",
});
applyDefaultExclusive(id, form.bizType, form.isDefault);
message.success("模板已发布");
// 分类/模式不对运营展示:分类按业务默认,模式新建默认 auto,编辑保留原值
const category = defaultCategoryForBiz(form.bizType);
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("category", category);
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);
message.success(row?.parseMessage || "已上传并解析");
} else if (editing.value) {
await editTemplateMeta({
id: editing.value.id,
name: form.name.trim(),
bizType: form.bizType,
category,
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 || "保存失败");
}
formOpen.value = false;
}
function downloadDemo() {
message.success("已开始下载");
async function downloadPreviewOrOriginal() {
const row = previewRow.value;
if (!row?.versionId) {
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.success({ content: "已开始下载", key: "tpl-dl" });
} catch (e: any) {
message.error({ content: e?.message || "下载预览文件失败", key: "tpl-dl" });
}
}
</script>
@@ -307,19 +390,19 @@ function downloadDemo() {
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="keyword" placeholder="搜索模板名称…" />
<input v-model="keyword" placeholder="搜索模板名称…" @keyup.enter="loadList" />
</div>
<select v-model="bizFilter">
<select v-model="bizFilter" @change="loadList">
<option value="">全部业务</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
</select>
<select v-model="categoryFilter">
<select v-model="categoryFilter" @change="loadList">
<option value="">全部分类</option>
<option v-for="c in ALL_CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="statusFilter">
<select v-model="statusFilter" @change="loadList">
<option value="">全部状态</option>
<option value="enabled">启用</option>
<option value="disabled">禁用</option>
@@ -553,7 +636,9 @@ function downloadDemo() {
<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">已解析 · {{ previewRow.placeholderCount }} 个占位符</span>
<span class="tag tag-green"
>已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点</span
>
</div>
<div class="qt-paper">
<div class="qt-paper-head">
@@ -561,63 +646,61 @@ function downloadDemo() {
<div class="qt-paper-title">
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
</div>
<div class="qt-paper-cap">模板版式 · 由上传 Excel 决定</div>
<div class="qt-paper-cap">模板款式 · 由上传 Excel 决定(黄区固定)</div>
</div>
<div class="qt-paper-meta">
<div>QT-202607020001</div>
<div>2026-07-02</div>
<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 class="qt-paper-grid">
<div>
<span class="qt-paper-label">客户</span>
<span class="qt-paper-value">ABC Trading Co.</span>
</div>
<div>
<span class="qt-paper-label">箱型</span>
<span class="qt-paper-value">40HQ × 2</span>
</div>
<div>
<span class="qt-paper-label">起运港 → 目的港</span>
<span class="qt-paper-value">CNSHA → USLAX</span>
</div>
<div>
<span class="qt-paper-label">销售</span>
<span class="qt-paper-value">张敏</span>
</div>
<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>
<div class="qt-paper-total">
<div class="qt-paper-total-label">报价合计</div>
<div class="qt-paper-amt">USD 3,680.00</div>
<div class="qt-paper-total-hint">含海运费 + 本地费 · 有效期至 2026-07-05</div>
</div>
<table class="qt-paper-fee">
<thead>
<tr>
<th>费用项</th>
<th style="text-align: right">金额</th>
</tr>
</thead>
<tbody>
<tr>
<td>O/F 海运费</td>
<td style="text-align: right">2,900</td>
</tr>
<tr>
<td>THC + DOC + SEAL</td>
<td style="text-align: right">780</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>合计</td>
<td style="text-align: right">3,680</td>
</tr>
</tfoot>
</table>
<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、签章与业务上传模板一致;上述为预览回填数据。
* 版式、Logo、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。
</p>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
@@ -625,8 +708,8 @@ function downloadDemo() {
<div class="qt-preview-modal-footer">
<button type="button" class="btn btn-secondary" @click="closePreview">关闭</button>
<button type="button" class="btn btn-primary" @click="downloadDemo">
<ProtoIcon name="download" :size="16" />下载文件
<button type="button" class="btn btn-primary" @click="downloadPreviewOrOriginal">
<ProtoIcon name="download" :size="16" />下载预览文件
</button>
</div>
</div>
@@ -675,6 +758,16 @@ function downloadDemo() {
pointer-events: auto;
}
.qt-paper-loading {
padding: 16px 0;
font-size: 12px;
font-weight: 700;
color: #64748b;
}
.qt-paper-loading--err {
color: #b42318;
}
.qt-create-row--keywords {
/* 与名称/业务同用 center,单行时标签与输入框垂直对齐 */
align-items: center;