停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。 Co-authored-by: Cursor <cursoragent@cursor.com>
806 lines
30 KiB
Vue
806 lines
30 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 {
|
||
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>
|