机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
@@ -3,7 +3,7 @@
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { message } from "ant-design-vue";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import { getDashboardMetrics } from "/@/views/inquiry/dashboard/dashboard.api";
@@ -100,6 +100,28 @@ async function loadMetrics() {
const res: any = await getDashboardMetrics(req);
const data = res?.result || res || {};
todayMetrics.value = (data.metrics || []) as MetricCard[];
// #region agent log
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId: "post-fix",
hypothesisId: "THOUSAND_SEP",
location: "dashboard/index.vue:loadMetrics",
message: "指标千分位展示抽样",
data: {
samples: todayMetrics.value.slice(0, 9).map((m) => ({
key: m.key,
raw: m.value,
display: formatMetricDisplay(m),
isTime: !!m.isTime,
})),
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
date: String(p.date || ""),
value: Number(p.value || 0),
@@ -202,6 +224,36 @@ function formatCost(v: number | null | undefined) {
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
/**
* 看板统计数字千分位:百分比、时长原样;件数/金额加分组符。
* 主账已格式化时再跑一遍也安全(先去逗号再 format)。
*/
function formatGroupedNumberText(numStr: string): string {
const cleaned = String(numStr || "").replace(/,/g, "").trim();
if (!/^\d+(\.\d+)?$/.test(cleaned)) return numStr;
const n = Number(cleaned);
if (Number.isNaN(n)) return numStr;
if (cleaned.includes(".")) {
const frac = cleaned.split(".")[1].length;
return n.toLocaleString("zh-CN", { minimumFractionDigits: frac, maximumFractionDigits: frac });
}
return n.toLocaleString("zh-CN");
}
function formatMetricDisplay(m: MetricCard): string {
if (m.isTime) return m.value;
const raw = String(m.value ?? "");
if (!raw || raw === "-" || raw.includes("%")) return raw;
const match = raw.match(/^([\d,]+(?:\.\d+)?)(.*)$/);
if (!match) return raw;
return formatGroupedNumberText(match[1]) + match[2];
}
function formatCountDisplay(n: number): string {
if (n == null || Number.isNaN(n)) return "-";
return Number(n).toLocaleString("zh-CN");
}
const inquirySvg = computed(() => {
const points = inquiryTrend.value.length
? inquiryTrend.value
@@ -237,26 +289,6 @@ const costDates = computed(() =>
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
);
const costSampleDays = computed(() => {
const set = new Set<string>();
for (const r of costRoutes.value) {
r.values.forEach((v, i) => {
if (v != null && !Number.isNaN(v) && costDates.value[i]) set.add(costDates.value[i]);
});
}
return [...set];
});
const costFootnote = computed(() => {
if (!costRoutes.value.length) {
return "近 7 日暂无带金额的工单,有报价后将显示线路成本趋势";
}
const days = costSampleDays.value;
if (!days.length) {
return "已选线路在近 7 日无金额样本";
}
return `成本来自主账工单(成本价优先,缺省销售价);有金额日期:${days.join("、")},其余日悬停为 —`;
});
const costXs = computed(() => {
const n = Math.max(costDates.value.length, 1);
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
@@ -290,7 +322,7 @@ function showInquiryTip(date: string, value: number, x: number, y: number, wrapE
left: `${(x / 760) * box.width}px`,
top: `${(y / 240) * box.height}px`,
date,
value: `${value} 单/日`,
value: `${formatCountDisplay(value)} 单/日`,
lines: [],
};
}
@@ -331,7 +363,7 @@ function exportRanking() {
row.name,
row.dept,
roleLabels[row.role] || row.role,
`${row.volumeNum}单`,
`${formatCountDisplay(row.volumeNum)}单`,
]);
const csv = [header, ...body]
.map((cols) => cols.map((col) => `"${String(col).replace(/"/g, '""')}"`).join(","))
@@ -351,6 +383,92 @@ function onDocClick(e: MouseEvent) {
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
/**
* 调试:打开线路筛选时采集弹层/chips/祖先裁切与可滚尺寸。
*/
function probeRouteFilterMenu(runId: string) {
// #region agent log
const menu = document.querySelector(".ref-route-filter__menu") as HTMLElement | null;
const chips = document.querySelector(".ref-route-filter__chips") as HTMLElement | null;
const filter = document.querySelector(".ref-route-filter") as HTMLElement | null;
if (!menu || !chips) {
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId,
hypothesisId: "H2",
location: "dashboard/index.vue:probeRouteFilterMenu",
message: "弹层或 chips 未找到",
data: { hasMenu: !!menu, hasChips: !!chips },
timestamp: Date.now(),
}),
}).catch(() => {});
return;
}
const menuCs = getComputedStyle(menu);
const chipsCs = getComputedStyle(chips);
const menuRect = menu.getBoundingClientRect();
const chipsRect = chips.getBoundingClientRect();
const clipAncestors: Array<{ tag: string; cls: string; overflow: string; overflowY: string; rectBottom: number }> = [];
let node: HTMLElement | null = filter;
while (node && node !== document.body) {
const cs = getComputedStyle(node);
if (cs.overflow !== "visible" || cs.overflowY !== "visible" || cs.overflowX !== "visible") {
clipAncestors.push({
tag: node.tagName.toLowerCase(),
cls: (node.className || "").toString().slice(0, 80),
overflow: cs.overflow,
overflowY: cs.overflowY,
rectBottom: Math.round(node.getBoundingClientRect().bottom),
});
}
node = node.parentElement;
}
const spaceBelow = Math.round(window.innerHeight - menuRect.top);
fetch("http://127.0.0.1:7800/ingest/a69bf0d0-32e3-486e-80eb-ae1824ba17a1", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7b2ee5" },
body: JSON.stringify({
sessionId: "7b2ee5",
runId,
hypothesisId: "H1-H5",
location: "dashboard/index.vue:probeRouteFilterMenu",
message: "线路筛选弹层尺寸与溢出",
data: {
routeCount: costRoutes.value.length,
menu: {
h: Math.round(menuRect.height),
bottom: Math.round(menuRect.bottom),
overflow: menuCs.overflow,
overflowY: menuCs.overflowY,
maxHeight: menuCs.maxHeight,
},
chips: {
h: Math.round(chipsRect.height),
clientH: chips.clientHeight,
scrollH: chips.scrollHeight,
canScroll: chips.scrollHeight > chips.clientHeight + 1,
overflow: chipsCs.overflow,
overflowY: chipsCs.overflowY,
maxHeight: chipsCs.maxHeight,
},
viewport: { innerH: window.innerHeight, spaceBelow, menuClippedByViewport: menuRect.bottom > window.innerHeight + 1 },
clipAncestors,
},
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
watch(routeFilterOpen, async (open) => {
if (!open) return;
await nextTick();
requestAnimationFrame(() => probeRouteFilterMenu("post-fix"));
});
const inquiryWrap = ref<HTMLElement | null>(null);
const costWrap = ref<HTMLElement | null>(null);
@@ -409,7 +527,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span class="ref-metric-tip">{{ m.tip }}</span>
</button>
</div>
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ m.value }}</div>
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ formatMetricDisplay(m) }}</div>
</div>
</div>
</div>
@@ -447,7 +565,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<text v-for="c in inquirySvg.coords" :key="'d-' + c.date" :x="c.x" y="218">{{ c.date }}</text>
</g>
<g fill="#0f172a" font-size="8" font-weight="800" text-anchor="middle">
<text v-for="c in inquirySvg.coords" :key="'v-' + c.date" :x="c.x" :y="c.y - 11">{{ c.value }}</text>
<text v-for="c in inquirySvg.coords" :key="'v-' + c.date" :x="c.x" :y="c.y - 11">{{ formatCountDisplay(c.value) }}</text>
</g>
<g
v-for="c in inquirySvg.coords"
@@ -575,9 +693,6 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span v-for="(line, i) in costTip.lines" :key="i" class="value-line">{{ line }}</span>
</div>
</div>
<div class="dash-cost-footnote">
{{ costFootnote }}
</div>
</div>
</div>
</div>
@@ -625,7 +740,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<span class="col-name">{{ row.name }}</span>
<span class="col-dept">{{ row.dept }}</span>
</div>
<span class="col-volume">{{ row.volumeNum }} 单</span>
<span class="col-volume">{{ formatCountDisplay(row.volumeNum) }} 单</span>
</div>
</div>
</div>
@@ -682,13 +797,6 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
z-index: 120;
}
.dash-cost-footnote {
margin-top: 12px;
font-size: 10px;
font-weight: 700;
color: #94a3b8;
}
.ref-surface--metrics,
.ref-surface--metrics .ref-metrics-grid,
.ref-surface--metrics .ref-metric-card {