This commit is contained in:
wintsa 2026-05-08 15:23:43 +08:00
parent 0990959e82
commit 38978befb7
2 changed files with 102 additions and 32 deletions

View File

@ -128,6 +128,10 @@ type ApiBuildingFunctionStat = {
median_value?: number | null;
data_count?: number | null;
};
type ApiBuildingFunctionStatBatchItem = {
key?: string;
data?: ApiBuildingFunctionStat[];
};
type ChartDatum = {
groupName: string;
minValue: number | null;
@ -153,13 +157,36 @@ type SelectedContentNode = {
color: string;
};
function formatWan(value: number) {
const wanValue = value / 10000;
const fractionDigits = Math.abs(wanValue) < 1 ? 4 : 2;
return `${wanValue.toLocaleString('zh-CN', {
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
})}`;
maximumFractionDigits,
});
}
function formatCostValue(value: number) {
const absValue = Math.abs(value);
if (absValue >= 10000) {
return `${formatNumber(value / 10000, 2)}万元`;
}
const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2;
return `${formatNumber(value, fractionDigits)}`;
}
function formatAreaMetricValue(value: number) {
const absValue = Math.abs(value);
const fractionDigits = absValue > 0 && absValue < 1 ? 4 : 2;
return `${formatNumber(value, fractionDigits)}元/m²`;
}
function formatChartValue(value: number, metricKey: MetricKey, statisticKey: StatisticKey) {
if (statisticKey === 'dataCount') {
return formatNumber(value, 0);
}
if (metricKey === 'cost') {
return formatCostValue(value);
}
return formatAreaMetricValue(value);
}
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
@ -354,6 +381,7 @@ function App() {
const [chartQueryVersion, setChartQueryVersion] = useState(0);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingHint, setLoadingHint] = useState('');
const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0];
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
@ -511,6 +539,9 @@ function App() {
const updateMetricKey = (nextMetricKey: MetricKey) => {
setMetricKey(nextMetricKey);
setChartDataBySelection({});
setLoadError(null);
setLoadingHint('正在重新加载数据');
setLoading(true);
setChartQueryVersion((version) => version + 1);
};
@ -546,37 +577,45 @@ function App() {
if (selectedContentNodes.length === 0) {
setChartDataBySelection({});
setLoading(false);
setLoadingHint('');
setLoadError(null);
return;
}
setLoading(true);
setLoadingHint('正在加载数据');
setLoadError(null);
try {
const missingNodes = selectedContentNodes.filter((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]);
if (missingNodes.length === 0) {
const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]);
if (!hasAnyMissingNode) {
setLoading(false);
setLoadingHint('');
return;
}
const results = await Promise.all(
missingNodes.map(async (node) => {
const search = new URLSearchParams({
groupBy: groupKey,
metric: metricKey,
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostStatsBatch`, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
groupBy: groupKey,
metric: metricKey,
nodes: selectedContentNodes.map((node) => ({
key: getSelectionKey(node.contentKey, node.id),
contentKey: node.contentKey,
nodeId: node.id,
});
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostStats?${search.toString()}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as { data?: ApiBuildingFunctionStat[] };
return [getSelectionKey(node.contentKey, node.id), (payload.data ?? []).map(normalizeStat).slice(0, 36)] as const;
})),
}),
);
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = (await response.json()) as { data?: ApiBuildingFunctionStatBatchItem[] };
const results = (payload.data ?? [])
.filter((item) => item.key)
.map((item) => [item.key as string, (item.data ?? []).map(normalizeStat).slice(0, 36)] as const);
setChartDataBySelection((current) => ({
...current,
...Object.fromEntries(results),
@ -587,6 +626,7 @@ function App() {
} finally {
if (!controller.signal.aborted) {
setLoading(false);
setLoadingHint('');
}
}
}
@ -838,6 +878,14 @@ function App() {
interpolation: {
type: 'smooth',
},
tooltip: {
renderer: ({ datum, yKey, yName }) => ({
title: yName,
data: [
{ label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey, statisticKey) },
],
}),
},
})),
axes: {
x: {
@ -867,13 +915,7 @@ function App() {
label: {
color: '#1f2933',
fontSize: 12,
formatter: ({ value }) =>
!isCount
? formatWan(Number(value))
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}),
formatter: ({ value }) => formatChartValue(Number(value), metricKey, statisticKey),
},
line: {
enabled: false,
@ -902,7 +944,7 @@ function App() {
enabled: true,
},
};
}, [chartDataBySelection, selectedContentNodes, selectedMetric.label, selectedStatistic.label, statisticKey]);
}, [chartDataBySelection, metricKey, selectedContentNodes, selectedMetric.label, selectedStatistic.label, statisticKey]);
return (
<main className="dashboard-shell">
@ -969,11 +1011,16 @@ function App() {
</div>
) : null}
</div>
{loading || loadError ? <div className="chart-status">{loading ? '加载中' : loadError}</div> : null}
{loading || loadError ? <div className="chart-status">{loading ? loadingHint || '加载中' : loadError}</div> : null}
<button className="chart-fullscreen-button ag-charts-toolbar__button" type="button" title="全屏(F11)">
<i className="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button" />
</button>
<AgCharts options={chartOptions} />
{loading ? (
<div className="chart-loading-mask" aria-live="polite" aria-busy="true">
<div className="chart-loading-panel">{loadingHint || '加载中'}</div>
</div>
) : null}
</div>
</section>

View File

@ -191,6 +191,29 @@ button {
height: auto;
}
.chart-loading-mask {
position: absolute;
inset: 26px 0 0;
z-index: 13;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 248, 240, 0.58);
backdrop-filter: blur(1px);
}
.chart-loading-panel {
min-width: 120px;
padding: 10px 14px;
border: 1px solid rgba(90, 82, 72, 0.16);
border-radius: 3px;
color: #262a33;
background: rgba(255, 252, 248, 0.96);
box-shadow: 0 6px 18px rgba(69, 54, 36, 0.12);
font-size: 14px;
line-height: 1.4;
}
.chart-frame .ag-charts-wrapper {
--ag-charts-accent-color: #0078a8;
--ag-charts-button-background-color: rgba(255, 249, 241, 0.72);