落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-18 17:42:46 +08:00
co-authored by Cursor
parent 982b3a2317
commit 527050bbf9
41 changed files with 4965 additions and 426 deletions
@@ -14,7 +14,7 @@
import '/@/views/inquiry/styles/admin-fidelity.css';
import '/@/views/inquiry/styles/dashboard-prototype.css';
type NavIcon = 'dashboard' | 'tickets' | 'staff' | 'template' | 'botReply' | 'account' | 'role';
type NavIcon = 'dashboard' | 'tickets' | 'staff' | 'template' | 'botReply' | 'account' | 'role' | 'clock';
type NavItem = {
key: string;
@@ -49,6 +49,7 @@
{
title: '系统配置',
items: [
{ key: '/inquiry-sys/system-setting', label: '系统设置', icon: 'clock' },
{ key: '/inquiry-sys/user', label: '账号管理', icon: 'account' },
{ key: '/inquiry-sys/role', label: '角色管理', icon: 'role' },
],
@@ -24,6 +24,121 @@ import { getPermCode } from '/@/api/sys/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum';
/**
* 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。
* 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。
* 这里按侧栏清单补挂手写页,不依赖种子是否已导入。
*/
type InquiryLeaf = {
path: string;
name: string;
component: string;
title: string;
};
const INQUIRY_LEAVES: InquiryLeaf[] = [
{ path: '/inquiry/dashboard', name: 'inquiry-dashboard', component: 'inquiry/dashboard/index', title: '分析看板' },
{ path: '/inquiry/work-order', name: 'inquiry-work-order', component: 'inquiry/workOrder/index', title: '询价工单' },
{ path: '/inquiry/staff-role', name: 'inquiry-staff-role', component: 'inquiry/staffRole/index', title: '员工管理' },
{ path: '/inquiry/template', name: 'inquiry-template', component: 'inquiry/template/index', title: '报价模板' },
{ path: '/inquiry/bot-reply', name: 'inquiry-bot-reply', component: 'inquiry/botReply/index', title: '机器人回复语' },
{
path: '/inquiry-sys/system-setting',
name: 'inquiry-system-setting',
component: 'inquiry/systemSetting/index',
title: '系统设置',
},
{ path: '/inquiry-sys/user', name: 'inquiry-sys-user', component: 'inquiry/account/index', title: '账号管理' },
{ path: '/inquiry-sys/role', name: 'inquiry-sys-role', component: 'inquiry/role/index', title: '角色管理' },
];
function findRouteByPath(list: AppRouteRecordRaw[] = [], targetPath: string): AppRouteRecordRaw | null {
for (const item of list) {
const p = item.path || '';
if (p === targetPath || p === targetPath.replace(/^\//, '') || p.endsWith(targetPath)) {
return item;
}
const nested = findRouteByPath(item.children as AppRouteRecordRaw[] | undefined, targetPath);
if (nested) {
return nested;
}
}
return null;
}
function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRouteRecordRaw | null {
for (const item of list) {
const p = item.path || '';
if (p === parentPath || p.endsWith(parentPath)) {
return item;
}
const nested = findParent(item.children as AppRouteRecordRaw[] | undefined, parentPath);
if (nested) {
return nested;
}
}
return null;
}
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
const groups: Record<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
'/inquiry': {
title: '询价',
redirect: '/inquiry/dashboard',
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
},
'/inquiry-sys': {
title: '询价系统配置',
redirect: '/inquiry-sys/system-setting',
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
},
};
for (const [parentPath, group] of Object.entries(groups)) {
for (const leaf of group.leaves) {
const existing = findRouteByPath(routeList, leaf.path);
if (existing) {
// 菜单有路径但组件指错(常见:空、旧 dashboard/Analysis)→ 纠正为手写页
const comp = String((existing as any).component || '');
if (!comp.includes(leaf.component) && !comp.includes(leaf.component.replace(/\/index$/, ''))) {
(existing as any).component = leaf.component;
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
}
continue;
}
const node: AppRouteRecordRaw = {
path: leaf.path,
name: leaf.name,
component: leaf.component as any,
meta: {
title: leaf.title,
ignoreKeepAlive: true,
},
};
let parent = findParent(routeList, parentPath);
if (!parent) {
parent = {
path: parentPath,
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
component: 'LAYOUT' as any,
redirect: group.redirect,
meta: { title: group.title },
children: [],
};
routeList.push(parent);
}
parent.children = parent.children || [];
if (leaf.path === group.redirect) {
parent.children.unshift(node);
} else {
parent.children.push(node);
}
}
}
return routeList;
}
// 系统权限
interface AuthItem {
// 菜单权限编码,例如:“sys:schedule:list,sys:schedule:info”,多个逗号隔开
@@ -219,6 +334,7 @@ export const usePermissionStore = defineStore({
try {
this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
routeList = ensureInquiryHandwrittenRoutes(routeList);
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
let hasIndex: boolean = false;
let hasIcon: boolean = false;
+21 -1
View File
@@ -4,7 +4,7 @@ import type { CreateAxiosOptions } from './axiosTransform';
import axios from 'axios';
import qs from 'qs';
import { AxiosCanceler } from './axiosCancel';
import { isFunction } from '/@/utils/is';
import { isFormData, isFunction } from '/@/utils/is';
import { cloneDeep } from 'lodash-es';
import { ContentTypeEnum } from '/@/enums/httpEnum';
import { RequestEnum } from '/@/enums/httpEnum';
@@ -201,7 +201,23 @@ export class VAxios {
}
request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
// lodash cloneDeep 会把 FormData 变成空对象,Tomcat 随后报 no multipart boundary。
// 文件字段必须按原引用发出,浏览器才能自动补 boundary。
const rawData = config.data;
const rawParams = config.params;
let conf: CreateAxiosOptions = cloneDeep(config);
if (isFormData(rawData)) {
conf.data = rawData;
}
if (isFormData(rawParams)) {
conf.params = rawParams;
}
// 默认头是 application/json;手写 multipart/form-data 又不带 boundary,Tomcat 会拒。
// 删掉 Content-Type,让浏览器给 FormData 自动加 boundary。
if (isFormData(conf.data) && conf.headers) {
delete (conf.headers as Record<string, unknown>)['Content-Type'];
delete (conf.headers as Record<string, unknown>)['content-type'];
}
const transform = this.getTransform();
const { requestOptions } = this.options;
@@ -215,6 +231,10 @@ export class VAxios {
conf.requestOptions = opt;
conf = this.supportFormData(conf);
if (isFormData(conf.data) && conf.headers) {
delete (conf.headers as Record<string, unknown>)['Content-Type'];
delete (conf.headers as Record<string, unknown>)['content-type'];
}
return new Promise((resolve, reject) => {
this.axiosInstance
@@ -10,7 +10,7 @@ import { router } from '/@/router';
import { useGlobSetting } from '/@/hooks/setting';
import { useMessage } from '/@/hooks/web/useMessage';
import { RequestEnum, ResultEnum, ContentTypeEnum, ConfigEnum } from '/@/enums/httpEnum';
import { isString } from '/@/utils/is';
import { isFormData, isString } from '/@/utils/is';
import { getToken, getTenantId } from '/@/utils/auth';
import { setObjToUrlParams, deepMerge } from '/@/utils';
import signMd5Utils from '/@/utils/encryption/signMd5Utils';
@@ -100,6 +100,14 @@ const transform: AxiosTransform = {
}
const params = config.params || {};
const data = config.data || false;
// FormData 没有可枚举 key;Object.keys 会当成空对象,把文件体换成 params,上传必失败。
if (isFormData(data) || isFormData(params)) {
config.data = isFormData(data) ? data : params;
if (isFormData(params)) {
config.params = undefined;
}
return config;
}
formatDate && data && !isString(data) && formatRequestDate(data);
if (config.method?.toUpperCase() === RequestEnum.GET) {
if (!isString(params)) {
+8
View File
@@ -97,6 +97,14 @@ export function isMap(val: unknown): val is Map<any, any> {
return is(val, 'Map');
}
/**
* 是否为浏览器 FormData。
* lodash cloneDeep / Object.keys 都拿不到表单字段,上传前必须按引用原样发送。
*/
export function isFormData(val: unknown): val is FormData {
return typeof FormData !== 'undefined' && val instanceof FormData;
}
export const isServer = typeof window === 'undefined';
export const isClient = !isServer;
@@ -28,6 +28,8 @@ export type TemplateRow = {
currentVersion?: number;
parseMessage?: string;
unmappedRequired?: string[];
/** 表上有、对照清单未映射上的中文说明(智能体 mappingGaps) */
mappingGaps?: string[];
objectKey?: string;
};
@@ -58,7 +60,8 @@ export function mapApiRow(raw: any): TemplateRow {
versionId: raw.versionId || undefined,
currentVersion: raw.currentVersion,
parseMessage: raw.parseMessage,
unmappedRequired: raw.unmappedRequired,
unmappedRequired: Array.isArray(raw.unmappedRequired) ? raw.unmappedRequired.map(String) : [],
mappingGaps: Array.isArray(raw.mappingGaps) ? raw.mappingGaps.map(String) : [],
objectKey: raw.objectKey,
};
}
@@ -1495,7 +1495,8 @@ textarea:focus-visible {
}
.modal-dialog.qt-preview-dialog {
max-width: 560px;
max-width: min(96vw, 980px);
width: min(96vw, 980px);
}
.modal-dialog.account-modal-dialog {
@@ -1887,6 +1888,11 @@ textarea:focus-visible {
background: #fff1f2;
}
.qt-parse-banner.is-warn {
border-color: #fec84b;
background: #fffaeb;
}
.qt-parse-banner-icon {
width: 32px;
height: 32px;
@@ -1906,6 +1912,11 @@ textarea:focus-visible {
color: #be123c;
}
.qt-parse-banner-icon.is-warn {
background: #fef0c7;
color: #b54708;
}
.qt-parse-banner-title {
font-size: 13px;
font-weight: 800;
@@ -1916,6 +1927,10 @@ textarea:focus-visible {
color: #9f1239;
}
.qt-parse-banner.is-warn .qt-parse-banner-title {
color: #b54708;
}
.qt-parse-banner-desc {
margin-top: 2px;
font-size: 11px;
@@ -1927,6 +1942,10 @@ textarea:focus-visible {
color: #be123c;
}
.qt-parse-banner.is-warn .qt-parse-banner-desc {
color: #93370d;
}
.qt-upload-zone {
display: grid;
gap: 16px;
@@ -0,0 +1,205 @@
<script setup>
/**
* 系统设置:响应超时小时配置。
* 海运 / 空运 / 陆运同页分三块展示,只允许改超时小时;无增删、无 Tab。
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
*/
import { computed, onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
getTimeoutListAll,
saveTimeoutHours,
} from '/@/views/inquiry/systemSetting/systemSetting.api';
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
const MODE_BLOCKS = [
{ code: 'SEA', title: '海运' },
{ code: 'AIR', title: '空运' },
{ code: 'LAND', title: '陆运' },
];
const ROLE_LABEL = {
sales: '销售',
product: '产品',
air_route: '航线',
};
const rows = ref([]);
const loading = ref(false);
const savingId = ref('');
/** 编辑中的草稿小时数(按 id),失焦或回车时提交 */
const draftHours = ref({});
/** 按运输方式分组,块内按 sort_no 排 */
const blocks = computed(() =>
MODE_BLOCKS.map((b) => ({
...b,
rows: rows.value
.filter((r) => r.transportMode === b.code)
.sort((a, c) => (a.sortNo || 0) - (c.sortNo || 0)),
})),
);
function roleLabel(code) {
return ROLE_LABEL[code] || code;
}
function displayOrDash(v) {
const s = (v || '').trim();
return s || '—';
}
async function loadList() {
loading.value = true;
try {
const res = await getTimeoutListAll();
const list = Array.isArray(res) ? res : res?.result || res?.records || [];
rows.value = list.map((r) => ({
...r,
timeoutHours: Number(r.timeoutHours ?? 0),
}));
const next = {};
for (const r of rows.value) {
next[r.id] = r.timeoutHours;
}
draftHours.value = next;
} catch (e) {
message.error(e?.message || '加载超时配置失败');
} finally {
loading.value = false;
}
}
onMounted(() => loadList());
function onHoursInput(id, raw) {
const n = Math.floor(Number(raw));
draftHours.value[id] = Number.isFinite(n) && n >= 0 ? n : 0;
}
async function commitHours(row) {
const hours = draftHours.value[row.id];
if (hours == null || !Number.isFinite(hours) || hours < 0) {
message.warning('超时小时须为 ≥0 的整数');
draftHours.value[row.id] = row.timeoutHours;
return;
}
if (hours === row.timeoutHours) {
return;
}
savingId.value = row.id;
try {
await saveTimeoutHours(row.id, hours);
row.timeoutHours = hours;
message.success('已保存');
} catch (e) {
message.error(e?.message || '保存失败');
draftHours.value[row.id] = row.timeoutHours;
} finally {
savingId.value = '';
}
}
</script>
<template>
<div class="sys-setting-page">
<div class="toolbar-bar">
<span class="hint">固定规则,仅可改超时小时;失焦或回车保存</span>
</div>
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
<template v-else>
<section v-for="block in blocks" :key="block.code" class="section-card mode-block">
<div class="section-card-header">
<div class="section-card-title">{{ block.title }}</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th style="width: 88px">角色</th>
<th>运输类型</th>
<th style="width: 140px">超时设置(小时)</th>
</tr>
</thead>
<tbody>
<tr v-if="!block.rows.length">
<td colspan="3" style="color: #94a3b8">暂无配置</td>
</tr>
<tr v-for="row in block.rows" :key="row.id">
<td>{{ roleLabel(row.roleCode) }}</td>
<td>{{ displayOrDash(row.transportType) }}</td>
<td>
<div class="hours-cell">
<input
class="field hours-input"
type="number"
min="0"
step="1"
:value="draftHours[row.id]"
:disabled="savingId === row.id"
@input="onHoursInput(row.id, $event.target.value)"
@blur="commitHours(row)"
@keydown.enter.prevent="commitHours(row)"
/>
<span class="hours-unit">h</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.sys-setting-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar-bar {
display: flex;
justify-content: flex-end;
align-items: center;
}
.hint {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.mode-block {
margin: 0;
}
.mode-block .table-wrap {
border: none;
border-radius: 0;
box-shadow: none;
}
.hours-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.hours-input {
width: 88px;
min-height: 32px;
padding: 4px 8px;
}
.hours-unit {
font-size: 11px;
color: #667085;
font-weight: 600;
}
</style>
@@ -0,0 +1,26 @@
/**
* 系统设置 · 响应超时配置 API。
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
*/
import { defHttp } from '/@/utils/http/axios';
enum Api {
listAll = '/inquiry/responseTimeout/listAll',
edit = '/inquiry/responseTimeout/edit',
}
export type ResponseTimeoutRow = {
id: string;
transportMode: string;
roleCode: string;
routeCategory?: string;
transportType?: string;
timeoutHours: number;
sortNo?: number;
};
export const getTimeoutListAll = () => defHttp.get({ url: Api.listAll });
/** 仅提交 id + timeoutHours,维度字段不传以免误改。 */
export const saveTimeoutHours = (id: string, timeoutHours: number) =>
defHttp.put({ url: Api.edit, data: { id, timeoutHours } });
@@ -3,26 +3,23 @@
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:弹窗内按映射回填合同样例事实(接近原型可读性);权威版式仍以下载 Excel 为准
* - 预览:原表截图(LibreOffice 转图),不是 AI 画格子
*/
import { computed, onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
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 {
buildPreviewViewModel,
emptyPreviewViewModel,
type PreviewViewModel,
} from "/@/views/inquiry/template/previewSample";
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
@@ -48,8 +45,15 @@ 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 previewState = reactive({
loading: false,
error: "",
objectUrl: "",
isImage: true,
mappedCount: 0,
fileName: "",
});
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
@@ -70,30 +74,85 @@ const parseState = reactive({
ok: true,
title: "",
desc: "",
/** 表上有、未映射上的中文说明 */
gaps: [] as string[],
});
const formTitle = computed(() => (editing.value ? "编辑报价模板" : "上传报价模板"));
const formSub = computed(() =>
editing.value
? "可改基本信息;更换文件将追加新版本并重新解析映射"
: "上传 Excel,系统按黄区与标签自动建议单元格映射",
? "可改基本信息;更换文件将追加新版本并由 AI 异步解析映射"
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
);
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint =
"用户端生成时将复制本版 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}`;
});
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
const previewLightbox = ref(false);
const filtered = computed(() => rows.value);
function revokePreviewUrl() {
if (previewState.objectUrl) {
URL.revokeObjectURL(previewState.objectUrl);
previewState.objectUrl = "";
}
}
/**
* 打开预览:读取上传后已缓存的截图(未就绪则现转)。
* 副作用: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.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 || "原表截图尚未就绪,请稍后重试或下载原模板";
}
}
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";
@@ -118,6 +177,7 @@ async function loadList() {
});
const records = res?.records || res?.result?.records || [];
rows.value = (records as any[]).map(mapApiRow);
scheduleParsePoll();
} catch (e: any) {
message.error(e?.message || "加载模板列表失败");
} finally {
@@ -125,11 +185,50 @@ async function loadList() {
}
}
/** 列表里仍有「解析中」时每 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: "",
@@ -146,6 +245,7 @@ function resetForm() {
parseState.ok = true;
parseState.title = "";
parseState.desc = "";
parseState.gaps = [];
}
function openCreate() {
@@ -168,15 +268,28 @@ function openEdit(row: TemplateRow) {
});
pendingFile.value = null;
parseState.visible = true;
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成";
parseState.ok = row.parseStatus === "已解析" || row.parseStatus === "映射未完成" || row.parseStatus === "解析中";
parseState.title = row.parseStatus || "解析状态";
parseState.gaps = listMappingGaps(row);
parseState.desc =
row.parseMessage ||
`已识别 ${row.mappingPointCount} 个映射点` +
(row.unmappedRequired?.length ? `;未映射:${row.unmappedRequired.join(",")}` : "");
(parseState.gaps.length ? `;未解析到 ${parseState.gaps.length} 项` : "") +
(row.unmappedRequired?.length && !parseState.gaps.length
? `;未映射:${row.unmappedRequired.join(",")}`
: "");
formOpen.value = true;
}
/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */
function listMappingGaps(row: TemplateRow): string[] {
if (row.mappingGaps?.length) return [...row.mappingGaps];
if (row.unmappedRequired?.length) {
return row.unmappedRequired.map((c) => `字段未映射:${c}`);
}
return [];
}
function normalizeKeywordItems(items: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
@@ -199,50 +312,6 @@ function removeKeyword(index: number) {
form.keywordItems.splice(index, 1);
}
/**
* 打开预览:先展示列表摘要,再拉 version.mappingJson 组装样例回填一览。
* 副作用:HTTP 读版本详情;不写库、不调 LLM。
*/
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() {
formOpen.value = false;
}
function closePreview() {
previewOpen.value = false;
previewVm.value = emptyPreviewViewModel();
}
async function toggleStatus(row: TemplateRow) {
try {
if (row.status === "enabled") {
@@ -262,6 +331,56 @@ async function toggleStatus(row: TemplateRow) {
}
}
/**
* 重新提交 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`;
@@ -281,7 +400,8 @@ function applySelectedFile(file: File) {
parseState.visible = true;
parseState.ok = true;
parseState.title = "待上传解析";
parseState.desc = "点击保存后由主账解析黄区并自动建议映射点";
parseState.desc = "点击保存后提交 AI 异步解析;列表会显示解析中/成功/失败原因与未解析项";
parseState.gaps = [];
}
function onFileChange(e: Event) {
@@ -325,7 +445,12 @@ async function submitForm() {
fd.append("isDefault", String(form.isDefault));
fd.append("file", pendingFile.value);
const row: any = await uploadTemplate(fd);
message.success(row?.parseMessage || "已上传并解析");
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,
@@ -353,17 +478,12 @@ async function downloadPreviewOrOriginal() {
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.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" });
message.error({ content: e?.message || "下载原模板失败", key: "tpl-dl" });
}
}
</script>
@@ -422,7 +542,31 @@ async function downloadPreviewOrOriginal() {
<td class="qt-col-keywords" :title="formatKeywords(row.keywords)">
{{ formatKeywords(row.keywords) || "—" }}
</td>
<td class="qt-col-muted">{{ row.parseStatus }}</td>
<td
class="qt-col-muted"
:class="{
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
}"
:title="row.parseMessage || row.parseStatus"
>
<span>{{ row.parseStatus }}</span>
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
未解析 {{ listMappingGaps(row).length }}
</span>
<div
v-if="
row.parseMessage &&
(row.parseStatus === '失败' ||
row.parseStatus === '映射未完成' ||
row.parseStatus === '解析中' ||
listMappingGaps(row).length > 0)
"
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>
@@ -433,6 +577,15 @@ async function downloadPreviewOrOriginal() {
预览
</button>
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
<button
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
@click.stop="reparseRow(row)"
>
重新解析
</button>
<button
type="button"
class="link-action"
@@ -441,6 +594,9 @@ async function downloadPreviewOrOriginal() {
>
{{ row.status === "enabled" ? "禁用" : "启用" }}
</button>
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
删除
</button>
</div>
</td>
</tr>
@@ -529,13 +685,26 @@ async function downloadPreviewOrOriginal() {
</div>
</div>
<div v-if="parseState.visible" class="qt-parse-banner" :class="{ 'is-error': !parseState.ok }">
<div class="qt-parse-banner-icon" :class="parseState.ok ? 'is-ok' : 'is-err'">
<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>
<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>
@@ -610,7 +779,7 @@ async function downloadPreviewOrOriginal() {
<ProtoIcon name="eye" :size="16" />
</div>
<div>
<div class="qt-modal-sub">基于上传模板渲染 · 用户端预览</div>
<div class="qt-modal-sub">上传原表 · 截图预览</div>
<div class="qt-modal-title">{{ previewRow.name }}</div>
</div>
</div>
@@ -621,73 +790,32 @@ async function downloadPreviewOrOriginal() {
<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"
>已解析 · {{ previewVm.mappedCount || previewRow.mappingPointCount }} 个映射点</span
>
<span class="tag" :class="bizTag(previewRow.bizType)">{{
previewState.fileName || previewRow.fileName
}}</span>
<span class="tag tag-green">已映射 {{ previewState.mappedCount }} 项</span>
</div>
<div class="qt-paper">
<div class="qt-paper-head">
<div>
<div class="qt-paper-title">
{{ previewRow.name.replace(/模板$/, "") || previewRow.name }}
</div>
<div class="qt-paper-cap">模板款式 · 由上传 Excel 决定(黄区固定)</div>
</div>
<div class="qt-paper-meta">
<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 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>
<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、签章与业务上传模板一致;上述为预览回填数据,金额只来自冻结报价事实,模型不发明价格。
</p>
<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="点击放大"
@click="openLightbox"
/>
<iframe
v-else
class="qt-snapshot-iframe"
:src="previewState.objectUrl"
title="原表 PDF 预览"
/>
</div>
<p class="qt-preview-foot-hint">{{ previewFootHint }}</p>
</div>
@@ -695,15 +823,38 @@ async function downloadPreviewOrOriginal() {
<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" />下载预览文件
<ProtoIcon name="download" :size="16" />下载原模板
</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-col-name {
font-weight: 800;
color: #0f172a;
@@ -718,6 +869,54 @@ async function downloadPreviewOrOriginal() {
font-size: 11px;
color: #334155;
}
.qt-parse-fail {
color: #b91c1c;
}
.qt-parse-gaps {
color: #b45309;
}
.qt-gap-badge {
display: inline-block;
margin-left: 6px;
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;
@@ -757,6 +956,71 @@ async function downloadPreviewOrOriginal() {
.qt-paper-loading--err {
color: #b42318;
}
.qt-snapshot-wrap {
max-height: min(70vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
}
.qt-snapshot-img {
display: inline-block;
width: auto;
height: auto;
max-width: none;
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,单行时标签与输入框垂直对齐 */
@@ -843,4 +1107,129 @@ async function downloadPreviewOrOriginal() {
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>
@@ -1,7 +1,6 @@
/**
* 报价模板预览用:合同样例事实 + 字段中文名。
* 与 contracts/quote-template-mapping-v1.json 的 samplePreviewFacts / systemFields 对齐。
* 仅用于管理端「用户端预览」弹窗回填一览;金额不由模型发明。
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
* 主数据来自 /previewSheet;mappingJson 仅用于未映射必填提示。
*/
/** 系统字段 code → 中文标签 */
@@ -30,178 +29,82 @@ export const FIELD_LABELS: Record<string, string> = {
total_amount: "报价合计",
};
/** 弹窗优先展示的字段顺序(接近原型) */
export const PREVIEW_FIELD_ORDER = [
"customer_name",
"container_type_qty",
"pol",
"pod",
"origin",
"destination",
"sales_name",
"commodity",
"fcl_or_lcl",
"trade_terms",
"contact_name",
"contact_phone",
"quantity",
"pieces",
"weight_kg",
"volume_cbm",
"cargo_ready_date",
"currency",
] as const;
/** 与主账 previewSample 同源的合同样例事实 */
export const SAMPLE_PREVIEW_FACTS: Record<string, any> = {
customer_name: "ABC Trading Co.",
contact_name: "张三",
contact_phone: "13800000000",
quote_no: "QT-202607020001",
quote_date: "2026-07-02",
valid_until: "2026-07-05",
currency: "USD",
sales_name: "张敏",
pol: "CNSHA",
pod: "USLAX",
origin: "深圳",
destination: "洛杉矶",
commodity: "通用货物",
quantity: "2",
container_type_qty: "40HQ × 2",
fcl_or_lcl: "FCL",
trade_terms: "FOB",
cargo_ready_date: "2026-07-10",
pieces: "10",
weight_kg: "1200",
volume_cbm: "8.5",
total_amount: "3680.00",
fee_rows: [
{ name: "O/F 海运费", amount: "2900", remark: "" },
{ name: "THC + DOC + SEAL", amount: "780", remark: "" },
],
export type SheetPreviewCell = {
addr: string;
text: string;
yellow: boolean;
mappedCode?: string;
mappedLabel?: string;
};
export type PreviewFeeRow = { name: string; amount: string; remark?: string };
export type PreviewFieldRow = {
code: string;
label: string;
value: string;
cell: string;
export type SheetPreviewRow = {
rowNo: number;
cells: SheetPreviewCell[];
};
export type PreviewViewModel = {
loading: boolean;
error: string;
quoteNo: string;
quoteDate: string;
currency: string;
totalAmount: string;
validUntil: string;
/** 航线摘要:有 pol/pod 或 origin/destination 时拼出 */
routeText: string;
fields: PreviewFieldRow[];
fees: PreviewFeeRow[];
sheet: string;
fileName: string;
colHeaders: string[];
rows: SheetPreviewRow[];
mappedCount: number;
yellowCount: number;
unmappedLabels: string[];
unmappedRequired: string[];
};
function parseMapping(raw: unknown): { fields: Record<string, string>; unmapped: string[] } {
if (!raw) return { fields: {}, unmapped: [] };
let obj: any = raw;
if (typeof raw === "string") {
function parseUnmapped(mappingJson: unknown): string[] {
if (!mappingJson) return [];
let obj: any = mappingJson;
if (typeof mappingJson === "string") {
try {
obj = JSON.parse(raw);
obj = JSON.parse(mappingJson);
} catch {
return { fields: {}, unmapped: [] };
return [];
}
}
const fields: Record<string, string> = {};
const src = obj?.fields || {};
if (src && typeof src === "object") {
for (const [k, v] of Object.entries(src)) {
if (k === "fee_rows") continue;
const cell = String(v ?? "").trim();
if (cell) fields[k] = cell;
}
}
const unmapped = Array.isArray(obj?.unmappedRequired)
? obj.unmappedRequired.map((x: any) => String(x))
: [];
return { fields, unmapped };
return Array.isArray(obj?.unmappedRequired) ? obj.unmappedRequired.map((x: any) => String(x)) : [];
}
/**
* 根据 mappingJson 与样例事实组装弹窗视图。
* 只展示「已映射」字段的样例值;费用行在有 fee_rows 映射或样例费用时展示。
*/
export function buildPreviewViewModel(mappingJson: unknown, pointCountHint = 0): PreviewViewModel {
const { fields: mapped, unmapped } = parseMapping(mappingJson);
const facts = SAMPLE_PREVIEW_FACTS;
const fieldRows: PreviewFieldRow[] = [];
const ordered = [
...PREVIEW_FIELD_ORDER.filter((c) => mapped[c]),
...Object.keys(mapped).filter((c) => !(PREVIEW_FIELD_ORDER as readonly string[]).includes(c)),
];
// 航线合并展示,避免 grid 里重复 pol/pod
const hasRoute = Boolean(mapped.pol || mapped.pod || mapped.origin || mapped.destination);
let routeText = "";
if (hasRoute) {
if (mapped.pol || mapped.pod) {
routeText = `${facts.pol || "—"} → ${facts.pod || "—"}`;
} else {
routeText = `${facts.origin || "—"} → ${facts.destination || "—"}`;
}
}
for (const code of ordered) {
if (code === "pol" || code === "pod" || code === "origin" || code === "destination") {
continue;
}
const rawVal = facts[code];
if (rawVal == null || rawVal === "") continue;
fieldRows.push({
code,
label: FIELD_LABELS[code] || code,
value: String(rawVal),
cell: mapped[code],
});
}
if (routeText) {
fieldRows.splice(Math.min(2, fieldRows.length), 0, {
code: "route",
label: "航线",
value: routeText,
cell: [mapped.pol, mapped.pod, mapped.origin, mapped.destination].filter(Boolean).join(" / "),
});
}
const fees: PreviewFeeRow[] = Array.isArray(facts.fee_rows)
? facts.fee_rows.map((r: any) => ({
name: String(r.name ?? ""),
amount: String(r.amount ?? ""),
remark: String(r.remark ?? ""),
}))
/** 把主账 previewSheet 结果转成弹窗视图 */
export function buildSheetPreviewViewModel(sheetRes: any, mappingJson?: unknown): PreviewViewModel {
const colHeaders = Array.isArray(sheetRes?.colHeaders)
? sheetRes.colHeaders.map((x: any) => String(x))
: [];
const currency = String(facts.currency || "USD");
const total = String(facts.total_amount || "");
const rowsRaw = Array.isArray(sheetRes?.rows) ? sheetRes.rows : [];
const rows: SheetPreviewRow[] = rowsRaw.map((r: any) => ({
rowNo: Number(r.rowNo || 0),
cells: Array.isArray(r.cells)
? r.cells.map((c: any) => ({
addr: String(c.addr || ""),
text: String(c.text || ""),
yellow: Boolean(c.yellow),
mappedCode: c.mappedCode ? String(c.mappedCode) : undefined,
mappedLabel: c.mappedLabel ? String(c.mappedLabel) : undefined,
}))
: [],
}));
let yellowCount = 0;
let mappedHit = 0;
for (const row of rows) {
for (const c of row.cells) {
if (c.yellow) yellowCount += 1;
if (c.mappedCode) mappedHit += 1;
}
}
const unmapped = parseUnmapped(mappingJson);
return {
loading: false,
error: "",
quoteNo: String(facts.quote_no || ""),
quoteDate: String(facts.quote_date || ""),
currency,
totalAmount: total,
validUntil: String(facts.valid_until || ""),
routeText,
fields: fieldRows,
fees,
mappedCount: Object.keys(mapped).length || pointCountHint,
error: String(sheetRes?.error || ""),
sheet: String(sheetRes?.sheet || ""),
fileName: String(sheetRes?.fileName || ""),
colHeaders,
rows,
mappedCount: Number(sheetRes?.mappedCount || mappedHit || 0),
yellowCount,
unmappedLabels: unmapped.map((c) => FIELD_LABELS[c] || c),
unmappedRequired: unmapped,
};
}
@@ -210,15 +113,18 @@ export function emptyPreviewViewModel(): PreviewViewModel {
return {
loading: false,
error: "",
quoteNo: "",
quoteDate: "",
currency: "USD",
totalAmount: "",
validUntil: "",
routeText: "",
fields: [],
fees: [],
sheet: "",
fileName: "",
colHeaders: [],
rows: [],
mappedCount: 0,
yellowCount: 0,
unmappedLabels: [],
unmappedRequired: [],
};
}
/** @deprecated 兼容旧名:请用 buildSheetPreviewViewModel */
export function buildPreviewViewModel(_mappingJson: unknown, _pointCountHint = 0): PreviewViewModel {
return emptyPreviewViewModel();
}
@@ -16,22 +16,75 @@ enum Api {
disable = '/inquiry/quoteTemplate/disable',
setDefault = '/inquiry/quoteTemplate/setDefault',
editMeta = '/inquiry/quoteTemplate/editMeta',
delete = '/inquiry/quoteTemplate/delete',
previewSample = '/inquiry/quoteTemplate/previewSample',
previewSheet = '/inquiry/quoteTemplate/previewSheet',
previewSnapshot = '/inquiry/quoteTemplate/previewSnapshot',
download = '/inquiry/quoteTemplate/download',
reparse = '/inquiry/quoteTemplate/reparse',
}
export const getTemplateList = (params) => defHttp.get({ url: Api.list, params });
/**
* 上传模板文件(新建或追加版本)。
* 禁止手写 Content-Type: multipart/form-data:缺 boundary 会被 Tomcat 直接拒收。
* FormData 交给浏览器自动带 boundary;解析可能超过默认 10 秒,单独放宽超时。
*/
export const uploadTemplate = (data: FormData) =>
defHttp.post({
url: Api.upload,
data,
headers: { 'Content-Type': 'multipart/form-data' },
});
defHttp.post(
{
url: Api.upload,
data,
timeout: 120 * 1000,
},
{ errorMessageMode: 'message' },
);
export const getTemplateVersion = (versionId: string) =>
defHttp.get({ url: Api.version, params: { versionId } });
/** 上传原表 Excel 样式快照(格子原文 + 黄区/映射;旧接口保留) */
export const getTemplateSheetPreview = (versionId: string) =>
defHttp.get({ url: Api.previewSheet, params: { versionId }, timeout: 60 * 1000 });
/**
* 原表截图预览(主账本机 LibreOffice 转 PNG/PDF)。
* 走 blob:带鉴权;首次转换可能较慢。
*/
export async function fetchTemplateSnapshotBlob(versionId: string): Promise<{ blob: Blob; contentType: string }> {
const glob = useGlobSetting();
const base = (glob.apiUrl || '').replace(/\/$/, '');
const url = `${base}${Api.previewSnapshot}?versionId=${encodeURIComponent(versionId)}`;
const token = getToken() || '';
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 150 * 1000);
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-Access-Token': String(token),
},
signal: controller.signal,
});
const ctype = (res.headers.get('content-type') || '').toLowerCase();
if (!res.ok || ctype.includes('application/json')) {
let msg = `预览失败 HTTP ${res.status}`;
try {
const j = await res.json();
if (j?.message) msg = String(j.message);
} catch {
/* ignore */
}
throw new Error(msg);
}
const blob = await res.blob();
return { blob, contentType: ctype || blob.type || 'application/octet-stream' };
} finally {
window.clearTimeout(timer);
}
}
export const listTemplateVersions = (templateId: string) =>
defHttp.get({ url: Api.versions, params: { templateId } });
@@ -44,10 +97,31 @@ export const enableTemplate = (templateId: string, versionId: string) =>
export const disableTemplate = (templateId: string) =>
defHttp.post({ url: Api.disable, params: { templateId } });
/**
* 删除模板逻辑头及全部版本;不可恢复。
* 走 POST(与 Jeecg 惯例一致):部分网关/Nginx 对 DELETE 会直接 404。
* joinParamsToUrl:非 GET 时 axios 会把 params 挪进 JSON 体,必须拼到查询串给 @RequestParam。
*/
export const deleteTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.delete, params: { templateId } },
{ joinParamsToUrl: true },
);
export const setTemplateDefault = (templateId: string, isDefault = true) =>
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
export const editTemplateMeta = (params) => defHttp.put({ url: Api.editMeta, params });
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
/**
* 对最新 Excel 版重新提交 AI 异步解析(已解析也可点;解析中跳过)。
*/
export const reparseTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
{ joinParamsToUrl: true },
);
/**
* 带鉴权下载样例预览 / 原文件。