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

View File

@ -191,6 +191,29 @@ button {
height: auto; 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 { .chart-frame .ag-charts-wrapper {
--ag-charts-accent-color: #0078a8; --ag-charts-accent-color: #0078a8;
--ag-charts-button-background-color: rgba(255, 249, 241, 0.72); --ag-charts-button-background-color: rgba(255, 249, 241, 0.72);