补齐超时响应唯一 timeoutCode 与智能体按码查询,并增强报价模板预览与解析落库。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user