feat(web): add mobile-first read-only dashboard frontend
This commit is contained in:
1 parent
257fbe009b
commit
cbba405271
18 files changed
+2628
-2
No files matched your search
@@ -0,0 +1,188 @@
|
||||
import { formatAxis, formatCount, formatDay } from "./format.js";
|
||||
|
||||
const charts = new Map();
|
||||
let resizeObserver = null;
|
||||
|
||||
function colors() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
text: styles.getPropertyValue("--text").trim() || "#1c1f23",
|
||||
muted: styles.getPropertyValue("--muted").trim() || "#6b7280",
|
||||
grid: styles.getPropertyValue("--grid").trim() || "#e8ebf0",
|
||||
accent: "#fb7299",
|
||||
blue: "#00aeec",
|
||||
surface: styles.getPropertyValue("--surface").trim() || "#ffffff",
|
||||
};
|
||||
}
|
||||
|
||||
function ensureEcharts() {
|
||||
if (!window.echarts) {
|
||||
throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js");
|
||||
}
|
||||
return window.echarts;
|
||||
}
|
||||
|
||||
function mount(element, option) {
|
||||
const echarts = ensureEcharts();
|
||||
let chart = charts.get(element);
|
||||
if (!chart) {
|
||||
chart = echarts.init(element, null, { renderer: "canvas" });
|
||||
charts.set(element, chart);
|
||||
}
|
||||
chart.setOption(option, true);
|
||||
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
charts.get(entry.target)?.resize();
|
||||
}
|
||||
});
|
||||
}
|
||||
resizeObserver?.observe(element);
|
||||
return chart;
|
||||
}
|
||||
|
||||
export function disposeAll() {
|
||||
for (const chart of charts.values()) {
|
||||
try {
|
||||
chart.dispose();
|
||||
} catch {
|
||||
// 页面切换时忽略已销毁实例
|
||||
}
|
||||
}
|
||||
charts.clear();
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
const palette = {
|
||||
accent: "#fb7299",
|
||||
blue: "#00aeec",
|
||||
purple: "#9b7bff",
|
||||
green: "#22c55e",
|
||||
};
|
||||
|
||||
export function lineChart(element, { dates, values, name = "数值", color = palette.accent, scale = false }) {
|
||||
if (!element) return null;
|
||||
const theme = colors();
|
||||
const option = {
|
||||
animationDuration: 300,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.grid,
|
||||
textStyle: { color: theme.text, fontSize: 12 },
|
||||
formatter(params) {
|
||||
const point = params[0];
|
||||
if (!point) return "";
|
||||
const value = point.value;
|
||||
return `<strong>${point.axisValue}</strong><br>${name}:${
|
||||
value === null || value === undefined ? "缺失" : formatCount(value)
|
||||
}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: dates,
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: theme.grid } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
fontSize: 11,
|
||||
formatter: (value) => formatDay(value),
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale,
|
||||
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
fontSize: 11,
|
||||
formatter: (value) => formatAxis(value),
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
name,
|
||||
type: "line",
|
||||
data: values,
|
||||
smooth: false,
|
||||
connectNulls: false,
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
showSymbol: values.length <= 45,
|
||||
lineStyle: { width: 2.2, color },
|
||||
itemStyle: { color },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${color}55` },
|
||||
{ offset: 1, color: `${color}00` },
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
return mount(element, option);
|
||||
}
|
||||
|
||||
export function barChart(element, { labels, values, name = "数值", color = palette.blue }) {
|
||||
if (!element) return null;
|
||||
const theme = colors();
|
||||
const option = {
|
||||
animationDuration: 300,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: theme.grid,
|
||||
textStyle: { color: theme.text, fontSize: 12 },
|
||||
formatter(params) {
|
||||
const point = params[0];
|
||||
if (!point) return "";
|
||||
return `<strong>${point.name}</strong><br>${name}:${formatCount(point.value)}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: labels,
|
||||
axisLine: { lineStyle: { color: theme.grid } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
fontSize: 10,
|
||||
interval: 0,
|
||||
rotate: labels.length > 8 ? 32 : 0,
|
||||
formatter: (value) => String(value).slice(0, 10),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
|
||||
axisLabel: {
|
||||
color: theme.muted,
|
||||
fontSize: 11,
|
||||
formatter: (value) => formatAxis(value),
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
name,
|
||||
type: "bar",
|
||||
data: values,
|
||||
barMaxWidth: 26,
|
||||
itemStyle: { color, borderRadius: [4, 4, 0, 0] },
|
||||
}],
|
||||
};
|
||||
return mount(element, option);
|
||||
}
|
||||
|
||||
export function emptyChart(element, message = "暂无数据") {
|
||||
if (!element) return;
|
||||
element.innerHTML = `<div class="chart-empty">${message}</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user