看板平均响应按协同分发统计;查价失败可从主账恢复;报价模板匹配与填表更稳,后台改名运营后台。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 长内容折叠:超过 maxHeight 时收起,底部下箭头点开展开,再点收起。
|
||||
* 用于工单列表「询价需求 / 报价信息」和详情同名区块,避免一行撑满整屏。
|
||||
* 副作用:无写库。内容变化(contentKey)时重新量高并默认收起。
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 收起时最大高度(px) */
|
||||
maxHeight?: number;
|
||||
/** 内容身份:工单号/区块名变化时重测并收起 */
|
||||
contentKey?: string | number;
|
||||
/** 有费用明细等:即使收起后高度不够,也显示下箭头 */
|
||||
forceToggle?: boolean;
|
||||
/**
|
||||
* 收起时是否用 maxHeight 裁切内容。
|
||||
* 列表报价只藏费用明细、摘要必须完整露出时关掉,避免「CNY / 第二条报价」被切半。
|
||||
*/
|
||||
clipCollapsed?: boolean;
|
||||
}>(),
|
||||
{
|
||||
maxHeight: 120,
|
||||
contentKey: "",
|
||||
forceToggle: false,
|
||||
clipCollapsed: true,
|
||||
},
|
||||
);
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
const expanded = ref(false);
|
||||
const overflow = ref(false);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function measure() {
|
||||
const el = bodyRef.value;
|
||||
if (!el) {
|
||||
overflow.value = !!props.forceToggle;
|
||||
return;
|
||||
}
|
||||
const natural = el.scrollHeight;
|
||||
overflow.value = !!props.forceToggle || natural > props.maxHeight + 2;
|
||||
if (!overflow.value) {
|
||||
expanded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!overflow.value) return;
|
||||
expanded.value = !expanded.value;
|
||||
nextTick(measure);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(measure);
|
||||
if (typeof ResizeObserver !== "undefined" && bodyRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(bodyRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.contentKey, props.maxHeight, props.forceToggle],
|
||||
() => {
|
||||
expanded.value = false;
|
||||
nextTick(measure);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="expand-clamp"
|
||||
:class="{ 'is-expanded': expanded, 'is-overflow': props.clipCollapsed && overflow && !expanded }"
|
||||
>
|
||||
<div
|
||||
ref="bodyRef"
|
||||
class="expand-clamp-body"
|
||||
:style="
|
||||
props.clipCollapsed && overflow && !expanded ? { maxHeight: maxHeight + 'px' } : undefined
|
||||
"
|
||||
>
|
||||
<slot :expanded="expanded" />
|
||||
</div>
|
||||
<button
|
||||
v-if="overflow"
|
||||
type="button"
|
||||
class="expand-clamp-toggle"
|
||||
:class="{ 'is-open': expanded }"
|
||||
:title="expanded ? '收起' : '展开全部'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ProtoIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expand-clamp {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.expand-clamp-body {
|
||||
overflow: visible;
|
||||
}
|
||||
.expand-clamp.is-overflow .expand-clamp-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-clamp.is-overflow:not(.is-expanded)::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
height: 28px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff 90%);
|
||||
}
|
||||
.expand-clamp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 2px 0 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.expand-clamp-toggle:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.expand-clamp-toggle :deep(svg) {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.expand-clamp-toggle.is-open :deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="login-brand-mark">
|
||||
<ProtoIcon name="bot" :size="22" />
|
||||
</div>
|
||||
<h1>询价机器人后台</h1>
|
||||
<h1>友通达智能体运营后台</h1>
|
||||
<p>管理询价工单、人员配置与系统权限</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,19 +31,19 @@ export type CostRoute = {
|
||||
points: Array<{ x: number; y: number; index: number }>;
|
||||
};
|
||||
|
||||
/** 今日视角九指标(与原型默认展示一致) */
|
||||
/** 今日视角九指标(与原型 5 列栅格、主账 InquiryDashboardServiceImpl 一致) */
|
||||
export const todayMetrics: MetricCard[] = [
|
||||
{ key: "inquiry", label: "询价总数", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "inquiry", label: "询价总量", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "quoted", label: "报价工单", tip: "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", value: "92 单" },
|
||||
{ key: "tms", label: "标准报价命中率", tip: "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", value: "67.8%" },
|
||||
{ key: "abnormal", label: "异常处理完成率", tip: "已关闭异常工单数 ÷ 异常工单总数 × 100%", value: "86.4%" },
|
||||
{ key: "manual", label: "手工报价占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "accepted", label: "已成交工单", tip: "状态=已成交的工单数量", value: "31" },
|
||||
{ key: "dealRate", label: "报价成交率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价反馈率", tip: "(已成交 + 未成交 − 关闭)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{ key: "avgSale", label: "平均报价销售额", tip: "AVG(已勾选报价的销售价)", value: "12800" },
|
||||
{ key: "manual", label: "手工工单占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "dealRate", label: "报价成功率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价有效率", tip: "(已成交 + 未成交)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{
|
||||
key: "avgResp",
|
||||
label: "内部协同平均响应时长",
|
||||
label: "内部报价平均响应",
|
||||
tip: "AVG(首次有效回复时间 − 分发时间)",
|
||||
value: "8m42s",
|
||||
isTime: true,
|
||||
|
||||
@@ -70,6 +70,9 @@ body {
|
||||
font-size: 11px !important;
|
||||
font-weight: 900 !important;
|
||||
line-height: 1.25 !important;
|
||||
white-space: normal !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
@@ -476,9 +479,19 @@ body {
|
||||
.ticket-list-table td.col-sticky-left,
|
||||
.ticket-list-table td.col-sticky-right,
|
||||
.ticket-list-table th.col-sticky-left,
|
||||
.ticket-list-table th.col-sticky-right {
|
||||
.ticket-list-table th.col-sticky-right,
|
||||
.ticket-list-table td.col-quote,
|
||||
.ticket-list-table td.col-price,
|
||||
.ticket-list-table td.col-demand {
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ticket-list-table .quote-source,
|
||||
.ticket-list-table .quote-price,
|
||||
.ticket-list-table .quote-fee-line,
|
||||
.ticket-list-table .ticket-price-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-list-table .attach-tags {
|
||||
@@ -558,7 +571,7 @@ body {
|
||||
.ticket-list-table {
|
||||
table-layout: fixed !important;
|
||||
width: max-content !important;
|
||||
min-width: 1480px !important;
|
||||
min-width: 1640px !important;
|
||||
max-width: none !important;
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
@@ -566,8 +579,8 @@ body {
|
||||
|
||||
.ticket-list-table .col-w-id { width: 132px; }
|
||||
.ticket-list-table .col-w-user { width: 72px; }
|
||||
.ticket-list-table .col-w-demand { width: 200px; }
|
||||
.ticket-list-table .col-w-quote { width: 140px; }
|
||||
.ticket-list-table .col-w-demand { width: 220px; }
|
||||
.ticket-list-table .col-w-quote { width: 250px; }
|
||||
.ticket-list-table .col-w-attach { width: 160px; }
|
||||
.ticket-list-table .col-w-status { width: 84px; }
|
||||
.ticket-list-table .col-w-price { width: 168px; }
|
||||
|
||||
@@ -469,11 +469,11 @@
|
||||
}
|
||||
.ref-metric-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 6;
|
||||
z-index: 40;
|
||||
width: max-content;
|
||||
max-width: 240px;
|
||||
max-width: 280px;
|
||||
border: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
@@ -485,26 +485,26 @@
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transform: translate(-50%, 4px);
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
white-space: normal;
|
||||
}
|
||||
.ref-metric-tip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1px solid #dfe5ee;
|
||||
border-bottom: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
transform: translateY(-4px) rotate(45deg);
|
||||
transform: translate(-50%, -4px) rotate(45deg);
|
||||
}
|
||||
.ref-metric-help:hover .ref-metric-tip,
|
||||
.ref-metric-help:focus-visible .ref-metric-tip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.ref-quality-value {
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。
|
||||
*
|
||||
* 口径对齐销售侧询价确认卡(inquiry_field_lines):
|
||||
* 空运看件数/重量/体积/包装;海运看货量/整柜拼柜/箱型箱量;
|
||||
* 陆运拼车看体积重量,整车/中港零担看车型数量,中港整车不强制车型。
|
||||
* 只做展示,不改六态、不写主账。
|
||||
*/
|
||||
|
||||
/** 详情格子一行:标签 + 值(空则「-」) */
|
||||
export type DemandField = { label: string; value: string };
|
||||
|
||||
const LAND_FTL_TYPES = new Set(["国内运输整车", "跨境整车", "中港零担/集拼"]);
|
||||
const LAND_CUSTOMS_TYPES = new Set(["跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"]);
|
||||
|
||||
const LAND_LABEL: Record<string, string> = {
|
||||
起运港: "始发站",
|
||||
目的港: "目的地",
|
||||
品名: "货物品名",
|
||||
毛重: "重量(KG)",
|
||||
体积: "体积(CBM)",
|
||||
"车型/数量": "车型/数量",
|
||||
通关口岸: "通关口岸",
|
||||
报价日期: "报价日期",
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
|
||||
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
|
||||
*/
|
||||
export function parseFactsMap(raw: unknown): Record<string, string> {
|
||||
if (raw == null) return {};
|
||||
let obj: any = raw;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (val == null) continue;
|
||||
if (typeof val === "object") continue;
|
||||
const text = String(val).trim();
|
||||
if (text) out[key] = text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
|
||||
export function mergeInquiryFacts(ticket: any): Record<string, string> {
|
||||
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
|
||||
const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts);
|
||||
const out: Record<string, string> = { ...inquiry };
|
||||
for (const [k, v] of Object.entries(collab)) {
|
||||
if (!out[k]) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pick(facts: Record<string, string>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const v = (facts[key] || "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function dash(v: unknown): string {
|
||||
const s = (v ?? "").toString().trim();
|
||||
return s ? s : "-";
|
||||
}
|
||||
|
||||
function columnText(v: unknown): string {
|
||||
if (v == null || v === "") return "";
|
||||
const s = String(v).trim();
|
||||
if (!s || s === "-") return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
function businessLineOf(ticket: any): "AIR" | "SEA" | "LAND" | "MULTI" | "" {
|
||||
const raw = String(ticket?.businessLine ?? "").trim().toUpperCase();
|
||||
if (raw === "AIR" || raw === "SEA" || raw === "LAND" || raw === "MULTI") return raw;
|
||||
const label = String(ticket?.bizTypeLabel ?? ticket?.bizType ?? "").trim();
|
||||
if (label.includes("空运")) return "AIR";
|
||||
if (label.includes("海运")) return "SEA";
|
||||
if (label.includes("陆运")) return "LAND";
|
||||
if (label.includes("多段") || label.includes("联运")) return "MULTI";
|
||||
return "";
|
||||
}
|
||||
|
||||
function transportModeLabel(ticket: any, line: string): string {
|
||||
const label = String(ticket?.bizTypeLabel ?? "").trim();
|
||||
if (label) return label;
|
||||
if (line === "AIR") return "空运";
|
||||
if (line === "SEA") return "海运";
|
||||
if (line === "LAND") return "陆运";
|
||||
if (line === "MULTI") return "多段联运";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 当前陆运类型要展示的货物内部键。中港整车不要把车型当成询价必填。 */
|
||||
function landCargoKeys(landType: string): string[] {
|
||||
if (landType === "中港整车") {
|
||||
return ["起运港", "目的港", "品名", "报价日期"];
|
||||
}
|
||||
if (LAND_FTL_TYPES.has(landType)) {
|
||||
return ["起运港", "目的港", "品名", "车型/数量", "报价日期"];
|
||||
}
|
||||
return ["起运港", "目的港", "品名", "体积", "毛重", "报价日期"];
|
||||
}
|
||||
|
||||
function push(fields: DemandField[], label: string, value: string) {
|
||||
fields.push({ label, value: dash(value) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单详情询价需求格子。
|
||||
* 列字段(origin / pieces / containerSpec 等)作 facts 空时的兜底,兼容旧单。
|
||||
*/
|
||||
export function buildDemandFields(ticket: any): DemandField[] {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const mode = transportModeLabel(ticket, line);
|
||||
const origin = pick(facts, "起运港", "起运地") || columnText(ticket?.origin);
|
||||
const dest = pick(facts, "目的港", "目的地") || columnText(ticket?.destination);
|
||||
const goods = pick(facts, "品名", "货物品名") || columnText(ticket?.goodsName);
|
||||
const quoteDate =
|
||||
pick(facts, "报价日期") || columnText(ticket?.quoteDate)?.toString().slice(0, 10) || "";
|
||||
const customer = pick(facts, "客户名称") || columnText(ticket?.customerName);
|
||||
const fields: DemandField[] = [];
|
||||
|
||||
push(fields, "运输方式", mode);
|
||||
|
||||
if (line === "SEA") {
|
||||
push(fields, "起运港", origin);
|
||||
push(fields, "目的港", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "货物数量", pick(facts, "货量", "货物数量", "件数") || columnText(ticket?.cargoQty) || columnText(ticket?.pieces));
|
||||
push(fields, "整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
push(fields, "箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
push(fields, "贸易条款", pick(facts, "贸易条款"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else if (line === "LAND") {
|
||||
const landType = pick(facts, "运输类型");
|
||||
push(fields, "运输类型", landType);
|
||||
push(fields, "线路类别", pick(facts, "线路类别"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
const cargoVals: Record<string, string> = {
|
||||
起运港: origin,
|
||||
目的港: dest,
|
||||
品名: goods,
|
||||
体积: pick(facts, "体积") || columnText(ticket?.volumeCbm),
|
||||
毛重: pick(facts, "毛重", "重量") || columnText(ticket?.weightKg),
|
||||
"车型/数量": pick(facts, "车型/数量", "车型"),
|
||||
报价日期: quoteDate,
|
||||
};
|
||||
for (const key of landCargoKeys(landType)) {
|
||||
push(fields, LAND_LABEL[key] || key, cargoVals[key] || pick(facts, key));
|
||||
}
|
||||
if (LAND_CUSTOMS_TYPES.has(landType)) {
|
||||
push(fields, "通关口岸", pick(facts, "通关口岸"));
|
||||
}
|
||||
// 中港整车询价不强制车型,协同里有值仍展示,避免后台看不到 53 尺这类原文。
|
||||
if (landType === "中港整车" && pick(facts, "车型/数量", "车型")) {
|
||||
push(fields, "车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
}
|
||||
} else if (line === "AIR") {
|
||||
push(fields, "起运地", origin);
|
||||
push(fields, "目的地", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else {
|
||||
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
|
||||
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
const box = pick(facts, "箱型箱量") || columnText(ticket?.containerSpec);
|
||||
if (box) push(fields, "箱型箱量", box);
|
||||
const vehicle = pick(facts, "车型/数量", "车型");
|
||||
if (vehicle) push(fields, "车型/数量", vehicle);
|
||||
}
|
||||
|
||||
push(fields, "客户", customer);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「询价需求」第二行货量:按方式只写该方式关心的量,避免陆运整车全是件数「-」。
|
||||
*/
|
||||
export function quantitySummary(ticket: any): string {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const landType = pick(facts, "运输类型");
|
||||
const parts: string[] = [];
|
||||
const add = (label: string, raw: string) => {
|
||||
const v = (raw || "").trim();
|
||||
if (v && v !== "-") parts.push(`${label} ${v}`);
|
||||
};
|
||||
if (line === "SEA") {
|
||||
add("货物数量", pick(facts, "货量", "货物数量") || columnText(ticket?.cargoQty));
|
||||
add("整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
add("箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
if (line === "LAND") {
|
||||
if (landType) add("运输类型", landType);
|
||||
if (LAND_FTL_TYPES.has(landType) || (landType === "中港整车" && pick(facts, "车型/数量"))) {
|
||||
add("车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
} else {
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
add("件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import ExpandableClamp from "/@/views/inquiry/components/ExpandableClamp.vue";
|
||||
import {
|
||||
buildDemandFields,
|
||||
quantitySummary,
|
||||
type DemandField,
|
||||
} from "/@/views/inquiry/workOrder/demandFields";
|
||||
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
@@ -61,6 +67,8 @@ type TicketRow = {
|
||||
weightKg: string;
|
||||
volumeCbm: string;
|
||||
goodsName: string;
|
||||
/** 询价需求格子:按运输方式/运输类型变化 */
|
||||
demandFields: DemandField[];
|
||||
updateTime: string;
|
||||
lostReason: string;
|
||||
nextFollowupAt: string;
|
||||
@@ -300,7 +308,8 @@ function looksLikeRawFp(v: any) {
|
||||
|
||||
/**
|
||||
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
|
||||
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
|
||||
* 首行 = 业务线 · 路线;后续 = 该运输方式关心的货量 / 品名。
|
||||
* 陆运整车写车型数量,海运写箱型箱量;不信 demandDetail 管道串。
|
||||
*/
|
||||
function requirementSummaryLines(t: any): string[] {
|
||||
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
|
||||
@@ -313,37 +322,11 @@ function requirementSummaryLines(t: any): string[] {
|
||||
const lines: string[] = [];
|
||||
if (heading) lines.push(heading);
|
||||
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const quantities: string[] = [];
|
||||
const pieces = t?.pieces;
|
||||
const weight = t?.weightKg;
|
||||
const volume = t?.volumeCbm;
|
||||
const plain = (v: any) => {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? String(n) : String(v).trim();
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
const qty = quantitySummary(t);
|
||||
if (qty) lines.push(qty);
|
||||
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const cargo: string[] = [];
|
||||
const goods = String(t?.goodsName ?? "").trim();
|
||||
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
|
||||
@@ -418,6 +401,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
weightKg: fmtMeasure(t.weightKg, "kg"),
|
||||
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
|
||||
goodsName: dash(t.goodsName),
|
||||
demandFields: buildDemandFields(t),
|
||||
updateTime: fmtTime(t.updateTime || t.createTime),
|
||||
lostReason: dash(t.lostReason || t.lost_reason),
|
||||
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
|
||||
@@ -425,11 +409,8 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
};
|
||||
}
|
||||
|
||||
function routeText(row: TicketRow) {
|
||||
if (row.origin !== "-" || row.destination !== "-") {
|
||||
return `${row.origin === "-" ? "" : row.origin} → ${row.destination === "-" ? "" : row.destination}`.trim();
|
||||
}
|
||||
return row.demandTitle || "-";
|
||||
function quoteHasFees(row: TicketRow) {
|
||||
return (row.quotes || []).some((q) => (q.fees || []).length > 0);
|
||||
}
|
||||
|
||||
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
|
||||
@@ -670,18 +651,26 @@ function refresh() {
|
||||
<td class="align-top">
|
||||
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
<td class="align-top col-demand">
|
||||
<ExpandableClamp :content-key="row.id + '-demand'" :max-height="132">
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<template v-if="row.quotes.length">
|
||||
<td class="align-top col-quote">
|
||||
<ExpandableClamp
|
||||
v-if="row.quotes.length"
|
||||
v-slot="{ expanded }"
|
||||
:content-key="row.id + '-quote-' + row.quotes.length"
|
||||
:force-toggle="quoteHasFees(row)"
|
||||
:clip-collapsed="false"
|
||||
>
|
||||
<div
|
||||
v-for="q in row.quotes"
|
||||
:key="q.source + q.price"
|
||||
@@ -689,18 +678,20 @@ 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>
|
||||
<template v-if="expanded && q.fees.length">
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ExpandableClamp>
|
||||
<span v-else style="color: #94a3b8">-</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
@@ -896,7 +887,12 @@ function refresh() {
|
||||
<span v-else>{{ current.profit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
<ExpandableClamp
|
||||
v-if="current.quotes.length"
|
||||
class="ticket-detail-demand"
|
||||
:content-key="current.id + '-detail-quote-' + current.quotes.length"
|
||||
:max-height="220"
|
||||
>
|
||||
<div
|
||||
v-for="q in current.quotes"
|
||||
:key="q.source + q.price + (q.time || '')"
|
||||
@@ -922,28 +918,32 @@ function refresh() {
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</section>
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
<div class="ticket-detail-title">
|
||||
<ProtoIcon name="route" :size="14" />询价需求
|
||||
</div>
|
||||
<div class="ticket-detail-demand">
|
||||
<ExpandableClamp
|
||||
class="ticket-detail-demand"
|
||||
:content-key="current.id + '-detail-demand-' + current.demandFields.length"
|
||||
:max-height="220"
|
||||
>
|
||||
<div class="summary-cell">
|
||||
<strong>{{ current.demandTitle }}</strong>
|
||||
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-grid" style="margin-top: 8px">
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>起运 / 目的</label><span>{{ routeText(current) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>件数</label><span>{{ current.pieces }}</span></div>
|
||||
<div class="ticket-detail-item"><label>重量(KG)</label><span>{{ current.weightKg }}</span></div>
|
||||
<div class="ticket-detail-item"><label>体积(CBM)</label><span>{{ current.volumeCbm }}</span></div>
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
<div
|
||||
v-for="(item, idx) in current.demandFields"
|
||||
:key="'demand-field-' + idx + '-' + item.label"
|
||||
class="ticket-detail-item"
|
||||
>
|
||||
<label>{{ item.label }}</label><span>{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</section>
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="app-loading-dots">
|
||||
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
<div class="app-loading-title">友通达询价系统</div>
|
||||
<div class="app-loading-title">友通达智能体运营后台</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user