This commit is contained in:
wintsa 2026-05-09 18:22:43 +08:00
parent a891b5bccb
commit cfc6865986
5 changed files with 982 additions and 11 deletions

View File

@ -8,6 +8,7 @@
"ag-charts-community": "^13.2.1",
"ag-charts-enterprise": "13.2.1",
"ag-charts-react": "^13.2.1",
"lucide-react": "^1.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
},
@ -233,6 +234,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],

View File

@ -13,6 +13,7 @@
"ag-charts-community": "^13.2.1",
"ag-charts-enterprise": "13.2.1",
"ag-charts-react": "^13.2.1",
"lucide-react": "^1.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { AgCharts } from 'ag-charts-react';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import { Building2, Construction, LayoutGrid, LocateFixed, MapPinned } from 'lucide-react';
import {
AnnotationsModule,
ContextMenuModule,
@ -15,6 +16,7 @@ LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762
ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]);
const API_BASE_URL = 'https://nest.zwgczx.com/api/v1';
// const API_BASE_URL = 'http://127.0.0.1:9089/api/v1';
const statisticOptions = [
{ key: 'minValue', label: '最低值', shortLabel: '低' },
@ -38,6 +40,14 @@ const contentOptions = [
{ key: 'planningForm', label: '规划形式' },
] as const;
const filterOptions = [
{ key: 'region', label: '省市区', icon: MapPinned },
{ key: 'geoLocation', label: '自然地理区位', icon: LocateFixed },
{ key: 'facilityType', label: '设施类别', icon: Building2 },
{ key: 'constructionStage', label: '建设阶段', icon: Construction },
{ key: 'planningForm', label: '规划形式', icon: LayoutGrid },
] as const;
const browserTreeDefaults = {
treetype: '256',
checkStrictly: 'true',
@ -118,6 +128,7 @@ const chartLineColors = ['#0078a8', '#d14d72', '#1f8f4d', '#d96f23', '#6b5cc8',
type StatisticKey = (typeof statisticOptions)[number]['key'];
type MetricKey = (typeof metricOptions)[number]['key'];
type ContentKey = (typeof contentOptions)[number]['key'];
type FilterKey = (typeof filterOptions)[number]['key'];
type GroupKey = 'year';
type ApiBuildingFunctionStat = {
group_key?: string | number | null;
@ -156,6 +167,11 @@ type SelectedContentNode = {
label: string;
color: string;
};
type SelectedFilterNode = {
id: string;
filterKey: FilterKey;
label: string;
};
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString('zh-CN', {
@ -275,6 +291,81 @@ function normalizeTreeRows(rows: unknown[]): TreeNode[] {
});
}
const regionFieldKeys = {
provinceId: ['provinceId', 'province_id', 'provinceid', 'sfid', 'sf_id', 'shengId', 'sheng_id', 'sheng'],
provinceName: ['provinceName', 'province_name', 'province', 'sfmc', 'sf', 'shengName', 'sheng_name', 'shengmc'],
cityId: ['cityId', 'city_id', 'cityid', 'sid', 's_id', 'shiId', 'shi_id', 'shi'],
cityName: ['cityName', 'city_name', 'city', 'smc', 's', 'shiName', 'shi_name', 'shimc'],
districtId: ['districtId', 'district_id', 'districtid', 'countyId', 'county_id', 'qid', 'q_id', 'xid', 'x_id', 'id'],
districtName: ['districtName', 'district_name', 'district', 'countyName', 'county_name', 'county', 'qmc', 'q', 'xmc', 'x', 'name', 'shortname'],
};
function createFilterTreeNode(id: string, label: string, children: TreeNode[] = [], expanded = false): TreeNode {
return {
id,
label,
children,
hasChildren: children.length > 0,
canClick: true,
expanded,
loading: false,
loaded: true,
};
}
function normalizeFlatRegionRows(rows: unknown[]): TreeNode[] {
const sourceRows = rows.filter((row): row is Record<string, unknown> => !!row && typeof row === 'object');
const hasRegionShape = sourceRows.some((row) =>
readText(row, regionFieldKeys.provinceName) &&
readText(row, regionFieldKeys.cityName) &&
readText(row, regionFieldKeys.districtName),
);
if (!hasRegionShape) return [];
const provinceMap = new Map<string, {
id: string;
label: string;
cityMap: Map<string, {
id: string;
label: string;
districts: Map<string, TreeNode>;
}>;
}>();
sourceRows.forEach((row) => {
const provinceLabel = readText(row, regionFieldKeys.provinceName);
const cityLabel = readText(row, regionFieldKeys.cityName);
const districtLabel = readText(row, regionFieldKeys.districtName);
if (!provinceLabel || !cityLabel || !districtLabel) return;
const provinceId = `region:province:${readText(row, regionFieldKeys.provinceId) || provinceLabel}`;
const cityId = `region:city:${provinceLabel}:${readText(row, regionFieldKeys.cityId) || cityLabel}`;
const districtId = `region:district:${readText(row, regionFieldKeys.districtId) || `${provinceLabel}:${cityLabel}:${districtLabel}`}`;
const province = provinceMap.get(provinceId) ?? {
id: provinceId,
label: provinceLabel,
cityMap: new Map<string, { id: string; label: string; districts: Map<string, TreeNode> }>(),
};
const city = province.cityMap.get(cityId) ?? {
id: cityId,
label: cityLabel,
districts: new Map<string, TreeNode>(),
};
city.districts.set(districtId, createFilterTreeNode(districtId, districtLabel));
province.cityMap.set(cityId, city);
provinceMap.set(provinceId, province);
});
return Array.from(provinceMap.values()).map((province) => {
const cityNodes = Array.from(province.cityMap.values()).map((city) =>
createFilterTreeNode(city.id, city.label, Array.from(city.districts.values()), false),
);
return createFilterTreeNode(province.id, province.label, cityNodes, true);
});
}
function updateNode(nodes: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] {
return nodes.map((node) => {
if (node.id === nodeId) return updater(node);
@ -289,10 +380,40 @@ function getSelectionKey(contentKey: ContentKey, nodeId: string) {
return `${contentKey}:${nodeId}`;
}
function getFilterSelectionKey(filterKey: FilterKey, nodeId: string) {
return `${filterKey}:${nodeId}`;
}
function getSeriesValueKey(index: number) {
return `amount${index}`;
}
function isContentFilterKey(filterKey: FilterKey): filterKey is ContentKey {
return filterKey !== 'region';
}
function filterTreeNodesByKeyword(nodes: TreeNode[], keyword: string): TreeNode[] {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) return nodes;
const expandDescendants = (targetNodes: TreeNode[]): TreeNode[] => targetNodes.map((node) => ({
...node,
children: expandDescendants(node.children),
expanded: node.hasChildren || node.children.length > 0 ? true : node.expanded,
}));
return nodes.flatMap((node) => {
const matched = node.label.toLowerCase().includes(normalizedKeyword);
const children = filterTreeNodesByKeyword(node.children, keyword);
if (!matched && children.length === 0) return [];
return [{
...node,
children: matched ? expandDescendants(node.children) : children,
expanded: true,
}];
});
}
function renderTreeNodes(
nodes: TreeNode[],
contentKey: ContentKey,
@ -340,6 +461,51 @@ function renderTreeNodes(
);
}
function renderFilterTreeNodes(
nodes: TreeNode[],
filterKey: FilterKey,
selectedNodeKeys: Set<string>,
onToggle: (nodeId: string) => void,
onSelect: (node: TreeNode) => void,
depth = 0,
) {
return (
<ul className="content-tree-list filter-tree-list" role={depth === 0 ? 'tree' : 'group'}>
{nodes.map((node) => {
const selected = selectedNodeKeys.has(getFilterSelectionKey(filterKey, node.id));
return (
<li className="content-tree-node" role="treeitem" aria-expanded={node.hasChildren ? node.expanded : undefined} key={node.id}>
<div className="content-tree-row filter-tree-row" style={{ paddingLeft: 8 + depth * 18 }}>
{node.hasChildren ? (
<button className="content-tree-caret" type="button" aria-label={node.expanded ? '收起' : '展开'} onClick={() => onToggle(node.id)}>
{node.expanded ? '▾' : '▸'}
</button>
) : (
<span className="content-tree-caret is-leaf" />
)}
<button
className="content-tree-select filter-tree-select"
type="button"
aria-pressed={selected}
onClick={() => onSelect(node)}
title={node.label}
>
<span className="filter-tree-check" aria-hidden="true" />
<span className="content-tree-label">{node.label}</span>
</button>
{node.loading ? <span className="content-tree-loading"></span> : null}
</div>
{node.expanded && node.children.length > 0
? renderFilterTreeNodes(node.children, filterKey, selectedNodeKeys, onToggle, onSelect, depth + 1)
: null}
</li>
);
})}
</ul>
);
}
function App() {
const workspaceRef = useRef<HTMLElement>(null);
const chartFrameRef = useRef<HTMLDivElement>(null);
@ -349,6 +515,13 @@ function App() {
constructionStage: false,
planningForm: false,
});
const filterTreeInitialLoadStartedRef = useRef<Record<FilterKey, boolean>>({
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
const [groupKey, setGroupKey] = useState<GroupKey>('year');
@ -379,11 +552,89 @@ function App() {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingHint, setLoadingHint] = useState('');
const [filterTreeByKey, setFilterTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
region: null,
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
region: null,
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
const [filterSearchValue, setFilterSearchValue] = useState('');
const filterSearchComposingRef = useRef(false);
const filterSearchTimerRef = useRef<number | null>(null);
const filterSearchRequestSeqRef = useRef<Record<FilterKey, number>>({
region: 0,
geoLocation: 0,
facilityType: 0,
constructionStage: 0,
planningForm: 0,
});
const lastFilterSearchRef = useRef('');
const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0];
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0];
const activeTree = treeByContent[activeContentKey];
const activeFilter = filterOptions.find((option) => option.key === filterModalKey);
const activeFilterTree = filterModalKey ? filterTreeByKey[filterModalKey] : [];
const trimmedFilterSearchValue = filterSearchValue.trim();
const activeFilterDisplayTree = filterModalKey && trimmedFilterSearchValue ? filterSearchTreeByKey[filterModalKey] : activeFilterTree;
const activeFilterTreeLoading = filterModalKey
? trimmedFilterSearchValue ? filterSearchLoadingByKey[filterModalKey] : filterTreeLoadingByKey[filterModalKey]
: false;
const activeFilterTreeError = filterModalKey
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
: null;
const activeFilterCount = Object.values(appliedFilters).reduce((total, nodes) => total + nodes.length, 0);
const chartEmptyText = selectedContentNodes.length === 0
? '请选择右侧分类项'
: activeFilterCount > 0
? '当前筛选无数据'
: '所选分类暂无数据';
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
@ -391,6 +642,19 @@ function App() {
() => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))),
[selectedContentNodes],
);
const draftFilterNodeKeys = useMemo(
() => new Set(draftFilterNodes.map((node) => getFilterSelectionKey(node.filterKey, node.id))),
[draftFilterNodes],
);
const appliedFilterPayload = useMemo(
() => filterOptions
.map((option) => ({
key: option.key,
nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })),
}))
.filter((filter) => filter.nodes.length > 0),
[appliedFilters],
);
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
let hash = 0;
@ -401,7 +665,7 @@ function App() {
return chartLineColors[hash];
};
const fetchContentTree = async (contentKey: ContentKey, nodeId?: string) => {
const fetchContentTree = async (contentKey: ContentKey, nodeId?: string, signal?: AbortSignal) => {
const config = contentTreeConfigs[contentKey];
if (!config) {
throw new Error('接口待接入');
@ -428,6 +692,7 @@ function App() {
};
const response = await fetch(`${config.endpoint}?${buildQuery(params)}`, {
credentials: 'include',
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
@ -435,7 +700,65 @@ function App() {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return normalizeTreeRows(pickArray(await response.json()));
const rows = pickArray(await response.json());
return normalizeTreeRows(rows);
};
const normalizeBackendTree = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => ({
...node,
hasChildren: node.children.length > 0 || node.hasChildren,
expanded: node.children.length > 0,
loaded: true,
children: normalizeBackendTree(node.children),
}));
const getTreeNodePrefix = (nodes: TreeNode[]) => {
const stack = [...nodes];
while (stack.length) {
const node = stack.shift();
if (!node) {
continue;
}
const matched = node.id.match(/^(.*_)\d+$/);
if (matched) {
return matched[1];
}
stack.push(...node.children);
}
return '';
};
const fetchRegionFilterTree = async (signal?: AbortSignal) => {
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'region' })}`, {
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const rows = pickArray(await response.json());
const flatRegionTree = normalizeFlatRegionRows(rows);
return flatRegionTree.length > 0 ? flatRegionTree : normalizeBackendTree(normalizeTreeRows(rows));
};
const fetchBackendFilterTreeSearch = async (filterKey: FilterKey, keyword: string, signal?: AbortSignal) => {
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTreeSearch?${buildQuery({
key: filterKey,
keyword,
nodePrefix: isContentFilterKey(filterKey) ? getTreeNodePrefix(filterTreeByKey[filterKey]) : '',
})}`, {
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(`搜索接口请求失败HTTP ${response.status}${errorText ? ` ${errorText.slice(0, 160)}` : ''}`);
}
return normalizeBackendTree(normalizeTreeRows(pickArray(await response.json())));
};
const loadContentTreeWithDefaultExpansion = async (contentKey: ContentKey) => {
@ -463,6 +786,17 @@ function App() {
return loadChildren(await fetchContentTree(contentKey), 1);
};
const loadFilterTree = async (filterKey: FilterKey, keyword?: string, signal?: AbortSignal) => {
if (filterKey === 'region') {
const nodes = await fetchRegionFilterTree(signal);
return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes;
}
if (keyword?.trim()) {
return fetchBackendFilterTreeSearch(filterKey, keyword, signal);
}
return loadContentTreeWithDefaultExpansion(filterKey);
};
const toggleContentNode = (nodeId: string) => {
if (!contentTreeConfigs[activeContentKey]) return;
const target = activeTree.find((node) => node.id === nodeId);
@ -546,6 +880,196 @@ function App() {
setLoading(false);
};
const ensureFilterTreeLoaded = (filterKey: FilterKey) => {
if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return;
filterTreeInitialLoadStartedRef.current[filterKey] = true;
setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: true }));
setFilterTreeErrorByKey((current) => ({ ...current, [filterKey]: null }));
loadFilterTree(filterKey)
.then((nodes) => {
setFilterTreeByKey((current) => ({ ...current, [filterKey]: nodes }));
})
.catch((error) => {
filterTreeInitialLoadStartedRef.current[filterKey] = false;
setFilterTreeErrorByKey((current) => ({
...current,
[filterKey]: error instanceof Error ? error.message : '加载失败',
}));
})
.finally(() => {
setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: false }));
});
};
const openFilterModal = (filterKey: FilterKey) => {
setFilterModalKey(filterKey);
setDraftFilterNodes(appliedFilters[filterKey]);
setFilterSearchValue('');
lastFilterSearchRef.current = '';
if (filterSearchTimerRef.current != null) {
window.clearTimeout(filterSearchTimerRef.current);
filterSearchTimerRef.current = null;
}
ensureFilterTreeLoaded(filterKey);
};
const closeFilterModal = () => {
if (filterModalKey) {
filterSearchRequestSeqRef.current[filterModalKey] += 1;
}
if (filterSearchTimerRef.current != null) {
window.clearTimeout(filterSearchTimerRef.current);
filterSearchTimerRef.current = null;
}
lastFilterSearchRef.current = '';
setFilterModalKey(null);
setDraftFilterNodes([]);
setFilterSearchValue('');
};
const scheduleFilterSearch = (filterKey: FilterKey, rawKeyword: string) => {
if (filterSearchTimerRef.current != null) {
window.clearTimeout(filterSearchTimerRef.current);
filterSearchTimerRef.current = null;
}
const keyword = rawKeyword.trim();
const searchKey = `${filterKey}:${keyword}`;
if (keyword && lastFilterSearchRef.current === searchKey) {
return;
}
lastFilterSearchRef.current = searchKey;
filterSearchRequestSeqRef.current[filterKey] += 1;
const requestSeq = filterSearchRequestSeqRef.current[filterKey];
if (!keyword) {
setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: [] }));
setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null }));
setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false }));
return;
}
filterSearchTimerRef.current = window.setTimeout(() => {
setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: true }));
setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null }));
void loadFilterTree(filterKey, keyword)
.then((nodes) => {
if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return;
setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: nodes }));
})
.catch((error) => {
if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return;
setFilterSearchErrorByKey((current) => ({
...current,
[filterKey]: error instanceof Error ? error.message : '加载失败',
}));
})
.finally(() => {
if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return;
setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false }));
});
}, 500);
};
const toggleFilterTreeNode = (nodeId: string) => {
if (!filterModalKey) return;
const visit = (nodes: TreeNode[]): TreeNode | null => {
for (const node of nodes) {
if (node.id === nodeId) return node;
const matched = visit(node.children);
if (matched) return matched;
}
return null;
};
const node = visit(filterTreeByKey[filterModalKey]);
if (!node?.hasChildren) return;
setFilterTreeByKey((current) => ({
...current,
[filterModalKey]: updateNode(current[filterModalKey], nodeId, (currentNode) => ({
...currentNode,
expanded: currentNode.loaded ? !currentNode.expanded : currentNode.expanded,
loading: currentNode.loaded ? currentNode.loading : true,
})),
}));
if (node.loaded || !isContentFilterKey(filterModalKey)) return;
const currentFilterKey = filterModalKey;
fetchContentTree(currentFilterKey, nodeId)
.then((children) => {
setFilterTreeByKey((current) => ({
...current,
[currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({
...currentNode,
children,
expanded: true,
loading: false,
loaded: true,
hasChildren: currentNode.hasChildren || children.length > 0,
})),
}));
})
.catch((error) => {
setFilterTreeByKey((current) => ({
...current,
[currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({
...currentNode,
loading: false,
})),
}));
setFilterTreeErrorByKey((current) => ({
...current,
[currentFilterKey]: error instanceof Error ? error.message : '加载失败',
}));
});
};
const toggleDraftFilterNode = (node: TreeNode) => {
if (!filterModalKey) return;
const currentFilterKey = filterModalKey;
setDraftFilterNodes((current) => {
const selectionKey = getFilterSelectionKey(currentFilterKey, node.id);
const exists = current.some((item) => getFilterSelectionKey(item.filterKey, item.id) === selectionKey);
if (exists) {
return current.filter((item) => getFilterSelectionKey(item.filterKey, item.id) !== selectionKey);
}
return [...current, { id: node.id, filterKey: currentFilterKey, label: node.label }];
});
};
const applyFilterModal = () => {
if (!filterModalKey) return;
setAppliedFilters((current) => ({
...current,
[filterModalKey]: draftFilterNodes,
}));
setChartDataBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
closeFilterModal();
};
const clearFilter = (filterKey: FilterKey) => {
if (appliedFilters[filterKey].length === 0) return;
setAppliedFilters((current) => ({
...current,
[filterKey]: [],
}));
setChartDataBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
};
const updateMetricKey = (nextMetricKey: MetricKey) => {
setMetricKey(nextMetricKey);
setChartDataBySelection({});
@ -612,6 +1136,7 @@ function App() {
body: JSON.stringify({
groupBy: groupKey,
metric: requestMetricKey,
filters: appliedFilterPayload,
nodes: selectedContentNodes.map((node) => ({
key: getSelectionKey(node.contentKey, node.id),
contentKey: node.contentKey,
@ -646,7 +1171,7 @@ function App() {
return () => {
controller.abort();
};
}, [chartQueryVersion, groupKey, metricKey, requestMetricKey, selectedContentNodes]);
}, [appliedFilterPayload, chartQueryVersion, groupKey, metricKey, requestMetricKey, selectedContentNodes]);
useEffect(() => {
const frame = chartFrameRef.current;
@ -831,7 +1356,7 @@ function App() {
},
overlays: {
noData: {
text: '请选择右侧分类项',
text: chartEmptyText,
},
},
annotations: {
@ -955,7 +1480,7 @@ function App() {
pagination: true,
},
};
}, [chartDataBySelection, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]);
}, [activeFilterCount, chartDataBySelection, chartEmptyText, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]);
return (
<main className="dashboard-shell">
@ -966,6 +1491,51 @@ function App() {
</div>
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
<div className="chart-filter-bar chart-filter-bar--workspace" aria-label="筛选条件">
{filterOptions.map((option) => {
const count = appliedFilters[option.key].length;
const FilterIcon = option.icon;
return (
<button
className="chart-filter-button"
type="button"
key={option.key}
aria-pressed={count > 0}
title={count > 0 ? `${option.label}:已选${count}` : option.label}
onClick={() => openFilterModal(option.key)}
>
<FilterIcon className="chart-filter-icon" aria-hidden="true" strokeWidth={2} />
<span>{option.label}</span>
{count > 0 ? <strong>{count}</strong> : null}
</button>
);
})}
{activeFilterCount > 0 ? (
<button
className="chart-filter-clear"
type="button"
title="清空全部筛选"
onClick={() => {
setAppliedFilters({
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
setChartDataBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
}}
>
</button>
) : null}
</div>
<section className="chart-area" aria-label="年度总费用图表">
<div className="chart-frame" ref={chartFrameRef}>
{statisticMenuOpen ? (
@ -1064,6 +1634,72 @@ function App() {
</div>
</aside>
</section>
{filterModalKey && activeFilter ? (
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
<section
className="filter-modal"
role="dialog"
aria-modal="true"
aria-label={`${activeFilter.label}筛选`}
onMouseDown={(event) => event.stopPropagation()}
>
<header className="filter-modal-header">
<h2>{activeFilter.label}</h2>
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
</header>
<div className="filter-modal-search">
<input
type="search"
value={filterSearchValue}
placeholder="搜索"
onChange={(event) => {
const nextValue = event.target.value;
setFilterSearchValue(nextValue);
if (!filterSearchComposingRef.current && filterModalKey) {
scheduleFilterSearch(filterModalKey, nextValue);
}
}}
onCompositionStart={() => {
filterSearchComposingRef.current = true;
}}
onCompositionEnd={(event) => {
filterSearchComposingRef.current = false;
const nextValue = event.currentTarget.value;
setFilterSearchValue(nextValue);
if (filterModalKey) {
scheduleFilterSearch(filterModalKey, nextValue);
}
}}
/>
</div>
<div className="filter-modal-selected">
{draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length}` : '未选择'}
</div>
<div className="filter-modal-tree">
{activeFilterTreeLoading ? (
<div className="content-tree-empty"></div>
) : activeFilterTreeError ? (
<div className="content-tree-empty">{activeFilterTreeError}</div>
) : activeFilterDisplayTree.length > 0 ? (
renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode)
) : (
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
)}
</div>
<footer className="filter-modal-actions">
<button className="filter-modal-clear" type="button" onClick={() => setDraftFilterNodes([])}>
</button>
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
</button>
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
</button>
</footer>
</section>
</div>
) : null}
</main>
);
}

View File

@ -65,9 +65,10 @@ button {
z-index: 1;
display: grid;
grid-template-columns: minmax(540px, 52vw) 1fr;
gap: 28px;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px 28px;
height: 100vh;
padding: 8px 28px 18px 64px;
padding: 30px 28px 18px 64px;
}
.chart-area {
@ -157,7 +158,84 @@ button {
position: relative;
min-width: 0;
min-height: 0;
padding: 26px 0 0;
padding: 16px 0 0;
}
.chart-filter-bar {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
justify-content: center;
}
.chart-filter-bar--workspace {
grid-column: 1 / -1;
position: relative;
z-index: 2;
margin: 0 28px 0 64px;
padding-right: 58px;
}
.chart-filter-button,
.chart-filter-clear {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
max-width: 136px;
padding: 0 10px;
border: 1px solid rgba(90, 82, 72, 0.18);
border-radius: 3px;
color: #46413b;
background: rgba(255, 249, 241, 0.76);
font-size: 13px;
line-height: 30px;
white-space: nowrap;
cursor: pointer;
}
.chart-filter-icon {
flex: 0 0 14px;
width: 14px;
height: 14px;
display: block;
color: currentColor;
}
.chart-filter-button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-filter-button strong {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 9px;
color: #fff;
background: #0078a8;
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.chart-filter-button:hover,
.chart-filter-button[aria-pressed="true"],
.chart-filter-clear:hover {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
background: rgba(255, 252, 248, 0.96);
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.chart-filter-clear {
flex: 0 0 auto;
color: #7a3f2b;
}
.workspace:fullscreen {
@ -165,7 +243,8 @@ button {
z-index: 10;
display: grid;
grid-template-columns: minmax(540px, 52vw) 1fr;
gap: 28px;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px 28px;
width: 100vw;
height: 100vh;
padding: 8px 28px 18px 64px;
@ -173,7 +252,7 @@ button {
}
.workspace:fullscreen .chart-area {
height: calc(100vh - 26px);
min-height: 0;
}
.workspace:fullscreen .right-panel {
@ -182,6 +261,11 @@ button {
height: 100%;
}
.workspace:fullscreen .chart-filter-bar--workspace {
margin: 0;
padding-right: 0;
}
.chart-frame > div {
height: 100%;
}
@ -193,7 +277,7 @@ button {
.chart-loading-mask {
position: absolute;
inset: 26px 0 0;
inset: 16px 0 0;
z-index: 13;
display: flex;
align-items: center;
@ -688,6 +772,177 @@ button {
line-height: 24px;
}
.filter-modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(35, 30, 25, 0.28);
}
.filter-modal {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(560px, calc(100vw - 56px));
height: min(680px, calc(100vh - 56px));
min-height: 420px;
border: 1px solid rgba(90, 82, 72, 0.2);
border-radius: 6px;
background: #fff7ef;
box-shadow: 0 18px 50px rgba(35, 30, 25, 0.22);
overflow: hidden;
}
.filter-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 16px 18px 12px;
border-bottom: 1px solid rgba(90, 82, 72, 0.12);
}
.filter-modal-header h2 {
margin: 0;
color: #262a33;
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.filter-modal-close {
display: grid;
width: 30px;
height: 30px;
place-items: center;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
color: #6d6258;
background: transparent;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.filter-modal-close:hover {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.28);
background: rgba(255, 252, 248, 0.9);
}
.filter-modal-search {
padding: 12px 18px 8px;
}
.filter-modal-search input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid rgba(90, 82, 72, 0.2);
border-radius: 3px;
color: #262a33;
background: #fffdfa;
font-size: 14px;
outline: none;
}
.filter-modal-search input:focus {
border-color: rgba(0, 120, 168, 0.46);
box-shadow: 0 0 0 2px rgba(0, 120, 168, 0.12);
}
.filter-modal-selected {
padding: 0 18px 8px;
color: #6d6258;
font-size: 13px;
line-height: 20px;
}
.filter-modal-tree {
min-height: 0;
overflow: auto;
padding: 4px 12px 12px;
border-top: 1px solid rgba(90, 82, 72, 0.1);
}
.filter-tree-row {
min-height: 36px;
font-size: 15px;
line-height: 36px;
}
.filter-tree-select {
height: 32px;
gap: 8px;
}
.filter-tree-check {
position: relative;
width: 16px;
height: 16px;
flex: 0 0 16px;
border: 1px solid rgba(90, 82, 72, 0.32);
border-radius: 3px;
background: rgba(255, 252, 248, 0.82);
}
.filter-tree-select[aria-pressed="true"] .filter-tree-check {
border-color: #0078a8;
background: #0078a8;
}
.filter-tree-select[aria-pressed="true"] .filter-tree-check::after {
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg);
content: "";
}
.filter-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid rgba(90, 82, 72, 0.12);
}
.filter-modal-actions button {
height: 32px;
min-width: 72px;
padding: 0 14px;
border: 1px solid rgba(90, 82, 72, 0.18);
border-radius: 3px;
background: rgba(255, 249, 241, 0.76);
color: #46413b;
font-size: 14px;
cursor: pointer;
}
.filter-modal-actions button:hover {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
background: rgba(255, 252, 248, 0.96);
}
.filter-modal-confirm {
color: #fff !important;
border-color: #0078a8 !important;
background: #0078a8 !important;
}
.filter-modal-confirm:hover {
background: #006f9b !important;
}
@media (max-width: 900px) {
body {
overflow: auto;

View File

@ -113,3 +113,79 @@ Port 5173 is in use, trying another one...
10:04:35 [vite] (client) hmr update /src/styles.css
10:06:06 [vite] (client) hmr update /src/App.tsx
10:06:25 [vite] (client) hmr update /src/styles.css
14:21:06 [vite] (client) hmr update /src/App.tsx
14:26:18 [vite] (client) hmr update /src/App.tsx
14:26:51 [vite] (client) hmr update /src/styles.css
14:28:15 [vite] (client) hmr update /src/App.tsx
14:28:24 [vite] (client) hmr update /src/App.tsx
15:07:03 [vite] (client) hmr update /src/App.tsx
15:07:31 [vite] (client) hmr update /src/App.tsx
15:08:11 [vite] (client) hmr update /src/App.tsx
15:13:59 [vite] (client) hmr update /src/App.tsx
16:47:31 [vite] (client) hmr update /src/App.tsx
16:48:22 [vite] (client) hmr update /src/App.tsx
16:48:32 [vite] (client) hmr update /src/App.tsx
17:00:07 [vite] (client) hmr update /src/App.tsx
17:15:44 [vite] (client) hmr update /src/App.tsx
18:06:45 [vite] (client) hmr update /src/App.tsx
18:07:01 [vite] (client) hmr update /src/App.tsx
18:07:22 [vite] (client) hmr update /src/App.tsx
18:07:43 [vite] (client) hmr update /src/App.tsx
18:08:03 [vite] (client) hmr update /src/App.tsx
18:08:20 [vite] (client) hmr update /src/App.tsx
18:08:55 [vite] (client) hmr update /src/App.tsx
18:09:09 [vite] (client) hmr update /src/App.tsx
18:09:41 [vite] (client) hmr update /src/App.tsx
18:10:20 [vite] (client) hmr update /src/styles.css
18:11:00 [vite] (client) hmr update /src/styles.css
09:08:22 [vite] (client) hmr update /src/App.tsx
09:09:58 [vite] (client) hmr update /src/App.tsx
09:10:10 [vite] (client) hmr update /src/styles.css
09:12:48 [vite] (client) hmr update /src/styles.css
09:24:09 [vite] (client) hmr update /src/styles.css
09:24:54 [vite] (client) hmr update /src/styles.css
09:25:33 [vite] (client) hmr update /src/App.tsx
09:25:55 [vite] (client) hmr update /src/styles.css
09:31:55 [vite] (client) hmr update /src/App.tsx
09:32:05 [vite] (client) hmr update /src/App.tsx
09:32:12 [vite] (client) hmr update /src/App.tsx
09:32:32 [vite] (client) hmr update /src/styles.css
09:45:56 [vite] (client) hmr update /src/App.tsx
10:00:51 [vite] (client) hmr update /src/App.tsx
10:01:08 [vite] (client) hmr update /src/App.tsx
10:01:23 [vite] (client) hmr update /src/App.tsx
10:01:36 [vite] (client) hmr update /src/App.tsx
10:01:46 [vite] (client) hmr update /src/App.tsx
10:01:56 [vite] (client) hmr update /src/App.tsx
10:02:58 [vite] (client) hmr update /src/App.tsx
10:08:33 [vite] (client) hmr update /src/App.tsx
10:08:56 [vite] (client) hmr update /src/App.tsx
10:09:20 [vite] (client) hmr update /src/styles.css
10:09:29 [vite] (client) hmr update /src/styles.css
10:12:15 [vite] (client) hmr update /src/styles.css
10:17:02 [vite] (client) hmr update /src/App.tsx
10:54:20 [vite] (client) hmr update /src/App.tsx
10:54:44 [vite] (client) hmr update /src/App.tsx
10:59:54 [vite] (client) hmr update /src/App.tsx
11:00:16 [vite] (client) hmr update /src/App.tsx
11:00:32 [vite] (client) hmr update /src/App.tsx
11:00:55 [vite] (client) hmr update /src/App.tsx
11:12:15 [vite] (client) hmr update /src/App.tsx
11:16:52 [vite] (client) hmr update /src/App.tsx
11:20:34 [vite] (client) hmr update /src/App.tsx
11:22:39 [vite] (client) hmr update /src/App.tsx
11:30:32 [vite] (client) hmr update /src/App.tsx
11:42:14 [vite] (client) hmr update /src/App.tsx
11:48:40 [vite] (client) hmr update /src/App.tsx
12:07:11 [vite] (client) hmr update /src/App.tsx
12:07:25 [vite] (client) hmr update /src/App.tsx
12:07:50 [vite] (client) hmr update /src/App.tsx
14:44:40 [vite] (client) hmr update /src/App.tsx
14:47:46 [vite] (client) hmr update /src/App.tsx
14:48:15 [vite] (client) hmr update /src/App.tsx
14:51:05 [vite] (client) hmr update /src/App.tsx
15:13:54 [vite] (client) hmr update /src/App.tsx
15:14:22 [vite] (client) hmr update /src/App.tsx
15:17:54 [vite] (client) hmr update /src/App.tsx
15:18:05 [vite] (client) hmr update /src/App.tsx
15:18:58 [vite] (client) hmr update /src/App.tsx