完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1262,6 +1262,43 @@ html {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价模板预览截图:强制等比例(宽高同比例缩放),覆盖任何全局 img 拉满规则。
|
||||
* 为何写在 fidelity:Teleport 弹窗挂 body,需非 scoped + !important 兜底。
|
||||
*/
|
||||
.qt-snapshot-wrap {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: flex-start !important;
|
||||
max-height: min(72vh, 760px) !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
.qt-snapshot-img,
|
||||
.modal-overlay .qt-snapshot-img,
|
||||
.qt-preview-dialog .qt-snapshot-img {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: min(70vh, 720px) !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center !important;
|
||||
flex: 0 0 auto !important;
|
||||
align-self: flex-start !important;
|
||||
}
|
||||
|
||||
.qt-lightbox-img {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: calc(100vw - 48px) !important;
|
||||
max-height: calc(100vh - 72px) !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.qt-upload-title,
|
||||
.qt-file-name {
|
||||
font-size: 11px !important;
|
||||
|
||||
@@ -770,6 +770,50 @@ textarea:focus-visible {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* 报价费用明细:列表紧凑一行;详情左右对齐名称/金额 */
|
||||
.quote-fee-line {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.quote-fee-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 6px 0 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-name {
|
||||
color: #475467;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-amount {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-detail-quote {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ticket-detail-quote:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.attach-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2249,6 +2293,47 @@ textarea:focus-visible {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 报价模板原表截图:等比例适配弹窗,禁止拉伸变形 */
|
||||
.qt-snapshot-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-height: min(72vh, 760px);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.qt-snapshot-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: min(70vh, 720px);
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 工单详情:对齐原型 admin-inquiry-tickets 分节网格 */
|
||||
.ticket-detail-no {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -70,6 +70,8 @@ const previewState = reactive({
|
||||
isImage: true,
|
||||
mappedCount: 0,
|
||||
fileName: "",
|
||||
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
|
||||
trimmed: false,
|
||||
});
|
||||
|
||||
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
|
||||
@@ -125,16 +127,103 @@ const formSub = computed(() =>
|
||||
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
|
||||
);
|
||||
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
|
||||
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
|
||||
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
|
||||
const previewLightbox = ref(false);
|
||||
|
||||
const filtered = computed(() => rows.value);
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewState.objectUrl) {
|
||||
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewState.objectUrl);
|
||||
previewState.objectUrl = "";
|
||||
}
|
||||
previewState.objectUrl = "";
|
||||
previewState.trimmed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
|
||||
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
|
||||
*/
|
||||
function onSnapshotImgLoad(ev: Event) {
|
||||
if (previewState.trimmed || !previewState.isImage) return;
|
||||
const img = ev.target as HTMLImageElement | null;
|
||||
if (!img?.naturalWidth || !img.naturalHeight) return;
|
||||
try {
|
||||
const next = cropNearWhiteObjectUrl(img);
|
||||
if (!next) {
|
||||
previewState.trimmed = true;
|
||||
return;
|
||||
}
|
||||
const old = previewState.objectUrl;
|
||||
previewState.trimmed = true;
|
||||
previewState.objectUrl = next;
|
||||
if (old && old.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(old);
|
||||
}
|
||||
} catch {
|
||||
previewState.trimmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
|
||||
*/
|
||||
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
|
||||
const rowMostlyWhite = (y: number) => {
|
||||
const allow = Math.max(2, Math.floor(w / 200));
|
||||
let dirty = 0;
|
||||
const row = y * w * 4;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!isWhite(row + x * 4)) {
|
||||
dirty++;
|
||||
if (dirty > allow) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const colMostlyWhite = (x: number, y0: number, y1: number) => {
|
||||
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
|
||||
let dirty = 0;
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
if (!isWhite((y * w + x) * 4)) {
|
||||
dirty++;
|
||||
if (dirty > allow) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let top = 0;
|
||||
let bottom = h - 1;
|
||||
let left = 0;
|
||||
let right = w - 1;
|
||||
while (top < h && rowMostlyWhite(top)) top++;
|
||||
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
|
||||
while (left < w && colMostlyWhite(left, top, bottom)) left++;
|
||||
while (right > left && colMostlyWhite(right, top, bottom)) right--;
|
||||
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, left - pad);
|
||||
const y = Math.max(0, top - pad);
|
||||
const cw = Math.min(w - 1, right + pad) - x + 1;
|
||||
const ch = Math.min(h - 1, bottom + pad) - y + 1;
|
||||
if (cw >= w && ch >= h) return null;
|
||||
const out = document.createElement("canvas");
|
||||
out.width = cw;
|
||||
out.height = ch;
|
||||
const octx = out.getContext("2d");
|
||||
if (!octx) return null;
|
||||
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +238,7 @@ async function openPreview(row: TemplateRow) {
|
||||
previewState.loading = true;
|
||||
previewState.error = "";
|
||||
previewState.isImage = true;
|
||||
previewState.trimmed = false;
|
||||
previewState.mappedCount = row.mappingPointCount || 0;
|
||||
previewState.fileName = row.fileName || "";
|
||||
if (!row.versionId) {
|
||||
@@ -919,6 +1009,7 @@ async function downloadPreviewOrOriginal() {
|
||||
:src="previewState.objectUrl"
|
||||
alt="原表截图预览"
|
||||
title="点击放大"
|
||||
@load="onSnapshotImgLoad"
|
||||
@click="openLightbox"
|
||||
/>
|
||||
<iframe
|
||||
@@ -1248,19 +1339,35 @@ async function downloadPreviewOrOriginal() {
|
||||
.qt-paper-loading--err {
|
||||
color: #b42318;
|
||||
}
|
||||
/**
|
||||
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
|
||||
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
|
||||
*/
|
||||
.qt-snapshot-wrap {
|
||||
max-height: min(70vh, 720px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-height: min(72vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
/* 不设 min-height,避免图矮时底下留灰白空带 */
|
||||
min-height: 0;
|
||||
}
|
||||
.qt-snapshot-img {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
display: block;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: min(70vh, 720px) !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
vertical-align: top;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
|
||||
@@ -17,7 +17,16 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
|
||||
type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean };
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
type TicketQuote = {
|
||||
source: string;
|
||||
price: string;
|
||||
time?: string;
|
||||
highlight?: boolean;
|
||||
/** TMS/手工报价费用明细行;无明细时为空数组 */
|
||||
fees: TicketQuoteFee[];
|
||||
};
|
||||
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
|
||||
type TicketTimeline = { status: string; remark: string; time: string };
|
||||
|
||||
@@ -108,6 +117,110 @@ function quoteSourceLabel(q: any) {
|
||||
return label && label !== "报价" ? "手工报价" : "报价";
|
||||
}
|
||||
|
||||
/** JSON 字符串或对象都解开;解不开当没有。 */
|
||||
function asObject(raw: any): Record<string, any> | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record<string, any>;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, any>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 费用数组:List / JSON 字符串 / 二次编码都认。 */
|
||||
function asFeeArray(raw: any): any[] {
|
||||
if (raw == null) return [];
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (typeof parsed === "string") {
|
||||
const again = JSON.parse(parsed);
|
||||
return Array.isArray(again) ? again : [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条费用行收成「名称 + 金额」。
|
||||
* TMS 字段:name/chargeName、amount/unitPrice、currency。
|
||||
*/
|
||||
function feeRowToDisplay(item: any): TicketQuoteFee | null {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const name = String(
|
||||
item.name || item.chargeName || item.feeName || item.label || ""
|
||||
).trim();
|
||||
if (!name) return null;
|
||||
let amount = String(item.amount ?? item.totalPrice ?? item.value ?? "").trim();
|
||||
const unitPrice = String(item.unitPrice ?? item.unit_price ?? "").trim();
|
||||
if (!amount || amount === "-") {
|
||||
amount = unitPrice;
|
||||
}
|
||||
if (!amount || amount === "-") return null;
|
||||
const ccy = String(item.currency || "").trim().toUpperCase();
|
||||
const amountUpper = amount.toUpperCase();
|
||||
if (ccy && !amountUpper.startsWith(ccy) && !/^[A-Z]{3}\s/.test(amountUpper)) {
|
||||
amount = `${ccy} ${amount}`;
|
||||
}
|
||||
return { name, amount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从报价 payload 抽费用明细,供后台「价格与利润」展示。
|
||||
* 优先 fee_lines / fee_rows;空运再看 departureCharges;海运再看三段桶。
|
||||
* 副作用:无。不写库。
|
||||
*/
|
||||
function parseQuoteFees(q: any): TicketQuoteFee[] {
|
||||
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
|
||||
const out: TicketQuoteFee[] = [];
|
||||
const pushRows = (rows: any[]) => {
|
||||
for (const row of rows) {
|
||||
const one = feeRowToDisplay(row);
|
||||
if (one) out.push(one);
|
||||
}
|
||||
};
|
||||
pushRows(asFeeArray(payload.fee_lines ?? payload.fee_rows ?? q?.fee_lines ?? q?.fee_rows));
|
||||
if (!out.length) {
|
||||
const selected =
|
||||
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
|
||||
if (selected) {
|
||||
pushRows(
|
||||
asFeeArray(
|
||||
selected.departureCharges || selected.departure_charges || selected.feeItems
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!out.length) {
|
||||
const opts = asFeeArray(payload.airOptions || payload.air_options);
|
||||
const first = opts.find((x) => x && typeof x === "object") || null;
|
||||
if (first) {
|
||||
pushRows(
|
||||
asFeeArray(first.departureCharges || first.departure_charges || first.feeItems)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!out.length) {
|
||||
for (const bucket of ["polCostItems", "oceanCostItems", "podCostItems"] as const) {
|
||||
const key = `fee_rows_${bucket}`;
|
||||
pushRows(asFeeArray(payload[key] || payload[bucket]));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
||||
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
||||
@@ -260,6 +373,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
price: q.priceText || "-",
|
||||
time: fmtTime(q.createTime),
|
||||
highlight: q.highlighted === 1 || q.highlighted === true,
|
||||
fees: parseQuoteFees(q),
|
||||
})),
|
||||
attachments: attachRows.map((a) => a.name),
|
||||
attachRows,
|
||||
@@ -556,6 +670,13 @@ function refresh() {
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<div class="quote-source">{{ q.source }}</div>
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
|
||||
{{ q.price }}
|
||||
</div>
|
||||
@@ -688,9 +809,30 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
|
||||
</div>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
|
||||
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
|
||||
<div
|
||||
v-for="q in current.quotes"
|
||||
:key="q.source + q.price + (q.time || '')"
|
||||
class="quote-row ticket-detail-quote"
|
||||
>
|
||||
<div class="quote-source">
|
||||
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
|
||||
</div>
|
||||
<div v-if="q.fees.length" class="quote-fee-list">
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-detail-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
<span class="quote-fee-name">{{ fee.name }}</span>
|
||||
<span class="quote-fee-amount">{{ fee.amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="quote-price"
|
||||
:style="q.highlight ? { color: '#1d4ed8' } : undefined"
|
||||
>
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user