落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,6 +25,7 @@ pnpm run dev
|
||||
| 机器人回复语 | `inquiry/botReply/index` |
|
||||
| 账号管理 | `inquiry/account/index` |
|
||||
| 角色管理 | `inquiry/role/index` |
|
||||
| 系统设置 | `inquiry/systemSetting/index` |
|
||||
|
||||
菜单由后端 `sys_permission` 种子下发(见 `deploy/sql/inquiry_admin_menu_seed_v1.sql`)。登录后刷新权限即可看到询价菜单。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
/**
|
||||
* 带鉴权下载样例预览 / 原文件。
|
||||
|
||||
Reference in New Issue
Block a user