agchart/src/App.tsx
2026-05-09 18:22:43 +08:00

1708 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
CrosshairModule,
LicenseManager,
ZoomModule,
} from 'ag-charts-enterprise';
import { AG_CHARTS_LOCALE_ZH_CN } from 'ag-charts-locale';
LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b');
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: '低' },
{ key: 'maxValue', label: '最高值', shortLabel: '高' },
{ key: 'avgValue', label: '平均值', shortLabel: '均' },
{ key: 'medianValue', label: '中位数', shortLabel: '中' },
] as const;
const metricOptions = [
{ key: 'cost', label: '造价(元)' },
{ key: 'buildingArea', label: '建筑面积指标(元/m²' },
{ key: 'builtArea', label: '建造面积指标(元/m²' },
{ key: 'usableArea', label: '使用面积指标(元/m²' },
{ key: 'dataCount', label: '数据量' },
] as const;
const contentOptions = [
{ key: 'geoLocation', label: '自然地理区位' },
{ key: 'facilityType', label: '设施类别' },
{ key: 'constructionStage', label: '建设阶段' },
{ 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',
requestid: '-1',
workflowid: '181028',
wfid: '181028',
billid: '-1812',
isbill: '1',
f_weaver_belongto_userid: '267',
f_weaver_belongto_usertype: '0',
wf_isagent: '0',
wf_beagenter: '0',
wfTestStr: '',
viewtype: '1',
fromModule: 'workflow',
wfCreater: '267',
disabledConditionCache: 'true',
companyId: '1',
};
const contentTreeConfigs = {
geoLocation: {
endpoint: '/api/public/browser/data/256',
treeid: '94004',
fieldid: '305425',
defaultExpandedLevel: 1,
},
facilityType: {
endpoint: '/api/public/browser/data/256',
treeid: '94005',
fieldid: '305426',
defaultExpandedLevel: 3,
},
constructionStage: {
endpoint: '/api/public/browser/data/256',
treeid: '94007',
fieldid: '305428',
defaultExpandedLevel: 1,
},
planningForm: {
endpoint: '/api/public/browser/data/256',
treeid: '94006',
fieldid: '305427',
defaultExpandedLevel: 1,
},
} as const;
const chartLineColors = ['#0078a8', '#d14d72', '#1f8f4d', '#d96f23', '#6b5cc8', '#0d7680', '#9a6b12', '#b24b38'];
// const mockGeoLocationPayload = {
// checkStrictly: true,
// type: 3,
// datas: [
// {
// allVersionIds: '',
// canClick: false,
// checkStrictly: true,
// dsporder: 0,
// icon: '',
// id: '95005_22',
// isImgIcon: false,
// isParent: true,
// linkUrl: '/formmode/search/CustomSearchOpenTree.jsp?pid=95005_22',
// name: '中国',
// pid: '0_0',
// selected: false,
// title: '中国',
// type: '2',
// },
// ],
// iconSetting: {
// bgColor: '#96358a',
// icon: 'icon-coms-ModelingEngine',
// fontColor: '#fff',
// },
// };
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;
group_name?: string | null;
min_value?: number | null;
max_value?: number | null;
avg_value?: number | null;
median_value?: number | null;
data_count?: number | null;
};
type ApiBuildingFunctionStatBatchItem = {
key?: string;
data?: ApiBuildingFunctionStat[];
};
type ChartDatum = {
groupName: string;
minValue: number | null;
maxValue: number | null;
avgValue: number | null;
medianValue: number | null;
dataCount: number | null;
};
type TreeNode = {
id: string;
label: string;
children: TreeNode[];
hasChildren: boolean;
canClick: boolean;
expanded: boolean;
loading: boolean;
loaded: boolean;
};
type SelectedContentNode = {
id: string;
contentKey: ContentKey;
label: string;
color: string;
};
type SelectedFilterNode = {
id: string;
filterKey: FilterKey;
label: string;
};
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
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) {
if (metricKey === 'dataCount') {
return formatNumber(value, 0);
}
if (metricKey === 'cost') {
return formatCostValue(value);
}
return formatAreaMetricValue(value);
}
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
return {
groupName: row.group_name || String(row.group_key ?? '未命名'),
minValue: row.min_value ?? null,
maxValue: row.max_value ?? null,
avgValue: row.avg_value ?? null,
medianValue: row.median_value ?? null,
dataCount: row.data_count ?? null,
};
}
function buildQuery(params: Record<string, string | number | boolean | null | undefined>) {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value == null) return;
search.set(key, String(value));
});
search.set('currenttime', String(Date.now()));
search.set('__random__', String(Date.now()));
return search.toString();
}
function pickArray(payload: unknown): unknown[] {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== 'object') return [];
const source = payload as Record<string, unknown>;
const candidates = [
source.datas,
source.data,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).datas : null,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).list : null,
source.data && typeof source.data === 'object' ? (source.data as Record<string, unknown>).treeDatas : null,
source.result,
source.rows,
source.list,
source.treeDatas,
source.children,
];
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
return [];
}
function readText(row: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = row[key];
if (value !== null && value !== undefined && String(value).trim()) {
return String(value);
}
}
return '';
}
function normalizeTreeRows(rows: unknown[]): TreeNode[] {
return rows
.filter((row): row is Record<string, unknown> => !!row && typeof row === 'object')
.map((row, index) => {
const children = normalizeTreeRows(pickArray(row.children ?? row.childs ?? row.subs));
const id = readText(row, ['id', 'key', 'value', 'nodeid', 'treeid', 'browserid']) || `node-${index}`;
const label = readText(row, ['name', 'label', 'title', 'text', 'showname', 'showName', 'browsername', 'displayName']) || id;
const hasChildren =
children.length > 0 ||
row.hasChild === true ||
row.haschild === true ||
row.isParent === true ||
row.isparent === true ||
row.children === true ||
row.child === true;
const canClick = row.canClick === true || row.canClick === 'true' || row.canclick === true || row.canclick === 'true';
return {
id,
label,
children,
hasChildren,
canClick,
expanded: children.length > 0,
loading: false,
loaded: children.length > 0,
};
});
}
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);
return {
...node,
children: updateNode(node.children, nodeId, updater),
};
});
}
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,
selectedNodeKeys: Set<string>,
getNodeColor: (contentKey: ContentKey, nodeId: string) => string,
onToggle: (nodeId: string) => void,
onSelect: (node: TreeNode) => void,
depth = 0,
) {
return (
<ul className="content-tree-list" role={depth === 0 ? 'tree' : 'group'}>
{nodes.map((node) => {
const selected = selectedNodeKeys.has(getSelectionKey(contentKey, node.id));
const color = getNodeColor(contentKey, 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" 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"
type="button"
aria-pressed={selected}
onClick={() => onSelect(node)}
title={node.label}
>
<span className="content-tree-series-mark" style={{ backgroundColor: color }} />
<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
? renderTreeNodes(node.children, contentKey, selectedNodeKeys, getNodeColor, onToggle, onSelect, depth + 1)
: null}
</li>
);
})}
</ul>
);
}
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);
const treeInitialLoadStartedRef = useRef<Record<ContentKey, boolean>>({
geoLocation: false,
facilityType: false,
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');
const [statisticMenuOpen, setStatisticMenuOpen] = useState(false);
const [metricMenuOpen, setMetricMenuOpen] = useState(false);
const [activeContentKey, setActiveContentKey] = useState<ContentKey>('geoLocation');
const [treeByContent, setTreeByContent] = useState<Record<ContentKey, TreeNode[]>>({
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [treeLoadingByContent, setTreeLoadingByContent] = useState<Record<ContentKey, boolean>>({
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [treeErrorByContent, setTreeErrorByContent] = useState<Record<ContentKey, string | null>>({
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [selectedContentNodes, setSelectedContentNodes] = useState<SelectedContentNode[]>([]);
const [chartDataBySelection, setChartDataBySelection] = useState<Record<string, ChartDatum[]>>({});
const [chartQueryVersion, setChartQueryVersion] = useState(0);
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;
const selectedNodeKeys = useMemo(
() => 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;
const key = getSelectionKey(contentKey, nodeId);
for (let index = 0; index < key.length; index += 1) {
hash = (hash * 31 + key.charCodeAt(index)) % chartLineColors.length;
}
return chartLineColors[hash];
};
const fetchContentTree = async (contentKey: ContentKey, nodeId?: string, signal?: AbortSignal) => {
const config = contentTreeConfigs[contentKey];
if (!config) {
throw new Error('接口待接入');
}
const treeParams = {
...browserTreeDefaults,
treeid: config.treeid,
cube_treeid: config.treeid,
fieldid: config.fieldid,
};
const params = nodeId
? {
...treeParams,
type: '2',
id: nodeId,
isVirtual: '',
}
: {
...treeParams,
pageSize: '10',
current: '1',
min: '1',
max: '10',
};
const response = await fetch(`${config.endpoint}?${buildQuery(params)}`, {
credentials: 'include',
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
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) => {
const defaultExpandedLevel = contentTreeConfigs[contentKey]?.defaultExpandedLevel ?? 0;
const loadChildren = async (nodes: TreeNode[], level: number): Promise<TreeNode[]> => {
if (level > defaultExpandedLevel) return nodes;
return Promise.all(
nodes.map(async (node) => {
if (!node.hasChildren) return node;
const children = node.loaded ? node.children : await fetchContentTree(contentKey, node.id);
return {
...node,
children: await loadChildren(children, level + 1),
expanded: true,
loading: false,
loaded: true,
hasChildren: node.hasChildren || children.length > 0,
};
}),
);
};
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);
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 = target ?? visit(activeTree);
if (!node?.hasChildren) return;
setTreeByContent((current) => ({
...current,
[activeContentKey]: updateNode(current[activeContentKey], nodeId, (node) => ({
...node,
expanded: node.loaded ? !node.expanded : node.expanded,
loading: node.loaded ? node.loading : true,
})),
}));
if (node.loaded) return;
const currentContentKey = activeContentKey;
fetchContentTree(currentContentKey, nodeId)
.then((children) => {
setTreeByContent((current) => ({
...current,
[currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({
...currentNode,
children,
expanded: true,
loading: false,
loaded: true,
hasChildren: currentNode.hasChildren || children.length > 0,
})),
}));
})
.catch((error) => {
setTreeByContent((current) => ({
...current,
[currentContentKey]: updateNode(current[currentContentKey], nodeId, (currentNode) => ({
...currentNode,
loading: false,
})),
}));
setTreeErrorByContent((current) => ({
...current,
[currentContentKey]: error instanceof Error ? error.message : '加载失败',
}));
});
};
const toggleSelectedContentNode = (node: TreeNode) => {
const contentKey = activeContentKey;
setSelectedContentNodes((current) => {
const selectionKey = getSelectionKey(contentKey, node.id);
const existingIndex = current.findIndex((item) => getSelectionKey(item.contentKey, item.id) === selectionKey);
if (existingIndex >= 0) {
setChartDataBySelection((data) => {
const { [selectionKey]: _removed, ...rest } = data;
return rest;
});
return current.filter((_, index) => index !== existingIndex);
}
const color = getNodeColor(contentKey, node.id);
return [...current, { id: node.id, contentKey, label: node.label, color }];
});
};
const handleActiveContentKeyChange = (nextContentKey: ContentKey) => {
if (nextContentKey === activeContentKey) return;
setActiveContentKey(nextContentKey);
setSelectedContentNodes([]);
setChartDataBySelection({});
setLoadError(null);
setLoadingHint('');
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({});
setLoadError(null);
setLoadingHint('正在重新加载数据');
setLoading(true);
setChartQueryVersion((version) => version + 1);
};
useEffect(() => {
if (!contentTreeConfigs[activeContentKey]) return;
if (treeByContent[activeContentKey].length > 0 || treeInitialLoadStartedRef.current[activeContentKey]) return;
const currentContentKey = activeContentKey;
treeInitialLoadStartedRef.current[currentContentKey] = true;
setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: true }));
setTreeErrorByContent((current) => ({ ...current, [currentContentKey]: null }));
loadContentTreeWithDefaultExpansion(currentContentKey)
.then((nodes) => {
setTreeByContent((current) => ({ ...current, [currentContentKey]: nodes }));
})
.catch((error) => {
treeInitialLoadStartedRef.current[currentContentKey] = false;
setTreeErrorByContent((current) => ({
...current,
[currentContentKey]: error instanceof Error ? error.message : '加载失败',
}));
})
.finally(() => {
setTreeLoadingByContent((current) => ({ ...current, [currentContentKey]: false }));
});
}, [activeContentKey, treeByContent]);
useEffect(() => {
const controller = new AbortController();
async function loadStats() {
if (selectedContentNodes.length === 0) {
setChartDataBySelection({});
setLoading(false);
setLoadingHint('');
setLoadError(null);
return;
}
setLoading(true);
setLoadingHint('正在加载数据');
setLoadError(null);
try {
const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]);
if (!hasAnyMissingNode) {
setLoading(false);
setLoadingHint('');
return;
}
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: requestMetricKey,
filters: appliedFilterPayload,
nodes: selectedContentNodes.map((node) => ({
key: getSelectionKey(node.contentKey, node.id),
contentKey: node.contentKey,
nodeId: node.id,
})),
}),
});
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),
}));
} catch (error) {
if (controller.signal.aborted) return;
setLoadError(error instanceof Error ? error.message : '接口请求失败');
} finally {
if (!controller.signal.aborted) {
setLoading(false);
setLoadingHint('');
}
}
}
void loadStats();
return () => {
controller.abort();
};
}, [appliedFilterPayload, chartQueryVersion, groupKey, metricKey, requestMetricKey, selectedContentNodes]);
useEffect(() => {
const frame = chartFrameRef.current;
const fullscreenTarget = workspaceRef.current;
if (!frame || !fullscreenTarget) return;
const getFullscreenButton = () => frame.querySelector<HTMLButtonElement>('.chart-fullscreen-button');
const getStatisticButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-statistic')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const syncToolbarButtons = () => {
const button = getFullscreenButton();
if (button) {
let icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
if (!icon) {
button.innerHTML = '<i class="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button"></i>';
icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
}
const isFullscreen = document.fullscreenElement === fullscreenTarget;
button.classList.toggle('ag-charts-toolbar__button--active', isFullscreen);
icon?.classList.toggle('anticon-arrow-salt', !isFullscreen);
icon?.classList.toggle('anticon-shrink', isFullscreen);
}
const statisticButton = getStatisticButton();
if (statisticButton) {
statisticButton.classList.add('chart-statistic-button');
statisticButton.setAttribute('aria-expanded', String(statisticMenuOpen));
}
};
const toggleFullscreen = () => {
if (document.fullscreenElement === fullscreenTarget) {
void document.exitFullscreen();
} else {
void fullscreenTarget.requestFullscreen();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'F11') return;
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
const handleFullscreenChange = () => {
syncToolbarButtons();
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button, .chart-statistic-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else {
toggleFullscreen();
}
};
const handleToolbarKeyDown = (event: KeyboardEvent) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button, .chart-statistic-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else {
toggleFullscreen();
}
};
const suppressBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
const observer = new MutationObserver(syncToolbarButtons);
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('click', handleToolbarClick, true);
frame.addEventListener('keydown', handleToolbarKeyDown, true);
observer.observe(frame, { childList: true, subtree: true });
syncToolbarButtons();
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('click', handleToolbarClick, true);
frame.removeEventListener('keydown', handleToolbarKeyDown, true);
observer.disconnect();
};
}, [statisticMenuOpen]);
const chartOptions = useMemo<AgCartesianChartOptions>(() => {
const groupNames: string[] = [];
const groupNameSeen = new Set<string>();
selectedContentNodes.forEach((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
rows.forEach((datum) => {
if (groupNameSeen.has(datum.groupName)) return;
groupNameSeen.add(datum.groupName);
groupNames.push(datum.groupName);
});
});
const visibleData = groupNames.map((groupName) => {
const row: Record<string, string | number | null> = { groupName };
selectedContentNodes.forEach((node, index) => {
const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
row[getSeriesValueKey(index)] = datum?.[selectedValueKey] ?? null;
});
return row;
});
return {
theme: {
palette: {
fills: ['#006f9b', '#ff7faa', '#00994d', '#ff8833', '#00a0dd'],
strokes: ['#003f58', '#934962', '#004a25', '#914d1d', '#006288'],
},
params: {
foregroundColor: '#262a33',
backgroundColor: '#fff1e5',
accentColor: '#0d7680',
fontFamily: '"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif',
fontSize: 14,
tooltipBackgroundColor: '#fff7ef',
tooltipTextColor: '#262a33',
},
},
locale: {
localeText: AG_CHARTS_LOCALE_ZH_CN,
},
background: {
fill: 'transparent',
},
padding: {
top: 16,
right: 16,
bottom: 18,
left: 24,
},
data: visibleData,
zoom: {
enabled: true,
anchorPointX: 'pointer',
anchorPointY: 'pointer',
buttons: {
enabled: true,
visible: 'hover',
buttons: [
{ icon: 'zoom-out', value: 'zoom-out', section: 'zoom', tooltip: '缩小' },
{ icon: 'zoom-in', value: 'zoom-in', section: 'zoom', tooltip: '放大' },
{ icon: 'pan-left', value: 'pan-left', section: 'pan', tooltip: '左移' },
{ icon: 'pan-right', value: 'pan-right', section: 'pan', tooltip: '右移' },
{ icon: 'reset', value: 'reset', section: 'reset', tooltip: '重置' },
],
},
},
contextMenu: {
enabled: true,
items: ['defaults'],
},
overlays: {
noData: {
text: chartEmptyText,
},
},
annotations: {
enabled: true,
toolbar: {
buttons: ([
{
value: 'note',
tooltip: '切换统计指标',
label: `<span class="ag-charts-myButton-statistic ag-charts-diy-button">${selectedStatistic.shortLabel}</span>`,
},
{
icon: 'trend-line-drawing',
value: 'line-menu',
tooltip: 'Line Tool',
},
{
icon: 'text-annotation',
value: 'text-menu',
tooltip: 'Text Tool',
},
{
icon: 'arrow-drawing',
value: 'shape-menu',
tooltip: 'Shape Tool',
},
{
icon: 'fibonacci-retracement-drawing',
value: 'fibonacci-menu',
tooltip: 'Fibonacci Tool',
},
{
icon: 'delete',
value: 'clear',
tooltip: 'Clear annotations',
},
] as unknown as NonNullable<NonNullable<AgCartesianChartOptions['annotations']>['toolbar']>['buttons']),
},
},
series: selectedContentNodes.map((node, index) => ({
type: 'line',
xKey: 'groupName',
yKey: getSeriesValueKey(index),
yName: `${node.label} ${seriesValueLabel}`,
stroke: node.color,
strokeWidth: 2,
marker: {
enabled: true,
fill: node.color,
stroke: node.color,
size: 5,
},
interpolation: {
type: 'smooth',
},
tooltip: {
renderer: ({ datum, yKey, yName }) => ({
title: yName,
data: [
{ label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey) },
],
}),
},
})),
axes: {
x: {
type: 'category',
position: 'bottom',
line: {
enabled: true,
stroke: '#c8b9a7',
},
tick: {
enabled: false,
},
label: {
color: '#1f2933',
fontSize: 12,
},
crosshair: {
snap: false,
},
},
y: {
type: 'number',
position: 'left',
title: {
text: '',
},
label: {
color: '#1f2933',
fontSize: 12,
formatter: ({ value }) => formatChartValue(Number(value), metricKey),
},
line: {
enabled: false,
},
tick: {
enabled: false,
},
gridLine: {
enabled: true,
style: [
{
stroke: '#e5d9ca',
lineDash: [0],
},
],
},
crosshair: {
snap: false,
},
},
},
legend: {
enabled: selectedContentNodes.length > 1,
},
tooltip: {
enabled: true,
mode: 'shared',
pagination: true,
},
};
}, [activeFilterCount, chartDataBySelection, chartEmptyText, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]);
return (
<main className="dashboard-shell">
<div className="watermark-layer" aria-hidden="true">
{Array.from({ length: 18 }).map((_, index) => (
<span key={index}></span>
))}
</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 ? (
<div className="statistic-switcher-menu" role="menu" aria-label="切换统计指标">
{statisticOptions.map((option) => (
<button
className="statistic-switcher-menu-item"
type="button"
role="menuitem"
key={option.key}
aria-current={option.key === statisticKey}
onClick={() => {
setStatisticKey(option.key);
setStatisticMenuOpen(false);
}}
>
{option.label}
</button>
))}
</div>
) : null}
<div className="metric-switcher">
<button
className="metric-switcher-button"
type="button"
title="切换纵坐标指标"
aria-expanded={metricMenuOpen}
aria-haspopup="menu"
aria-label={`纵坐标:${selectedMetric.label}`}
onClick={() => {
setStatisticMenuOpen(false);
setMetricMenuOpen((open) => !open);
}}
>
{selectedMetric.label}
</button>
{metricMenuOpen ? (
<div className="metric-switcher-menu" role="menu" aria-label="切换纵坐标指标">
{metricOptions.map((option) => (
<button
className="metric-switcher-menu-item"
type="button"
role="menuitem"
key={option.key}
aria-current={option.key === metricKey}
onClick={() => {
updateMetricKey(option.key);
setMetricMenuOpen(false);
}}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
{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>
<aside className="right-panel" aria-label="选择内容">
<div className="content-tabs" role="tablist" aria-label="选择内容切换项">
{contentOptions.map((option) => (
<button
className="content-tab"
type="button"
role="tab"
key={option.key}
aria-selected={option.key === activeContentKey}
onClick={() => handleActiveContentKeyChange(option.key)}
>
{option.label}
</button>
))}
</div>
<div className="content-tree-panel">
<div className="content-tree-title">{activeContent.label}</div>
{treeLoadingByContent[activeContentKey] ? (
<div className="content-tree-empty"></div>
) : treeErrorByContent[activeContentKey] ? (
<div className="content-tree-empty">{treeErrorByContent[activeContentKey]}</div>
) : activeTree.length > 0 ? (
renderTreeNodes(activeTree, activeContentKey, selectedNodeKeys, getNodeColor, toggleContentNode, toggleSelectedContentNode)
) : (
<div className="content-tree-empty"></div>
)}
</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>
);
}
export default App;