主账支持企微通讯录同步人员,并完善工单展示与后台人员/工单页联调能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 20:26:36 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 624ec1b7c4
13 changed files with 1173 additions and 42 deletions
@@ -72,6 +72,37 @@ function dash(v: any) {
return s ? s : "-";
}
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
*/
const STATUS_LOG_ACTION_LABEL: Record<string, string> = {
transition: "",
retry: "系统重试",
abnormal_handle: "异常已处理",
};
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) : "-";
@@ -83,6 +114,90 @@ function fmtMeasure(v: any, unit: string) {
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(`毛重 ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
} else if (isSea) {
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
} else if (isLand) {
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
if (weight != null && weight !== "") quantities.push(`重量 ${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(`重量 ${plain(weight)}kg`);
if (volume != null && volume !== "") quantities.push(`体积 ${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) => ({
@@ -93,15 +208,15 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
.filter((a) => a.name);
const origin = dash(t.origin);
const destination = dash(t.destination);
const route =
origin !== "-" || destination !== "-"
? `${origin === "-" ? "" : origin} → ${destination === "-" ? "" : destination}`.trim()
: "";
const reqLines = requirementSummaryLines(t);
const salesName = String(t.salesName ?? "").trim();
// 发起人必须显示名称;企微 userid 仅作最后兜底(主账 enrich 后应已是中文名)
const initiator = salesName || t.salesWecomId || "—";
return {
id: t.workOrderNo || t.id || "",
initiator: t.salesName || t.salesWecomId || "—",
demandTitle: t.demandTitle || route || "—",
demandDetail: t.demandDetail || "",
initiator,
demandTitle: reqLines[0] || "—",
demandDetail: reqLines.slice(1).join("\n"),
quotes: (quotes || []).map((q) => ({
source: q.sourceLabel || q.source || "报价",
price: q.priceText || "-",
@@ -111,7 +226,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
attachRows,
timeline: (timeline || []).map((x) => ({
status: dash(x.toStatus || x.status),
remark: dash(x.remark || x.action),
remark: timelineNote(x),
time: fmtTime(x.createTime),
})),
status: t.status || "询价中",
@@ -142,6 +257,14 @@ function routeText(row: TicketRow) {
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 {
@@ -350,17 +473,13 @@ function refresh() {
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
</td>
<td class="align-top">
<div style="font-weight: 800">{{ row.demandTitle }}</div>
<div
style="
font-size: 10px;
color: #64748b;
margin-top: 4px;
white-space: pre-line;
line-height: 1.5;
"
>
{{ row.demandDetail }}
<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">
@@ -491,8 +610,9 @@ function refresh() {
<ProtoIcon name="route" :size="14" />询价需求
</div>
<div class="ticket-detail-demand">
<div style="font-weight: 800; color: #0f172a">
{{ dash(current.bizType) }}:{{ routeText(current) }}
<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>
@@ -503,7 +623,6 @@ function refresh() {
<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 v-if="current.demandDetail" class="ticket-detail-note">{{ current.demandDetail }}</div>
</div>
</section>
@@ -570,3 +689,27 @@ function refresh() {
</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>