补齐超时响应唯一 timeoutCode 与智能体按码查询,并增强报价模板预览与解析落库。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-18 22:25:04 +08:00
co-authored by Cursor
parent 19f94252f0
commit 6cd6803147
15 changed files with 706 additions and 35 deletions
@@ -3,7 +3,8 @@
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图),不是 AI 画格子
* - 预览:原表截图(LibreOffice 转图)
* - 解析状态列可点:弹窗看已解析 / 未解析清单
*/
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
@@ -17,9 +18,15 @@ import {
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 下载)
@@ -45,7 +52,17 @@ 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: "",
@@ -54,6 +71,34 @@ const previewState = reactive({
mappedCount: 0,
fileName: "",
});
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
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;
const list: string[] = [];
for (const g of m.gaps || []) {
if (g) list.push(g);
}
if (!list.length) {
for (const c of m.unmappedRequired || []) {
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
}
for (const lab of m.unmappedLabels || []) {
if (lab) list.push(`表标签未落点:${lab}`);
}
}
return list;
});
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
@@ -98,7 +143,7 @@ function revokePreviewUrl() {
}
/**
* 打开预览:读取上传后已缓存的截图(未就绪则现转)。
* 打开预览:仅原表截图。
* 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。
*/
async function openPreview(row: TemplateRow) {
@@ -132,6 +177,57 @@ async function openPreview(row: TemplateRow) {
}
}
/**
* 列表点「解析状态」:弹窗展示已解析 / 未解析清单。
* 副作用: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;
}
@@ -281,13 +377,23 @@ function openEdit(row: TemplateRow) {
formOpen.value = true;
}
/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */
/** 运营可见的未解析项:优先 mappingGaps,其次标签/必填未映射 */
function listMappingGaps(row: TemplateRow): string[] {
if (row.mappingGaps?.length) return [...row.mappingGaps];
if (row.unmappedRequired?.length) {
return row.unmappedRequired.map((c) => `字段未映射:${c}`);
const list: string[] = [];
for (const lab of row.unmappedLabels || []) {
if (lab) list.push(`表标签未落点:${lab}`);
}
return [];
for (const c of row.unmappedRequired || []) {
if (c) list.push(`字段未映射:${c}`);
}
return list;
}
/** 列表角标条数 */
function unparsedCount(row: TemplateRow): number {
if (row.unparsedHintCount && row.unparsedHintCount > 0) return row.unparsedHintCount;
return listMappingGaps(row).length;
}
function normalizeKeywordItems(items: string[]): string[] {
@@ -543,24 +649,29 @@ async function downloadPreviewOrOriginal() {
{{ formatKeywords(row.keywords) || "—" }}
</td>
<td
class="qt-col-muted"
class="qt-col-muted qt-parse-cell-click"
:class="{
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
'qt-parse-gaps': unparsedCount(row) > 0 && row.parseStatus === '已解析',
}"
:title="row.parseMessage || row.parseStatus"
:title="
unparsedCount(row) > 0
? `有 ${unparsedCount(row)} 项未解析,点击查看`
: '点击查看已解析 / 未解析内容'
"
@click.stop="openParseDetail(row)"
>
<span>{{ row.parseStatus }}</span>
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
未解析 {{ listMappingGaps(row).length }}
</span>
<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-if="
v-else-if="
row.parseMessage &&
(row.parseStatus === '失败' ||
row.parseStatus === '映射未完成' ||
row.parseStatus === '解析中' ||
listMappingGaps(row).length > 0)
row.parseStatus === '解析中')
"
class="qt-parse-msg"
>
@@ -830,6 +941,86 @@ async function downloadPreviewOrOriginal() {
</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
@@ -855,6 +1046,95 @@ async function downloadPreviewOrOriginal() {
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;
@@ -875,9 +1155,21 @@ async function downloadPreviewOrOriginal() {
.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;
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;