Files
inquiry_robot/inquiry-backend/src/views/inquiry/dashboard/index.vue
T

717 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
*/
import { computed, onMounted, onUnmounted, ref } 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();
const isoDay = now.toISOString().slice(0, 10);
const dateDisplay = ref(`${isoDay}——${isoDay}`);
const dateFrom = ref(isoDay);
const dateTo = ref(isoDay);
const dateFromMonth = ref(isoDay.slice(0, 7));
const dateToMonth = ref(isoDay.slice(0, 7));
const dateFromYear = ref(isoDay.slice(0, 4));
const dateToYear = ref(isoDay.slice(0, 4));
const pickerOpen = ref(false);
const pickerMode = ref<"day" | "month" | "year">("day");
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;
function resolveRange(): string {
if (pickerMode.value === "day") {
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 "today";
if (days <= 6) return "7d";
return "30d";
}
if (pickerMode.value === "month") return "30d";
return "30d";
}
async function loadMetrics() {
loading.value = true;
try {
const res: any = await getDashboardMetrics(resolveRange());
const data = res?.result || res || {};
todayMetrics.value = (data.metrics || []) as MetricCard[];
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 });
}
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 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));
});
function applyDateRange() {
if (pickerMode.value === "day") dateDisplay.value = `${dateFrom.value}——${dateTo.value}`;
else if (pickerMode.value === "month") dateDisplay.value = `${dateFromMonth.value}——${dateToMonth.value}`;
else dateDisplay.value = `${dateFromYear.value}——${dateToYear.value}`;
pickerOpen.value = false;
loadMetrics();
}
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: `${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,
`${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(".month-range-picker")) pickerOpen.value = false;
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
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="month-range-picker" @click.stop>
<div class="month-range-input" @click="pickerOpen = !pickerOpen">
<span>{{ dateDisplay }}</span>
<ProtoIcon name="calendar" :size="14" style="color: #94a3b8" />
</div>
<div v-show="pickerOpen" class="month-range-dropdown dash-picker-dropdown">
<div class="month-range-tabs">
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'day' }" @click="pickerMode = 'day'">按日</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'month' }" @click="pickerMode = 'month'">按月</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'year' }" @click="pickerMode = 'year'">按年</button>
</div>
<div class="dash-picker-body">
<template v-if="pickerMode === 'day'">
<input v-model="dateFrom" type="date" class="dash-picker-input" />
<input v-model="dateTo" type="date" class="dash-picker-input" />
</template>
<template v-else-if="pickerMode === 'month'">
<input v-model="dateFromMonth" type="month" class="dash-picker-input" />
<input v-model="dateToMonth" type="month" class="dash-picker-input" />
</template>
<template v-else>
<input v-model="dateFromYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="起始年" />
<input v-model="dateToYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="结束年" />
</template>
<button type="button" class="btn btn-primary" style="width: 100%; min-height: 32px; height: 32px" @click="applyDateRange">确定</button>
</div>
</div>
</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 }">{{ m.value }}</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">{{ 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 class="dash-cost-footnote">
{{ costFootnote }}
</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">{{ row.volumeNum }} 单</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dash-topbar-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.dash-picker-dropdown {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 300px;
z-index: 80;
}
.dash-picker-body {
display: grid;
gap: 8px;
padding-top: 4px;
}
.dash-picker-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #dfe5ee;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
}
.dash-refresh-btn {
height: 34px;
min-height: 34px;
}
.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 {
position: absolute;
right: 0;
top: calc(100% + 8px);
}
.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 {
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>