820 lines
29 KiB
Vue
820 lines
29 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 分析看板:对接主账 /inquiry/dashboard/metrics。
|
||
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
|
||
*/
|
||
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";
|
||
import type { MetricCard, InquiryPoint, RankingPerson, CostRoute } from "/@/views/inquiry/mock/dashboard";
|
||
|
||
const todayMetrics = ref<MetricCard[]>([]);
|
||
const inquiryTrend = ref<InquiryPoint[]>([]);
|
||
const rankingPeople = ref<RankingPerson[]>([]);
|
||
const rankingDepts = ref<string[]>([]);
|
||
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
|
||
const costRoutes = ref<CostRoute[]>([]);
|
||
const costDateLabels = ref<string[]>([]);
|
||
|
||
const COST_PLOT_TOP = 36;
|
||
const COST_PLOT_BOTTOM = 186;
|
||
|
||
const now = new Date();
|
||
/** 用本地日期,避免 toISOString 因时区偏到前一天 */
|
||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||
const isoDay = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
||
/** 默认:当月 1 号 → 今天(不是当天到当天) */
|
||
const isoMonthStart = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-01`;
|
||
const dateFrom = ref(isoMonthStart);
|
||
const dateTo = ref(isoDay);
|
||
const updatedTime = ref("00:00");
|
||
const routeFilterOpen = ref(false);
|
||
const selectedRoutes = ref<string[]>([]);
|
||
const rankingRole = ref<"all" | "sales" | "route" | "product">("all");
|
||
const rankingDept = ref("");
|
||
const chartReady = ref(false);
|
||
const loading = ref(false);
|
||
const chartTip = ref({
|
||
visible: false,
|
||
left: "0px",
|
||
top: "0px",
|
||
date: "",
|
||
value: "",
|
||
lines: [] as string[],
|
||
});
|
||
const costTip = ref({
|
||
visible: false,
|
||
left: "0px",
|
||
top: "0px",
|
||
date: "",
|
||
value: "",
|
||
lines: [] as string[],
|
||
});
|
||
const activePoint = ref<string | null>(null);
|
||
|
||
const INQUIRY_PLOT_TOP = 36;
|
||
const INQUIRY_PLOT_BOTTOM = 188;
|
||
|
||
/**
|
||
* 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。
|
||
* 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。
|
||
*/
|
||
function resolveRange(): string {
|
||
const from = new Date(dateFrom.value);
|
||
const to = new Date(dateTo.value);
|
||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||
if (days <= 0) return "1d";
|
||
if (days <= 6) return "7d";
|
||
return "30d";
|
||
}
|
||
|
||
/**
|
||
* 顶栏日期:与工单列表同一套 a-range-picker(双月历点选起止)。
|
||
* 不再并排放「按日/月/年」切换,避免顶栏挤换行显得乱。
|
||
*/
|
||
const dateRange = computed({
|
||
get(): [string, string] | undefined {
|
||
if (!dateFrom.value && !dateTo.value) return undefined;
|
||
return [dateFrom.value || "", dateTo.value || ""];
|
||
},
|
||
set(v: [string, string] | null | undefined) {
|
||
dateFrom.value = v?.[0] ? String(v[0]) : isoMonthStart;
|
||
dateTo.value = v?.[1] ? String(v[1]) : isoDay;
|
||
},
|
||
});
|
||
|
||
/** 选完起止日后立刻刷新看板 */
|
||
function onDateRangeChange() {
|
||
loadMetrics();
|
||
}
|
||
|
||
async function loadMetrics() {
|
||
loading.value = true;
|
||
try {
|
||
const req = {
|
||
range: resolveRange(),
|
||
dateFrom: dateFrom.value,
|
||
dateTo: dateTo.value,
|
||
};
|
||
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),
|
||
}));
|
||
rankingPeople.value = ((data.rankings || []) as any[]).map((p) => ({
|
||
name: String(p.name || ""),
|
||
role: (p.role || "sales") as RankingPerson["role"],
|
||
dept: String(p.dept || "-"),
|
||
volumeNum: Number(p.volumeNum || 0),
|
||
}));
|
||
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())}`;
|
||
} catch (e: any) {
|
||
message.error(e?.message || "加载看板失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把主账 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 });
|
||
}
|
||
|
||
/**
|
||
* 看板统计数字千分位:百分比、时长原样;件数/金额加分组符。
|
||
* 主账已格式化时再跑一遍也安全(先去逗号再 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
|
||
: [{ date: "—", value: 0 }];
|
||
const xs = points.map((_, i) => (points.length === 1 ? 380 : 36 + (654 / (points.length - 1)) * i));
|
||
const values = points.map((p) => p.value);
|
||
const max = Math.max(...values, 1);
|
||
const min = Math.min(...values, 0);
|
||
const range = Math.max(max - min, 1);
|
||
const plotH = INQUIRY_PLOT_BOTTOM - INQUIRY_PLOT_TOP;
|
||
const yFor = (v: number) => INQUIRY_PLOT_BOTTOM - ((v - min) / range) * plotH;
|
||
const coords = points.map((p, i) => ({
|
||
x: xs[i],
|
||
y: yFor(p.value),
|
||
date: p.date,
|
||
value: p.value,
|
||
}));
|
||
const line = coords.map((c) => `${c.x.toFixed(1)} ${c.y.toFixed(1)}`).join(" L");
|
||
const area = `M${line} L${coords[coords.length - 1].x.toFixed(1)} ${INQUIRY_PLOT_BOTTOM} L${coords[0].x.toFixed(1)} ${INQUIRY_PLOT_BOTTOM} Z`;
|
||
return { coords, line, area };
|
||
});
|
||
|
||
const filteredRoutes = computed(() => costRoutes.value.filter((r) => selectedRoutes.value.includes(r.id)));
|
||
|
||
const rankingRows = computed(() => {
|
||
let rows: RankingPerson[] = [...rankingPeople.value];
|
||
if (rankingRole.value !== "all") rows = rows.filter((p) => p.role === rankingRole.value);
|
||
if (rankingDept.value) rows = rows.filter((p) => p.dept === rankingDept.value);
|
||
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
|
||
});
|
||
|
||
const costDates = computed(() =>
|
||
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
|
||
);
|
||
|
||
const costXs = computed(() => {
|
||
const n = Math.max(costDates.value.length, 1);
|
||
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||
});
|
||
|
||
function refreshData() {
|
||
loadMetrics().then(() => message.success("数据已更新"));
|
||
}
|
||
|
||
function toggleRoute(id: string) {
|
||
const set = new Set(selectedRoutes.value);
|
||
if (set.has(id)) set.delete(id);
|
||
else set.add(id);
|
||
selectedRoutes.value = [...set];
|
||
}
|
||
|
||
function selectAllRoutes() {
|
||
selectedRoutes.value = costRoutes.value.map((r) => r.id);
|
||
}
|
||
|
||
function clearRoutes() {
|
||
selectedRoutes.value = [];
|
||
}
|
||
|
||
function showInquiryTip(date: string, value: number, x: number, y: number, wrapEl: HTMLElement | null) {
|
||
if (!wrapEl) return;
|
||
const box = wrapEl.getBoundingClientRect();
|
||
activePoint.value = date;
|
||
chartTip.value = {
|
||
visible: true,
|
||
left: `${(x / 760) * box.width}px`,
|
||
top: `${(y / 240) * box.height}px`,
|
||
date,
|
||
value: `${formatCountDisplay(value)} 单/日`,
|
||
lines: [],
|
||
};
|
||
}
|
||
|
||
function hideInquiryTip() {
|
||
activePoint.value = null;
|
||
chartTip.value.visible = false;
|
||
}
|
||
|
||
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, 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`,
|
||
top: `${(36 / 220) * box.height}px`,
|
||
date,
|
||
value: "",
|
||
lines,
|
||
};
|
||
}
|
||
|
||
function hideCostTip() {
|
||
costTip.value.visible = false;
|
||
}
|
||
|
||
function exportRanking() {
|
||
const roleLabels: Record<string, string> = { all: "全部", route: "航线", product: "产品", sales: "销售" };
|
||
const header = ["排名", "人员", "部门", "岗位", "处理量"];
|
||
const body = rankingRows.value.map((row, index) => [
|
||
String(index + 1),
|
||
row.name,
|
||
row.dept,
|
||
roleLabels[row.role] || row.role,
|
||
`${formatCountDisplay(row.volumeNum)}单`,
|
||
]);
|
||
const csv = [header, ...body]
|
||
.map((cols) => cols.map((col) => `"${String(col).replace(/"/g, '""')}"`).join(","))
|
||
.join("\n");
|
||
const blob = new Blob([`\ufeff${csv}`], { type: "text/csv;charset=utf-8;" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `人员排名_${roleLabels[rankingRole.value]}_${new Date().toISOString().slice(0, 10)}.csv`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
message.success("已导出排名 CSV");
|
||
}
|
||
|
||
function onDocClick(e: MouseEvent) {
|
||
const t = e.target as HTMLElement;
|
||
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);
|
||
|
||
onMounted(() => {
|
||
chartReady.value = true;
|
||
document.addEventListener("click", onDocClick);
|
||
loadMetrics();
|
||
});
|
||
onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||
</script>
|
||
|
||
<template>
|
||
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
|
||
<Teleport defer to="#page-topbar-actions">
|
||
<div class="dash-topbar-tools">
|
||
<div class="toolbar-date-range dash-toolbar-date" title="选择统计起止日期">
|
||
<a-range-picker
|
||
v-model:value="dateRange"
|
||
class="toolbar-range-picker dash-range-picker"
|
||
value-format="YYYY-MM-DD"
|
||
separator="至"
|
||
:placeholder="['开始日期', '结束日期']"
|
||
:allow-clear="false"
|
||
:input-read-only="true"
|
||
@change="onDateRangeChange"
|
||
>
|
||
<template #suffixIcon>
|
||
<ProtoIcon name="calendar" :size="14" />
|
||
</template>
|
||
</a-range-picker>
|
||
</div>
|
||
<div class="dashboard-data-note" aria-live="polite">
|
||
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
|
||
<span class="dashboard-data-note__time">数据更新时间{{ updatedTime }}</span>
|
||
</div>
|
||
<button type="button" class="btn btn-primary dash-refresh-btn" @click="refreshData">
|
||
<ProtoIcon name="refresh" :size="14" />
|
||
更新数据
|
||
</button>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 1. 九指标 -->
|
||
<div class="ref-surface ref-surface--metrics">
|
||
<div class="ref-header ref-header--compact">
|
||
<div>
|
||
<h2 class="ref-title">询价运营表现总览</h2>
|
||
</div>
|
||
</div>
|
||
<div class="ref-metrics-grid">
|
||
<div v-for="m in todayMetrics" :key="m.key" class="ref-metric-card">
|
||
<div class="ref-metric-card__label">
|
||
<span>{{ m.label }}</span>
|
||
<button type="button" class="ref-metric-help" :aria-label="m.label + '计算逻辑'">
|
||
<ProtoIcon name="help" :size="12" />
|
||
<span class="ref-metric-tip">{{ m.tip }}</span>
|
||
</button>
|
||
</div>
|
||
<div class="ref-metric-card__value" :class="{ 'is-time': m.isTime }">{{ formatMetricDisplay(m) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 双图 -->
|
||
<div class="ref-surface">
|
||
<div class="ref-charts-section">
|
||
<div class="ref-chart-panel">
|
||
<div class="ref-chart-panel__head">
|
||
<div class="ref-section-title dash-chart-title">
|
||
<ProtoIcon name="lineChart" :size="14" class="dash-chart-title__icon" />
|
||
询价单量趋势图
|
||
</div>
|
||
</div>
|
||
<div class="ref-chart-panel__body">
|
||
<div ref="inquiryWrap" class="ref-chart-wrap">
|
||
<svg v-if="chartReady" class="ref-chart" viewBox="0 0 760 240" role="img" aria-label="近七天询价趋势">
|
||
<defs>
|
||
<linearGradient id="refTrendFill" x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stop-color="#155eef" stop-opacity="0.18" />
|
||
<stop offset="100%" stop-color="#155eef" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path :d="inquirySvg.area" fill="url(#refTrendFill)" />
|
||
<path
|
||
:d="`M${inquirySvg.line}`"
|
||
fill="none"
|
||
stroke="#155eef"
|
||
stroke-width="4"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path d="M36 188 H724 M36 137 H724 M36 86 H724 M36 36 H724" stroke="#e8edf4" stroke-width="1" />
|
||
<g fill="#64748b" font-size="8" font-weight="800" text-anchor="middle">
|
||
<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">{{ formatCountDisplay(c.value) }}</text>
|
||
</g>
|
||
<g
|
||
v-for="c in inquirySvg.coords"
|
||
:key="'p-' + c.date"
|
||
class="ref-chart-point"
|
||
:class="{ 'is-active': activePoint === c.date }"
|
||
@mouseenter="showInquiryTip(c.date, c.value, c.x, c.y, inquiryWrap)"
|
||
@mouseleave="hideInquiryTip"
|
||
>
|
||
<!-- 加大热区,方便悬停出提示 -->
|
||
<circle :cx="c.x" :cy="c.y" r="14" fill="transparent" />
|
||
<circle :cx="c.x" :cy="c.y" r="4" fill="#155eef" stroke="#ffffff" stroke-width="3" />
|
||
</g>
|
||
</svg>
|
||
<div
|
||
class="ref-chart-tooltip"
|
||
:class="{ 'is-visible': chartTip.visible }"
|
||
:style="{ left: chartTip.left, top: chartTip.top }"
|
||
>
|
||
<div class="date">{{ chartTip.date }}</div>
|
||
<div class="value">{{ chartTip.value }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ref-chart-panel">
|
||
<div class="ref-chart-panel__head">
|
||
<div class="ref-section-title dash-chart-title">
|
||
<ProtoIcon name="trending" :size="14" class="dash-chart-title__icon" />
|
||
成本趋势图
|
||
</div>
|
||
<div class="ref-route-filter" @click.stop>
|
||
<button
|
||
type="button"
|
||
class="ref-route-filter__trigger"
|
||
:class="{ 'is-open': routeFilterOpen }"
|
||
@click="routeFilterOpen = !routeFilterOpen"
|
||
>
|
||
<ProtoIcon name="route" :size="14" />
|
||
<span>线路筛选</span>
|
||
<span class="ref-route-filter__badge">{{ selectedRoutes.length }}</span>
|
||
<ProtoIcon name="chevron" :size="14" class="ref-route-filter__arrow" />
|
||
</button>
|
||
<div v-show="routeFilterOpen" class="ref-route-filter__menu dash-route-menu">
|
||
<div class="ref-route-filter__menu-head">
|
||
<div>
|
||
<div class="ref-route-filter__menu-title">选择对比线路</div>
|
||
<div class="ref-route-filter__menu-sub">已选 {{ selectedRoutes.length }} / {{ costRoutes.length }}</div>
|
||
</div>
|
||
<div class="tabs-pill ref-route-filter__actions">
|
||
<button type="button" @click="selectAllRoutes">全选</button>
|
||
<button type="button" @click="clearRoutes">清空</button>
|
||
</div>
|
||
</div>
|
||
<div class="ref-route-filter__chips">
|
||
<label
|
||
v-for="r in costRoutes"
|
||
:key="r.id"
|
||
class="ref-route-chip"
|
||
@click.prevent="toggleRoute(r.id)"
|
||
>
|
||
<input type="checkbox" :checked="selectedRoutes.includes(r.id)" />
|
||
<span class="ref-route-chip__dot" :style="{ '--route-color': r.color }" />
|
||
<span class="ref-route-chip__text">{{ r.label }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ref-chart-panel__body">
|
||
<div ref="costWrap" id="costChartWrap" class="ref-chart-wrap">
|
||
<svg class="ref-chart" viewBox="0 0 760 220" role="img" aria-label="成本趋势图">
|
||
<path d="M36 186 H724 M36 136 H724 M36 86 H724 M36 36 H724" stroke="#e8edf4" stroke-width="1" />
|
||
<g fill="#94a3b8" font-size="8" font-weight="700" text-anchor="end">
|
||
<text x="30" y="40">高</text>
|
||
<text x="30" y="90">—</text>
|
||
<text x="30" y="140">—</text>
|
||
<text x="30" y="190">低</text>
|
||
</g>
|
||
<path
|
||
v-for="r in filteredRoutes"
|
||
:key="r.id"
|
||
class="cost-line"
|
||
:d="r.path"
|
||
fill="none"
|
||
:stroke="r.color"
|
||
stroke-width="2"
|
||
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, i, costWrap)"
|
||
@mouseleave="hideCostTip"
|
||
>
|
||
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
|
||
</g>
|
||
<g fill="#64748b" font-size="8" font-weight="800" text-anchor="middle">
|
||
<text v-for="(d, i) in costDates" :key="d" :x="costXs[i]" y="214">{{ d }}</text>
|
||
</g>
|
||
</svg>
|
||
<div
|
||
class="ref-chart-tooltip is-cost"
|
||
:class="{ 'is-visible': costTip.visible }"
|
||
:style="{ left: costTip.left, top: costTip.top }"
|
||
>
|
||
<div class="date">{{ costTip.date }}</div>
|
||
<div class="value">
|
||
<span v-for="(line, i) in costTip.lines" :key="i" class="value-line">{{ line }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. 人员排名 -->
|
||
<div class="ref-surface">
|
||
<div class="ref-panel-head ref-ranking-panel-head">
|
||
<div class="ref-section-title ref-ranking-section-title">
|
||
<ProtoIcon name="gauge" :size="16" class="ref-ranking-icon dash-chart-title__icon" />
|
||
人员排名
|
||
</div>
|
||
<div class="ref-ranking-panel-actions">
|
||
<select v-model="rankingDept" class="ref-ranking-dept-select" aria-label="部门筛选">
|
||
<option value="">全部部门</option>
|
||
<option v-for="d in rankingDepts" :key="d" :value="d">{{ d }}</option>
|
||
</select>
|
||
<div class="tabs-pill" role="tablist" aria-label="人员角色">
|
||
<button type="button" :class="{ 'is-active': rankingRole === 'all' }" @click="rankingRole = 'all'">全部</button>
|
||
<button type="button" :class="{ 'is-active': rankingRole === 'sales' }" @click="rankingRole = 'sales'">销售</button>
|
||
<button type="button" :class="{ 'is-active': rankingRole === 'route' }" @click="rankingRole = 'route'">航线</button>
|
||
<button type="button" :class="{ 'is-active': rankingRole === 'product' }" @click="rankingRole = 'product'">产品</button>
|
||
</div>
|
||
<button type="button" class="btn btn-secondary ref-ranking-export-btn" @click="exportRanking">
|
||
<ProtoIcon name="download" :size="16" />
|
||
导出
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="ref-ranking-table-wrap">
|
||
<div class="ref-ranking-list">
|
||
<div class="ref-ranking-head">
|
||
<div class="ref-ranking-info">
|
||
<span class="col-rank">排名</span>
|
||
<span class="col-name">人员</span>
|
||
<span class="col-dept">部门</span>
|
||
</div>
|
||
<span class="col-volume">处理量</span>
|
||
</div>
|
||
<div v-if="!rankingRows.length" class="ref-ranking-empty">暂无符合条件的人员数据</div>
|
||
<div v-for="(row, index) in rankingRows" :key="row.name + row.dept" class="ref-ranking-row">
|
||
<div class="ref-ranking-info">
|
||
<span class="col-rank"><span class="ref-rank" :class="{ top: index < 2 }">{{ index + 1 }}</span></span>
|
||
<span class="col-name">{{ row.name }}</span>
|
||
<span class="col-dept">{{ row.dept }}</span>
|
||
</div>
|
||
<span class="col-volume">{{ formatCountDisplay(row.volumeNum) }} 单</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dash-topbar-tools {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.dash-toolbar-date {
|
||
height: 34px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.dash-toolbar-date :deep(.ant-picker) {
|
||
height: 34px !important;
|
||
min-height: 34px !important;
|
||
width: 260px !important;
|
||
}
|
||
|
||
.dash-refresh-btn {
|
||
height: 34px;
|
||
min-height: 34px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.dashboard-data-note {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.dash-chart-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
font-weight: 900;
|
||
margin: 0;
|
||
}
|
||
|
||
.dash-chart-title__icon {
|
||
color: #1d4ed8;
|
||
}
|
||
|
||
.dash-route-menu {
|
||
/* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */
|
||
position: absolute;
|
||
right: 0;
|
||
top: calc(100% + 8px);
|
||
z-index: 120;
|
||
}
|
||
|
||
.ref-surface--metrics,
|
||
.ref-surface--metrics .ref-metrics-grid,
|
||
.ref-surface--metrics .ref-metric-card {
|
||
overflow: visible;
|
||
}
|
||
|
||
.ref-surface--metrics .ref-metric-card:hover {
|
||
z-index: 5;
|
||
}
|
||
|
||
.ref-chart-wrap {
|
||
position: relative;
|
||
overflow: visible;
|
||
}
|
||
|
||
/* 悬停提示改为不撑开文档宽度 */
|
||
.ref-chart-tooltip {
|
||
max-width: min(168px, 40vw);
|
||
}
|
||
</style>
|