717 lines
26 KiB
Vue
717 lines
26 KiB
Vue
<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>
|