feat(web): add mobile-first read-only dashboard frontend

This commit is contained in:
kairee committed 2026-10-31 23:46:55 +08:00
1 parent 257fbe009b
commit cbba405271
18 files changed
+2628 -2

No files matched your search

+188
View File
@@ -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>`;
}