Files
inquiry_robot/inquiry-backend/src/views/inquiry/workOrder/index.vue
T
jillion886andCursor 378bb892b3 陆运选项不全时改为复制填空清单,方便销售按编号回填。
停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:10:13 +08:00

806 lines
30 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 {
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";
type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean };
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;
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;
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 : "-";
}
/**
* 后台报价来源: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 !== "报价" ? "手工报价" : "报价";
}
/**
* 主账 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 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 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,
})),
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),
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),
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 routeText(row: TicketRow) {
if (row.origin !== "-" || row.destination !== "-") {
return `${row.origin === "-" ? "" : row.origin} → ${row.destination === "-" ? "" : row.destination}`.trim();
}
return row.demandTitle || "-";
}
/** 列表询价需求第二行起:与旧后台 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;
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) {
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("已刷新"));
}
</script>
<template>
<Teleport defer to="#page-topbar-actions">
<div style="display: flex; align-items: center; gap: 12px">
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
<ProtoIcon name="download" :size="16" />导出工单
</button>
<button type="button" class="btn btn-primary" @click="refresh">
<ProtoIcon name="refresh" :size="16" />刷新
</button>
</div>
</Teleport>
<div class="ticket-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">
<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>
<td class="align-top">
<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>
</div>
</template>
<span v-else style="color: #94a3b8">-</span>
</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">
<span class="ticket-price-cell">{{ row.costPrice }}</span>
</td>
<td class="align-top">
<span class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
</td>
<td class="align-top">
<span class="ticket-margin-cell">{{ 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="row.systemException !== '-' && row.status !== '已关闭'"
class="link-action"
type="button"
@click="retryTicket(row)"
>
重试
</button>
<button
v-if="row.status !== '已关闭'"
class="link-action"
type="button"
@click="openClose(row)"
>
关闭工单
</button>
<button 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="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-detail-grid">
<div class="ticket-detail-item"><label>成本价</label><span>{{ current.costPrice }}</span></div>
<div class="ticket-detail-item"><label>销售价</label><span>{{ current.salePrice }}</span></div>
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
</div>
<div v-if="current.quotes.length" class="ticket-detail-demand">
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
</div>
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-detail-title">
<ProtoIcon name="route" :size="14" />询价需求
</div>
<div class="ticket-detail-demand">
<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>
</div>
</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;
}
</style>