1294 lines
46 KiB
Vue
1294 lines
46 KiB
Vue
<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>
|