分析看板补齐成本趋势与排名:主账聚合近7日线路均价,后台按真实数据绘图。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
||||
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
|
||||
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
@@ -13,8 +13,12 @@ const todayMetrics = ref<MetricCard[]>([]);
|
||||
const inquiryTrend = ref<InquiryPoint[]>([]);
|
||||
const rankingPeople = ref<RankingPerson[]>([]);
|
||||
const rankingDepts = ref<string[]>([]);
|
||||
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
|
||||
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
|
||||
const costRoutes = ref<CostRoute[]>([]);
|
||||
const costDateLabels = ref<string[]>([]);
|
||||
|
||||
const COST_PLOT_TOP = 36;
|
||||
const COST_PLOT_BOTTOM = 186;
|
||||
|
||||
const now = new Date();
|
||||
const isoDay = now.toISOString().slice(0, 10);
|
||||
@@ -87,6 +91,15 @@ async function loadMetrics() {
|
||||
rankingDepts.value = Array.from(
|
||||
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
|
||||
);
|
||||
|
||||
const dates: string[] = Array.isArray(data.costDates) && data.costDates.length
|
||||
? data.costDates.map((d: any) => String(d))
|
||||
: inquiryTrend.value.map((p) => p.date);
|
||||
costDateLabels.value = dates;
|
||||
const rawRoutes = (data.costRoutes || []) as any[];
|
||||
costRoutes.value = buildCostRouteViews(rawRoutes, dates);
|
||||
selectedRoutes.value = costRoutes.value.map((r) => r.id);
|
||||
|
||||
const t = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
|
||||
@@ -97,6 +110,76 @@ async function loadMetrics() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把主账 costRoutes.values 转成 SVG path + 数据点。
|
||||
* 只有一天有金额时 path 只有 M 看不见线,必须画 points。
|
||||
*/
|
||||
function buildCostRouteViews(rawRoutes: any[], dates: string[]): CostRoute[] {
|
||||
const n = Math.max(dates.length, 1);
|
||||
const xs = dates.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||||
const parsed = rawRoutes.map((r) => {
|
||||
const values = Array.isArray(r.values)
|
||||
? r.values.map((v: any) => (v == null || v === "" ? null : Number(v)))
|
||||
: dates.map(() => null);
|
||||
while (values.length < dates.length) values.push(null);
|
||||
return {
|
||||
id: String(r.id || r.label || "route"),
|
||||
label: String(r.label || r.id || "线路"),
|
||||
color: String(r.color || "#2563eb"),
|
||||
values: values.slice(0, dates.length) as Array<number | null>,
|
||||
};
|
||||
});
|
||||
const nums = parsed.flatMap((r) => r.values.filter((v): v is number => v != null && !Number.isNaN(v) && v > 0));
|
||||
const max = Math.max(...nums, 1);
|
||||
// 单点时 min==max 会压成一条横线贴顶/贴底,留 10% 边距更易看见
|
||||
let min = nums.length ? Math.min(...nums) : 0;
|
||||
if (nums.length && max - min < max * 0.05) {
|
||||
min = Math.max(0, min - max * 0.1);
|
||||
}
|
||||
const range = Math.max(max - min, 1);
|
||||
const plotH = COST_PLOT_BOTTOM - COST_PLOT_TOP;
|
||||
const yFor = (v: number) => COST_PLOT_BOTTOM - ((v - min) / range) * plotH;
|
||||
return parsed.map((r) => {
|
||||
const points: Array<{ x: number; y: number; index: number }> = [];
|
||||
r.values.forEach((v, i) => {
|
||||
if (v == null || Number.isNaN(v)) return;
|
||||
points.push({ x: xs[i], y: yFor(v), index: i });
|
||||
});
|
||||
return {
|
||||
...r,
|
||||
path: pathFromValues(r.values, xs, yFor),
|
||||
points,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 有值连线,无值断点(不造假插值)。 */
|
||||
function pathFromValues(
|
||||
values: Array<number | null>,
|
||||
xs: number[],
|
||||
yFor: (v: number) => number,
|
||||
): string {
|
||||
let d = "";
|
||||
let started = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v == null || Number.isNaN(v)) {
|
||||
started = false;
|
||||
continue;
|
||||
}
|
||||
const x = xs[i];
|
||||
const y = yFor(v);
|
||||
d += `${started ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)} `;
|
||||
started = true;
|
||||
}
|
||||
return d.trim();
|
||||
}
|
||||
|
||||
function formatCost(v: number | null | undefined) {
|
||||
if (v == null || Number.isNaN(v)) return "-";
|
||||
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const inquirySvg = computed(() => {
|
||||
const points = inquiryTrend.value.length
|
||||
? inquiryTrend.value
|
||||
@@ -128,7 +211,30 @@ const rankingRows = computed(() => {
|
||||
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
|
||||
});
|
||||
|
||||
const costDates = computed(() => inquiryTrend.value.map((p) => p.date));
|
||||
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));
|
||||
@@ -180,17 +286,15 @@ function hideInquiryTip() {
|
||||
chartTip.value.visible = false;
|
||||
}
|
||||
|
||||
function showCostTip(date: string, x: number, wrapEl: HTMLElement | null) {
|
||||
function showCostTip(date: string, x: number, dateIndex: number, wrapEl: HTMLElement | null) {
|
||||
if (!wrapEl) return;
|
||||
const box = wrapEl.getBoundingClientRect();
|
||||
const routes = filteredRoutes.value;
|
||||
const lines =
|
||||
routes.length === 0
|
||||
? ["未选择线路"]
|
||||
: [
|
||||
...routes.slice(0, 3).map((r) => r.label),
|
||||
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
|
||||
];
|
||||
: routes.slice(0, 6).map((r) => `${r.label}: ${formatCost(r.values[dateIndex])}`);
|
||||
if (routes.length > 6) lines.push(`等 ${routes.length} 条`);
|
||||
costTip.value = {
|
||||
visible: true,
|
||||
left: `${(x / 760) * box.width}px`,
|
||||
@@ -434,11 +538,24 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- 有金额的日子画点:近 7 日往往只有一两天有价,单点折线不可见 -->
|
||||
<g v-for="r in filteredRoutes" :key="'pts-' + r.id">
|
||||
<circle
|
||||
v-for="p in r.points"
|
||||
:key="r.id + '-' + p.index"
|
||||
:cx="p.x"
|
||||
:cy="p.y"
|
||||
r="4"
|
||||
:fill="r.color"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
v-for="(x, i) in costXs"
|
||||
:key="'cx-' + costDates[i]"
|
||||
class="cost-point"
|
||||
@mouseenter="showCostTip(costDates[i], x, costWrap)"
|
||||
@mouseenter="showCostTip(costDates[i], x, i, costWrap)"
|
||||
@mouseleave="hideCostTip"
|
||||
>
|
||||
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
|
||||
@@ -458,7 +575,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-cost-footnote">
|
||||
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
|
||||
{{ costFootnote }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user