diff --git a/src/App.tsx b/src/App.tsx index ee7dfe7..12330a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 (
@@ -969,11 +1011,16 @@ function App() { ) : null} - {loading || loadError ?
{loading ? '加载中' : loadError}
: null} + {loading || loadError ?
{loading ? loadingHint || '加载中' : loadError}
: null} + {loading ? ( +
+
{loadingHint || '加载中'}
+
+ ) : null} diff --git a/src/styles.css b/src/styles.css index f3e67f7..abcbb60 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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);