Files
inquiry_robot/inquiry-backend/src/views/inquiry/workOrder/index.vue
T

1294 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 询价工单列表:对接主账 /inquiry/workOrder。
* 六态关单/重试必须调主账;禁止本地改 status 冒充成功。
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
exportWorkOrders,
getWorkOrderDetail,
getWorkOrderList,
retryAbnormal,
transitionWorkOrder,
} from "/@/views/inquiry/workOrder/workOrder.api";
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";
import { usePermission } from "/@/hooks/web/usePermission";
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
const { hasPermission } = usePermission();
const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
const canViewDetail = () =>
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
/** 导出:有 export 或 list 均可(与主账 Logical.OR 对齐) */
const canExportTicket = () =>
hasPermission(InquiryBtnPerm.workorderExport) || hasPermission(InquiryBtnPerm.workorderList);
const exporting = ref(false);
/**
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
* 已成交/未成交/已关闭为终态,禁止再显示「关闭工单」(否则点确认会报不允许流转)。
*/
const CLOSEABLE_STATUSES = new Set(["询价中", "已报价", "协商中", "转人工"]);
function canShowCloseForStatus(status: string) {
return canCloseTicket() && CLOSEABLE_STATUSES.has(String(status || "").trim());
}
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
/** 成本价 / 销售价 / 利润点的一行。最后一行名称是「费用合计」。 */
type PriceLine = { name: string; text: 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 };
type TicketRow = {
id: string;
initiator: string;
demandTitle: string;
demandDetail: string;
quotes: TicketQuote[];
attachments: string[];
attachRows: TicketAttach[];
timeline: TicketTimeline[];
status: string;
costPrice: string;
salePrice: string;
profit: string;
costLines: PriceLine[];
saleLines: PriceLine[];
profitLines: PriceLine[];
eventException: string;
systemException: string;
systemExceptionReason: string;
createdAt: string;
quoteDate: string;
bizType: string;
customerName: string;
origin: string;
destination: string;
pieces: string;
weightKg: string;
volumeCbm: string;
goodsName: string;
/** 询价需求格子:按运输方式/运输类型变化 */
demandFields: DemandField[];
updateTime: string;
lostReason: string;
nextFollowupAt: string;
dealNotifySummary: string;
};
const rows = ref<TicketRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const filters = reactive({
keyword: "",
status: "",
eventException: "",
systemException: "",
bizType: "",
dateFrom: "",
dateTo: "",
});
const detailOpen = ref(false);
const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
const closeReason = ref("");
/**
* 创建时间范围:与 filters.dateFrom/dateTo 双向同步。
* RangePicker 一次弹层点选起止两天;valueFormat 固定 YYYY-MM-DD 给列表 API。
*/
const dateRange = computed({
get(): [string, string] | undefined {
if (!filters.dateFrom && !filters.dateTo) return undefined;
return [filters.dateFrom || "", filters.dateTo || ""];
},
set(v: [string, string] | null | undefined) {
filters.dateFrom = v?.[0] ? String(v[0]) : "";
filters.dateTo = v?.[1] ? String(v[1]) : "";
},
});
function dash(v: any) {
const s = (v ?? "").toString().trim();
return s ? s : "-";
}
/** 主账逐行。没有行时页面退回原来的总价字符串。 */
function asPriceLines(raw: any): PriceLine[] {
if (!Array.isArray(raw)) return [];
return raw
.map((row) => ({
name: String(row?.name ?? "").trim(),
text: String(row?.text ?? "").trim(),
}))
.filter((row) => row.name);
}
/**
* 未调价利润点必须是 0%。主账应已逐行给 0%;旧接口没给时,按成本/销售费用名补 0%。
* 禁止把金额拿来填利润点列。
*/
function displayProfitLines(row: TicketRow): PriceLine[] {
if (row.profitLines.length) return row.profitLines;
const src = row.saleLines.length ? row.saleLines : row.costLines;
if (!src.length) return [];
return src.map((line) => ({ name: line.name, text: "0%" }));
}
/**
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
* 禁止把产品报价或销售调价再显示成 TMS。
*/
function quoteSourceLabel(q: any) {
const src = String(q?.source ?? "").trim().toUpperCase();
if (src === "SALES_ADJUST") return "销售调价";
if (src === "PRODUCT") return "手工报价";
if (src === "TMS") return "TMS标准报价";
const label = String(q?.sourceLabel || q?.source || "").replace(/\s/g, "");
if (label.includes("销售调价")) return "销售调价";
if (label.includes("TMS")) return "TMS标准报价";
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]));
}
}
// 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
for (const extra of parseLandRateFees(payload)) {
if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
out.push(extra);
}
}
return out;
}
/**
* 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
* 只读 payload,不写库。海运/空运没有这些键则不出行。
*/
function parseLandRateFees(payload: Record<string, any>): TicketQuoteFee[] {
const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
const out: TicketQuoteFee[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
const included = formatLandTaxIncluded(
row.tax_included ?? row.rateFlag ?? row.rate_flag
);
if (fuel) {
const key = `fuel:${fuel}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "燃油附加费率", amount: fuel });
}
}
if (tax) {
const key = `tax:${tax}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "税率", amount: tax });
}
}
if (included) {
const key = `inc:${included}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "是否含税", amount: included });
}
}
}
return out;
}
/** 燃油附加费率:数字带 %。 */
function formatLandPercent(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (/^-?\d+(\.\d+)?$/.test(s)) {
return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
return s;
}
/** 税率 rate:0.09→9%;大于 1 按百分点。 */
function formatLandTaxRate(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
const n = Number(s);
if (!Number.isFinite(n)) return s;
const abs = Math.abs(n);
const pct = abs > 0 && abs <= 1 ? n * 100 : n;
return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
/** rateFlag:1=含税,0=不含税。 */
function formatLandTaxIncluded(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
return s;
}
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
*/
const STATUS_LOG_ACTION_LABEL: Record<string, string> = {
transition: "",
retry: "系统重试",
abnormal_handle: "异常已处理",
quote_adjust: "销售调价",
manual_quote: "手工报价",
};
function isInternalActionCode(v: string) {
return Object.prototype.hasOwnProperty.call(STATUS_LOG_ACTION_LABEL, v);
}
/**
* 时间线第二段:优先用户备注;否则把内部 action 译成中文;纯内部码且无文案则不展示。
*/
function timelineNote(x: any) {
const remark = String(x?.remark ?? "").trim();
if (remark && !isInternalActionCode(remark)) {
return remark;
}
const action = String(x?.action ?? "").trim();
const key = isInternalActionCode(remark) ? remark : action;
if (isInternalActionCode(key)) {
const label = STATUS_LOG_ACTION_LABEL[key];
return label ? label : "-";
}
return dash(action);
}
function fmtTime(v: any) {
const s = (v ?? "").toString().trim().replace("T", " ");
return s ? s.slice(0, 19) : "-";
}
function fmtMeasure(v: any, unit: string) {
if (v == null || v === "") return "-";
const s = String(v).trim();
return s ? `${s} ${unit}` : "-";
}
/** 业务线英文码 → 中文,对齐旧主账 businessLineLabel。 */
function businessLineLabel(v: any) {
const raw = String(v ?? "").trim();
const upper = raw.toUpperCase();
if (upper === "AIR") return "空运";
if (upper === "SEA") return "海运";
if (upper === "LAND") return "陆运";
if (upper === "MULTI") return "多段联运";
return raw;
}
/** FP:AIR|… 管道原文禁止当询价需求展示。 */
function looksLikeRawFp(v: any) {
const raw = String(v ?? "").trim();
return raw.startsWith("FP:") || (raw.includes("|") && raw.includes(":"));
}
/**
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
* 首行 = 业务线 · 路线;后续 = 该运输方式关心的货量 / 品名。
* 陆运整车写车型数量,海运写箱型箱量;不信 demandDetail 管道串。
*/
function requirementSummaryLines(t: any): string[] {
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
const lineLabel = String(t?.bizTypeLabel ?? "").trim() || businessLineLabel(t?.businessLine);
const origin = String(t?.origin ?? "").trim();
const destination = String(t?.destination ?? "").trim();
const route =
origin || destination ? `${origin || "-"} → ${destination || "-"}` : "";
const heading = [lineLabel, route].filter(Boolean).join(" · ");
const lines: string[] = [];
if (heading) lines.push(heading);
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}`);
if (isAir) {
const customer = String(t?.customerName ?? "").trim();
if (customer) cargo.push(`客户 ${customer}`);
}
if (cargo.length) lines.push(cargo.join(" · "));
// 后端已 enrich 时直接用;仅当结构化拼不出时才回退已有标题(仍过滤 FP 原文)
if (!lines.length) {
const title = String(t?.demandTitle ?? "").trim();
const detail = String(t?.demandDetail ?? "").trim();
if (title && !looksLikeRawFp(title)) lines.push(title);
if (detail && !looksLikeRawFp(detail)) {
detail.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).forEach((x) => lines.push(x));
}
}
return lines;
}
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow {
const attachRows: TicketAttach[] = (attachments || [])
.map((a) => ({
name: a.fileName || a.objectKey || "附件",
uploadedAt: fmtTime(a.createTime),
uploader: dash(a.createBy || a.uploader || a.uploadedBy),
}))
.filter((a) => a.name);
const origin = dash(t.origin);
const destination = dash(t.destination);
const reqLines = requirementSummaryLines(t);
const salesName = String(t.salesName ?? "").trim();
// 发起人必须显示名称;企微 userid 仅作最后兜底(主账 enrich 后应已是中文名)
const initiator = salesName || t.salesWecomId || "—";
return {
id: t.workOrderNo || t.id || "",
initiator,
demandTitle: reqLines[0] || "—",
demandDetail: reqLines.slice(1).join("\n"),
quotes: (quotes || []).map((q) => ({
source: quoteSourceLabel(q),
price: q.priceText || "-",
time: fmtTime(q.createTime),
highlight: q.highlighted === 1 || q.highlighted === true,
fees: parseQuoteFees(q),
})),
attachments: attachRows.map((a) => a.name),
attachRows,
timeline: (timeline || []).map((x) => ({
status: dash(x.toStatus || x.status),
remark: timelineNote(x),
time: fmtTime(x.createTime),
})),
status: t.status || "询价中",
costPrice: dash(t.costPrice),
salePrice: dash(t.salePrice),
profit: dash(t.profitRate),
costLines: asPriceLines(t.costLines),
saleLines: asPriceLines(t.saleLines),
profitLines: asPriceLines(t.profitLines),
eventException: dash(t.eventException),
systemException: dash(t.systemException),
systemExceptionReason: dash(t.systemExceptionReason),
createdAt: fmtTime(t.createTime),
quoteDate: dash((t.quoteDate || "").toString().slice(0, 10)),
bizType: t.bizTypeLabel || t.businessLine || "",
customerName: dash(t.customerName),
origin,
destination,
pieces: t.pieces != null && t.pieces !== "" ? `${t.pieces} 件` : "-",
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),
dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary),
};
}
function quoteHasFees(row: TicketRow) {
return (row.quotes || []).some((q) => (q.fees || []).length > 0);
}
/**
* 报价信息 / 成本价 / 销售价 / 利润点共用一把「展开」:
* 收起时只留摘要(报价来源+总额、费用合计行),展开才看逐费明细。
* 避免只收起报价列、成本/利润仍撑很高。
*/
const priceExpandedMap = reactive<Record<string, boolean>>({});
const detailPriceExpanded = ref(false);
function detailPriceNeedsToggle(row: TicketRow | null) {
if (!row) return false;
return priceBlockNeedsToggle(row);
}
function isPriceExpanded(id: string) {
return !!priceExpandedMap[id];
}
function togglePriceExpanded(id: string) {
priceExpandedMap[id] = !priceExpandedMap[id];
}
function priceBlockNeedsToggle(row: TicketRow) {
return (
quoteHasFees(row) ||
row.costLines.length > 1 ||
row.saleLines.length > 1 ||
displayProfitLines(row).length > 1
);
}
/** 收起:优先只留「费用合计」;没有合计则留首行,避免整列空白。 */
function visiblePriceLines(lines: PriceLine[], expanded: boolean): PriceLine[] {
if (!lines.length) return [];
if (expanded || lines.length <= 1) return lines;
const totals = lines.filter((l) => l.name === "费用合计");
return totals.length ? totals : lines.slice(0, 1);
}
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
function demandDetailLines(row: TicketRow) {
return String(row?.demandDetail || "")
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
}
async function loadList() {
loading.value = true;
try {
const res: any = await getWorkOrderList({
keyword: filters.keyword.trim() || undefined,
status: filters.status || undefined,
eventException: filters.eventException || undefined,
systemException: filters.systemException || undefined,
bizType: filters.bizType || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t, t.quotes || []));
// 删减后当前页可能越界,回退到最后一页
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
}
} catch (e: any) {
message.error(e?.message || "加载工单失败");
} finally {
loading.value = false;
}
}
let timer: ReturnType<typeof setTimeout> | null = null;
/** 筛选变更:回到第 1 页再拉列表 */
watch(
() => ({ ...filters }),
() => {
pageNo.value = 1;
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadList(), 250);
},
{ deep: true },
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
async function openDetail(row: TicketRow) {
current.value = row;
detailPriceExpanded.value = false;
detailOpen.value = true;
try {
const res: any = await getWorkOrderDetail(row.id);
const detail = res?.ticket ? res : res?.result || res || {};
const ticket = detail.ticket || detail;
current.value = mapTicket(ticket, detail.quotes || [], detail.attachments || [], detail.timeline || []);
const idx = rows.value.findIndex((r) => r.id === row.id);
if (idx >= 0) rows.value[idx] = current.value;
} catch (e: any) {
message.error(e?.message || "加载详情失败");
}
}
function openClose(row: TicketRow) {
if (!canShowCloseForStatus(row.status)) {
message.warning(`「${row.status}」为终态,不能关闭工单`);
return;
}
current.value = row;
closeReason.value = "";
closeOpen.value = true;
}
async function confirmClose() {
if (!current.value) return;
try {
await transitionWorkOrder({
workOrderNo: current.value.id,
toStatus: "已关闭",
remark: closeReason.value.trim() || undefined,
});
closeOpen.value = false;
message.success(`工单 ${current.value.id} 已关闭`);
await loadList();
} catch (e: any) {
message.error(e?.message || "关单失败");
}
}
function retryTicket(row: TicketRow) {
Modal.confirm({
title: "重试系统任务?",
content: `确认对工单 ${row.id} 发起重试?`,
async onOk() {
try {
await retryAbnormal({ workOrderNo: row.id });
message.success(`已提交重试:${row.id}`);
await loadList();
} catch (e: any) {
message.error(e?.message || "重试失败");
}
},
});
}
function refresh() {
loadList().then(() => message.success("已刷新"));
}
/**
* 按当前筛选导出 Excel(与列表同源,不分页;主账上限 2 万行)。
*/
async function exportTickets() {
if (!canExportTicket()) {
message.warning("无导出权限");
return;
}
if (exporting.value) return;
exporting.value = true;
try {
await exportWorkOrders({
keyword: filters.keyword.trim() || undefined,
status: filters.status || undefined,
eventException: filters.eventException || undefined,
systemException: filters.systemException || undefined,
bizType: filters.bizType || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
});
message.success("已开始下载 Excel");
} catch (e: any) {
message.error(e?.message || "导出失败");
} finally {
exporting.value = false;
}
}
</script>
<template>
<Teleport defer to="#page-topbar-actions">
<div style="display: flex; align-items: center; gap: 12px">
<button
v-if="canExportTicket()"
type="button"
class="btn btn-secondary"
:disabled="exporting"
@click="exportTickets"
>
<ProtoIcon name="download" :size="16" />{{ exporting ? "导出中…" : "导出工单" }}
</button>
<button type="button" class="btn btn-primary" @click="refresh">
<ProtoIcon name="refresh" :size="16" />刷新
</button>
</div>
</Teleport>
<div class="ticket-page is-list-page">
<div class="section-card">
<div class="section-card-header">
<h2 class="section-card-title">询价工单列表</h2>
</div>
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="filters.keyword" placeholder="搜索工单号、销售、路线..." />
</div>
<select v-model="filters.status">
<option value="">状态 · 全部</option>
<option value="询价中">询价中</option>
<option value="已报价">已报价</option>
<option value="协商中">协商中</option>
<option value="转人工">转人工</option>
<option value="已成交">已成交</option>
<option value="未成交">未成交</option>
<option value="已关闭">已关闭</option>
</select>
<select v-model="filters.eventException">
<option value="">事件异常 · 全部</option>
<option value="销售响应超时">销售响应超时</option>
<option value="产品响应超时">产品响应超时</option>
<option value="航线响应超时">航线响应超时</option>
<option value="-">无</option>
</select>
<select v-model="filters.systemException">
<option value="">系统异常 · 全部</option>
<option value="大模型异常">大模型异常</option>
<option value="TMS异常">TMS异常</option>
<option value="建群异常">建群异常</option>
<option value="-">无</option>
</select>
<select v-model="filters.bizType">
<option value="">业务类型 · 全部</option>
<option value="空运">空运</option>
<option value="海运">海运</option>
<option value="陆运">陆运</option>
<option value="多段联运">多段联运</option>
</select>
<div class="toolbar-date-range" title="按创建时间筛选">
<a-range-picker
v-model:value="dateRange"
class="toolbar-range-picker"
value-format="YYYY-MM-DD"
separator="至"
:placeholder="['开始日期', '结束日期']"
:allow-clear="true"
:input-read-only="true"
>
<template #suffixIcon>
<ProtoIcon name="calendar" :size="14" />
</template>
</a-range-picker>
</div>
</div>
<div class="table-wrap ticket-table-scroll">
<table class="data-table ticket-list-table">
<colgroup>
<col class="col-w-id" />
<col class="col-w-user" />
<col class="col-w-demand" />
<col class="col-w-quote" />
<col class="col-w-attach" />
<col class="col-w-status" />
<col class="col-w-price" />
<col class="col-w-price" />
<col class="col-w-margin" />
<col class="col-w-ex" />
<col class="col-w-ex" />
<col class="col-w-reason" />
<col class="col-w-time" />
<col class="col-w-actions" />
</colgroup>
<thead>
<tr>
<th class="col-sticky-left">工单编号</th>
<th>发起人</th>
<th>询价需求</th>
<th>报价信息</th>
<th>附件</th>
<th>单据状态</th>
<th>成本价</th>
<th>销售价</th>
<th>利润点</th>
<th class="col-center">事件异常</th>
<th>系统异常</th>
<th>系统异常原因</th>
<th>创建时间</th>
<th class="col-actions col-sticky-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="14" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="14" style="text-align: center; color: #94a3b8">暂无工单</td>
</tr>
<tr v-for="row in rows" :key="row.id">
<td class="align-top ticket-cell col-sticky-left">
<button class="ticket-no" type="button" @click="openDetail(row)">
{{ row.id }}
</button>
</td>
<td class="align-top">
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
</td>
<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 col-quote">
<div class="ticket-price-expand">
<template v-if="row.quotes.length">
<div
v-for="q in row.quotes"
:key="q.source + q.price"
class="quote-row"
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
<template v-if="isPriceExpanded(row.id) && 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>
<span v-else style="color: #94a3b8">-</span>
<button
v-if="priceBlockNeedsToggle(row)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': isPriceExpanded(row.id) }"
:title="isPriceExpanded(row.id) ? '收起费用明细' : '展开费用明细'"
@click.stop="togglePriceExpanded(row.id)"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</td>
<td class="align-top">
<div v-if="row.attachments.length" class="attach-tags">
<span v-for="f in row.attachments" :key="f" class="tag">{{ f }}</span>
</div>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
<td class="align-top">
<div
v-if="visiblePriceLines(row.costLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(row.costLines, isPriceExpanded(row.id))"
:key="row.id + '-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
</td>
<td class="align-top">
<div
v-if="visiblePriceLines(row.saleLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(row.saleLines, isPriceExpanded(row.id))"
:key="row.id + '-sale-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
</td>
<td class="align-top">
<div
v-if="visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id))"
:key="row.id + '-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else class="ticket-margin-cell">{{ row.profit === "-" ? "0%" : row.profit }}</span>
</td>
<td class="col-center">
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top">
<span v-if="row.systemException !== '-'" class="tag tag-rose">{{ row.systemException }}</span>
<span v-else style="color: #94a3b8">-</span>
</td>
<td class="align-top">
<span style="font-size: 10px; color: #475569; line-height: 1.5">{{
row.systemExceptionReason
}}</span>
</td>
<td class="align-top" style="white-space: nowrap; font-size: 12px; color: #334155">
{{ row.createdAt }}
</td>
<td class="align-top col-actions col-sticky-right">
<div class="ticket-row-actions">
<button
v-if="canRetryTicket() && row.systemException !== '-' && row.status !== '已关闭'"
class="link-action"
type="button"
@click="retryTicket(row)"
>
重试
</button>
<button
v-if="canShowCloseForStatus(row.status)"
class="link-action"
type="button"
@click="openClose(row)"
>
关闭工单
</button>
<button
v-if="canViewDetail()"
class="link-action"
type="button"
@click="openDetail(row)"
>
查看详情
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="条工单"
:disabled="loading"
@change="onPageChange"
/>
</div>
</div>
<AdminModal
v-model:open="detailOpen"
title="工单详情"
size="xxl"
cancel-text="关闭"
:show-ok="false"
>
<template #titleExtra>
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
<button
v-if="
canRetryTicket() &&
current &&
current.systemException !== '-' &&
current.status !== '已关闭'
"
class="link-action"
type="button"
@click="retryTicket(current)"
>
重试
</button>
</template>
<div v-if="current" class="ticket-detail">
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="tickets" :size="14" />工单基础信息
</div>
<div class="ticket-detail-grid">
<div class="ticket-detail-item"><label>发起销售</label><span>{{ current.initiator }}</span></div>
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
<div class="ticket-detail-item"><label>当前状态</label><span>{{ current.status }}</span></div>
<div class="ticket-detail-item"><label>报价日期</label><span>{{ current.quoteDate }}</span></div>
<div class="ticket-detail-item"><label>创建时间</label><span>{{ current.createdAt }}</span></div>
<div class="ticket-detail-item"><label>事件异常</label><span>{{ current.eventException }}</span></div>
<div class="ticket-detail-item"><label>系统异常</label><span>{{ current.systemException }}</span></div>
<div class="ticket-detail-item"><label>系统异常原因</label><span>{{ current.systemExceptionReason }}</span></div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交时间</label><span>{{ current.updateTime }}</span>
</div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交通知</label><span>{{ current.dealNotifySummary }}</span>
</div>
<div v-if="current.status === '未成交'" class="ticket-detail-item">
<label>未成交原因</label><span>{{ current.lostReason }}</span>
</div>
<div v-if="current.status === '协商中'" class="ticket-detail-item">
<label>下次再问</label><span>{{ current.nextFollowupAt }}</span>
</div>
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="gauge" :size="14" />价格与利润
</div>
<div class="ticket-price-expand">
<div class="ticket-detail-grid">
<div class="ticket-detail-item">
<label>成本价</label>
<div
v-if="visiblePriceLines(current.costLines, detailPriceExpanded).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(current.costLines, detailPriceExpanded)"
:key="'detail-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else>{{ current.costPrice }}</span>
</div>
<div class="ticket-detail-item">
<label>销售价</label>
<div
v-if="visiblePriceLines(current.saleLines, detailPriceExpanded).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(current.saleLines, detailPriceExpanded)"
:key="'detail-sale-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else>{{ current.salePrice }}</span>
</div>
<div class="ticket-detail-item">
<label>利润点</label>
<div
v-if="visiblePriceLines(displayProfitLines(current), detailPriceExpanded).length"
class="ticket-price-lines"
>
<div
v-for="(line, i) in visiblePriceLines(displayProfitLines(current), detailPriceExpanded)"
:key="'detail-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div>
<span v-else>{{ current.profit === "-" ? "0%" : current.profit }}</span>
</div>
</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="detailPriceExpanded && 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>
<button
v-if="detailPriceNeedsToggle(current)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': detailPriceExpanded }"
:title="detailPriceExpanded ? '收起费用明细' : '展开费用明细'"
@click="detailPriceExpanded = !detailPriceExpanded"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="route" :size="14" />询价需求
</div>
<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
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>
</ExpandableClamp>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="fileText" :size="14" />附件记录
</div>
<table v-if="current.attachRows.length" class="ticket-detail-attach">
<thead>
<tr>
<th>附件名称</th>
<th>上传时间</th>
<th>上传人</th>
</tr>
</thead>
<tbody>
<tr v-for="f in current.attachRows" :key="f.name + f.uploadedAt">
<td class="ticket-detail-attach-name">{{ f.name }}</td>
<td>{{ f.uploadedAt }}</td>
<td>{{ f.uploader }}</td>
</tr>
</tbody>
</table>
<div v-else class="ticket-detail-empty">暂无附件</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="clock" :size="14" />状态流转与备注
</div>
<div v-if="current.timeline.length" class="ticket-detail-timeline">
<div v-for="(item, idx) in current.timeline" :key="item.time + idx" class="ticket-detail-tl-item">
<div>
<b>{{ item.status }}</b>
<span v-if="item.remark !== '-'"> · {{ item.remark }}</span>
</div>
<span class="ticket-detail-tl-time">{{ item.time }}</span>
</div>
</div>
<div v-else class="ticket-detail-empty">暂无流转记录</div>
</section>
</div>
<template #footer>
<div class="ticket-detail-foot">
<span class="ticket-detail-updated">最后更新:{{ current?.updateTime || "-" }}</span>
<button type="button" class="btn btn-secondary" @click="detailOpen = false">
<ProtoIcon name="x" :size="14" />关闭
</button>
</div>
</template>
</AdminModal>
<AdminModal
v-model:open="closeOpen"
title="关闭工单"
:subtitle="current ? `工单 ${current.id}` : ''"
size="md"
ok-text="确认关闭"
@ok="confirmClose"
>
<div class="form-row">
<label class="lbl">关闭原因</label>
<textarea v-model="closeReason" class="field" rows="4" placeholder="关闭原因(可选)" />
</div>
</AdminModal>
</template>
<style scoped>
/* 询价需求列:对齐旧后台 summary-cell(首行加粗,后续灰色小字) */
.summary-cell {
min-width: 230px;
white-space: normal;
word-break: break-word;
line-height: 1.55;
}
.summary-cell strong,
.summary-cell span {
display: block;
}
.summary-cell strong {
font-size: 12px;
font-weight: 800;
color: #0f172a;
}
.summary-cell span {
margin-top: 3px;
color: #708090;
font-size: 12px;
}
/* 报价+成本+利润共用展开:箭头管整块费用明细 */
.ticket-price-expand {
position: relative;
min-width: 0;
}
.ticket-price-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 4px;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
line-height: 1;
}
.ticket-price-toggle:hover {
color: #1d4ed8;
}
.ticket-price-toggle :deep(svg) {
transition: transform 0.15s ease;
}
.ticket-price-toggle.is-open :deep(svg) {
transform: rotate(180deg);
}
</style>