agchart/src/App.tsx
2026-05-22 09:12:38 +08:00

2442 lines
88 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AgGridReact } from 'ag-grid-react';
import { AgCharts } from 'ag-charts-react';
import {
AllCommunityModule as AgGridAllCommunityModule,
ModuleRegistry as AgGridModuleRegistry,
type ColDef,
type ColGroupDef,
type ValueFormatterParams,
} from 'ag-grid-community';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, Waypoints } 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]);
AgGridModuleRegistry.registerModules([AgGridAllCommunityModule]);
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 metricShortLabels: Record<MetricKey, string> = {
cost: '价',
buildingArea: '建',
builtArea: '造',
usableArea: '用',
dataCount: '数',
};
const contentOptions = [
{ key: 'geoLocation', label: '自然地理区位' },
{ key: 'facilityType', label: '设施类别' },
{ key: 'constructionStage', label: '建设阶段' },
{ key: 'planningForm', label: '规划形式' },
] as const;
const filterOptions = [
{ key: 'templateLibrary', label: '模板库', icon: Library },
{ key: 'indicatorTree', label: '指标树形', icon: Waypoints },
{ 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 chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree');
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 defaultTemplateFilterNode = {
id: '3',
filterKey: 'templateLibrary',
label: '默认模板',
} as const;
const overallSummaryKey = 'summary';
// 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 ChartViewKey = 'trend' | 'pivot';
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;
threshold_low_value?: number | null;
threshold_center_value?: number | null;
threshold_high_value?: number | null;
stddev_value?: number | null;
standard_deviation?: number | null;
iqr_value?: number | null;
quartile_range?: number | null;
variation_coefficient?: number | null;
coefficient_of_variation?: number | null;
data_count?: number | null;
};
type ApiBuildingFunctionStatBatchItem = {
key?: string;
data?: ApiBuildingFunctionStat[];
summary?: ApiBuildingFunctionStat | null;
};
type ChartDatum = {
groupName: string;
minValue: number | null;
maxValue: number | null;
avgValue: number | null;
medianValue: number | null;
thresholdLowValue: number | null;
thresholdCenterValue: number | null;
thresholdHighValue: number | null;
standardDeviation: number | null;
interquartileRange: number | null;
coefficientOfVariation: number | null;
dataCount: number | null;
};
type TreeNode = {
id: string;
label: string;
children: TreeNode[];
hasChildren: boolean;
canClick: boolean;
expanded: boolean;
loading: boolean;
loaded: boolean;
hasData?: boolean;
dataStatus?: 'normal' | 'empty';
};
type SelectedContentNode = {
id: string;
contentKey: ContentKey;
label: string;
color: string;
};
type SelectedFilterNode = {
id: string;
filterKey: FilterKey;
label: string;
};
type PivotGridRow = {
year: string;
name: string;
summary: boolean;
lowValue: number | null;
centerValue: number | null;
highValue: number | null;
maxValue: number | null;
minValue: number | null;
avgValue: number | null;
medianValue: number | null;
standardDeviation: number | null;
interquartileRange: number | null;
coefficientOfVariation: number | null;
dataCount: number | null;
};
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 {
const avgValue = row.avg_value ?? null;
const medianValue = row.median_value ?? null;
const fallbackThresholdLowValue = avgValue == null || medianValue == null ? null : Math.min(avgValue, medianValue);
const fallbackThresholdHighValue = avgValue == null || medianValue == null ? null : Math.max(avgValue, medianValue);
return {
groupName: row.group_name || String(row.group_key ?? '未命名'),
minValue: row.min_value ?? null,
maxValue: row.max_value ?? null,
avgValue,
medianValue,
thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue,
thresholdCenterValue: row.threshold_center_value ?? medianValue,
thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue,
standardDeviation: row.stddev_value ?? row.standard_deviation ?? null,
interquartileRange: row.iqr_value ?? row.quartile_range ?? null,
coefficientOfVariation: row.variation_coefficient ?? row.coefficient_of_variation ?? 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 readIndicatorDataStatus(row: Record<string, unknown>) {
const rawStatus = readText(row, ['dataStatus', 'datastatus']).toLowerCase();
const rawHasData = row.hasData ?? row.hasdata;
const hasDataField = rawStatus === 'normal' || rawStatus === 'empty' || rawHasData !== undefined;
const hasData =
!hasDataField ||
rawStatus === 'normal' ||
rawHasData === true ||
rawHasData === 1 ||
rawHasData === '1' ||
rawHasData === 'true';
return {
hasData,
dataStatus: rawStatus === 'empty' || !hasData ? 'empty' as const : 'normal' as const,
};
}
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,
};
});
}
function normalizeFlatTemplateLibraryRows(rows: unknown[]): TreeNode[] {
return rows
.filter((row): row is Record<string, unknown> => !!row && typeof row === 'object')
.map((row, index) => {
const id = readText(row, ['id', 'mainid', 'mbid']) || `node-${index}`;
const label = readText(row, ['mbmc', 'label', 'name', 'title', 'text']) || id;
return createFilterTreeNode(id, label);
});
}
function normalizeFlatIndicatorRows(rows: unknown[]): TreeNode[] {
const sourceRows = rows.filter((row): row is Record<string, unknown> => !!row && typeof row === 'object');
const rowsById = new Map<string, Record<string, unknown>>();
const childrenByParent = new Map<string, string[]>();
sourceRows.forEach((row, index) => {
const id = readText(row, ['zbid', 'zjzbk', 'id']) || `node-${index}`;
if (!id) return;
rowsById.set(id, row);
const parentId = readText(row, ['sj', 'parentId', 'parentid', 'pid', 'pId']) || '';
const children = childrenByParent.get(parentId) || [];
children.push(id);
childrenByParent.set(parentId, children);
});
const buildNode = (id: string): TreeNode => {
const row = rowsById.get(id) || {};
const children = (childrenByParent.get(id) || [])
.filter((childId) => rowsById.has(childId))
.map(buildNode);
const label = readText(row, ['label', 'name', 'title', 'text', 'zbbh', 'mbmc']) || id;
const dataStatus = readIndicatorDataStatus(row);
return {
id,
label,
children,
hasChildren: children.length > 0,
canClick: true,
expanded: children.length > 0,
loading: false,
loaded: true,
hasData: dataStatus.hasData,
dataStatus: dataStatus.dataStatus,
};
};
return Array.from(rowsById.keys())
.filter((id) => {
const row = rowsById.get(id);
if (!row) return false;
const parentId = readText(row, ['sj', 'parentId', 'parentid', 'pid', 'pId']) || '';
return !parentId || parentId === '0' || parentId === '0_0' || !rowsById.has(parentId);
})
.map(buildNode);
}
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(contentKey: ContentKey, nodeId: string) {
return `amount_${getSelectionKey(contentKey, nodeId).replace(/[^a-zA-Z0-9_]/g, '_')}`;
}
function compareGroupNames(a: string, b: string) {
const numberA = Number(a);
const numberB = Number(b);
if (Number.isFinite(numberA) && Number.isFinite(numberB)) {
return numberA - numberB;
}
return a.localeCompare(b, 'zh-CN', { numeric: true });
}
function isContentFilterKey(filterKey: FilterKey): filterKey is ContentKey {
return Object.prototype.hasOwnProperty.call(contentTreeConfigs, filterKey);
}
function isTemplateFilterKey(filterKey: FilterKey): filterKey is 'templateLibrary' {
return filterKey === 'templateLibrary';
}
function isIndicatorTreeFilterKey(filterKey: FilterKey): filterKey is 'indicatorTree' {
return filterKey === 'indicatorTree';
}
function isSingleSelectFilterKey(filterKey: FilterKey) {
return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey);
}
function getDefaultTemplateFilterNodes(): SelectedFilterNode[] {
return [{ ...defaultTemplateFilterNode }];
}
function isDefaultTemplateSelection(nodes: SelectedFilterNode[]) {
return nodes.length === 1 && nodes[0]?.id === defaultTemplateFilterNode.id;
}
function isSameFilterSelection(a: SelectedFilterNode[], b: SelectedFilterNode[]) {
return a.length === b.length && a.every((node, index) => node.id === b[index]?.id);
}
function getDefaultIndicatorTreeFilterNodes(nodes: TreeNode[]): SelectedFilterNode[] {
const defaultNode = nodes[0];
if (!defaultNode) return [];
return [{
id: defaultNode.id,
filterKey: 'indicatorTree',
label: defaultNode.label,
}];
}
function isDefaultIndicatorTreeSelection(nodes: SelectedFilterNode[], treeNodes: TreeNode[]) {
const defaultNode = treeNodes[0];
return Boolean(defaultNode) && nodes.length === 1 && nodes[0]?.id === defaultNode?.id;
}
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));
const showNoData = filterKey === 'indicatorTree' && node.dataStatus === 'empty';
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${showNoData ? ' is-empty-data' : ''}`}
type="button"
aria-pressed={selected}
onClick={() => onSelect(node)}
title={showNoData ? `${node.label}(无数据)` : node.label}
>
<span className="filter-tree-check" aria-hidden="true" />
<span className="content-tree-label">{node.label}</span>
{showNoData ? <span className="filter-tree-empty-badge"></span> : null}
</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>>({
templateLibrary: false,
indicatorTree: false,
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
const [chartViewKey, setChartViewKey] = useState<ChartViewKey>('trend');
const [workspaceFullscreen, setWorkspaceFullscreen] = useState(false);
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 [chartSummaryBySelection, setChartSummaryBySelection] = 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[]>>({
templateLibrary: [],
indicatorTree: [],
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
templateLibrary: false,
indicatorTree: false,
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
templateLibrary: null,
indicatorTree: null,
region: null,
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
templateLibrary: [],
indicatorTree: [],
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
templateLibrary: false,
indicatorTree: false,
region: false,
geoLocation: false,
facilityType: false,
constructionStage: false,
planningForm: false,
});
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
templateLibrary: null,
indicatorTree: null,
region: null,
geoLocation: null,
facilityType: null,
constructionStage: null,
planningForm: null,
});
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
templateLibrary: getDefaultTemplateFilterNodes(),
indicatorTree: [],
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>>({
templateLibrary: 0,
indicatorTree: 0,
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 selectedMetricShortLabel = metricShortLabels[metricKey];
const currentViewShortLabel = chartViewKey === 'pivot' ? '表' : '趋';
const pivotToggleActionLabel = chartViewKey === 'pivot' ? '切换到趋势图' : '切换到表格';
const pivotToggleTitle = `${chartViewKey === 'pivot' ? '当前表格' : '当前趋势图'}${pivotToggleActionLabel}`;
const fullscreenToggleLabel = workspaceFullscreen ? '退出全屏' : '全屏';
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 selectedTemplateId = appliedFilters.templateLibrary[0]?.id || defaultTemplateFilterNode.id;
const defaultIndicatorTreeNodes = useMemo(
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
[filterTreeByKey.indicatorTree],
);
const indicatorSelectionLabel = appliedFilters.indicatorTree[0]?.label ?? defaultIndicatorTreeNodes[0]?.label ?? '';
const activeFilterCount = Object.entries(appliedFilters).reduce((total, [key, nodes]) => (
key === 'templateLibrary' && isDefaultTemplateSelection(nodes) ? total : 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 groupNames = useMemo(() => {
const names: string[] = [];
const seen = new Set<string>();
selectedContentNodes.forEach((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
rows.forEach((datum) => {
if (seen.has(datum.groupName)) return;
seen.add(datum.groupName);
names.push(datum.groupName);
});
});
return names.sort(compareGroupNames);
}, [chartDataBySelection, selectedContentNodes]);
const pivotGridRowData = useMemo<PivotGridRow[]>(
() => selectedContentNodes.flatMap((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
return rows.map((datum) => ({
year: datum.groupName,
name: node.label,
summary: false,
lowValue: datum.thresholdLowValue,
centerValue: datum.thresholdCenterValue,
highValue: datum.thresholdHighValue,
maxValue: datum.maxValue,
minValue: datum.minValue,
avgValue: datum.avgValue,
medianValue: datum.medianValue,
standardDeviation: datum.standardDeviation,
interquartileRange: datum.interquartileRange,
coefficientOfVariation: datum.coefficientOfVariation,
dataCount: datum.dataCount,
}));
}),
[chartDataBySelection, selectedContentNodes],
);
const pivotGridPinnedBottomRowData = useMemo<PivotGridRow[]>(
() => {
const datum = chartSummaryBySelection[overallSummaryKey]
?? selectedContentNodes
.map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)])
.find(Boolean);
if (!datum) return [];
return [{
year: '',
name: '统计',
summary: true,
lowValue: datum.thresholdLowValue,
centerValue: datum.thresholdCenterValue,
highValue: datum.thresholdHighValue,
maxValue: datum.maxValue,
minValue: datum.minValue,
avgValue: datum.avgValue,
medianValue: datum.medianValue,
standardDeviation: datum.standardDeviation,
interquartileRange: datum.interquartileRange,
coefficientOfVariation: datum.coefficientOfVariation,
dataCount: datum.dataCount,
}];
},
[chartSummaryBySelection, selectedContentNodes],
);
const pivotGridColumnDefs = useMemo<(ColDef<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
() => {
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatChartValue(Number(value), requestMetricKey)
);
return [
{
field: 'year',
headerName: '年度',
minWidth: 68,
width: 74,
},
{
field: 'name',
headerName: '名称',
flex: 1,
minWidth: 108,
},
{
headerName: '基准阀值',
children: [
{
field: 'lowValue',
headerName: '低值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'centerValue',
headerName: '中心值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'highValue',
headerName: '高值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
],
},
{
headerName: '样本统计值(括号显示样本数量)',
children: [
{
field: 'maxValue',
headerName: '最大值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'minValue',
headerName: '最小值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'avgValue',
headerName: '平均值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'medianValue',
headerName: '中位数',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'standardDeviation',
headerName: '标准差',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'interquartileRange',
headerName: '四分位距',
type: 'numericColumn',
minWidth: 88,
valueFormatter,
},
{
field: 'coefficientOfVariation',
headerName: '变异系数',
type: 'numericColumn',
minWidth: 88,
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatNumber(Number(value), 4)
),
},
],
},
];
},
[requestMetricKey],
);
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 fetchTemplateLibraryTree = async (signal?: AbortSignal) => {
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'templateLibrary' })}`, {
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const rows = pickArray(await response.json());
const templateRows = normalizeFlatTemplateLibraryRows(rows);
return templateRows.length > 0 ? templateRows : normalizeBackendTree(normalizeTreeRows(rows));
};
const fetchIndicatorTree = async (signal?: AbortSignal) => {
const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'indicatorTree', templateId: selectedTemplateId })}`, {
signal,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const rows = pickArray(await response.json());
const indicatorRows = normalizeFlatIndicatorRows(rows);
return indicatorRows.length > 0 ? indicatorRows : 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 (isTemplateFilterKey(filterKey)) {
const nodes = await fetchTemplateLibraryTree(signal);
return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes;
}
if (filterKey === 'indicatorTree') {
const nodes = await fetchIndicatorTree(signal);
return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes;
}
if (keyword?.trim() && isContentFilterKey(filterKey)) {
return fetchBackendFilterTreeSearch(filterKey, keyword, signal);
}
if (isContentFilterKey(filterKey)) {
return loadContentTreeWithDefaultExpansion(filterKey);
}
return [];
};
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;
});
setChartSummaryBySelection((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({});
setChartSummaryBySelection({});
setLoadError(null);
setLoadingHint('');
setLoading(false);
};
const resetIndicatorTreeState = () => {
filterTreeInitialLoadStartedRef.current.indicatorTree = false;
filterSearchRequestSeqRef.current.indicatorTree += 1;
setFilterTreeByKey((current) => ({ ...current, indicatorTree: [] }));
setFilterTreeLoadingByKey((current) => ({ ...current, indicatorTree: false }));
setFilterTreeErrorByKey((current) => ({ ...current, indicatorTree: null }));
setFilterSearchTreeByKey((current) => ({ ...current, indicatorTree: [] }));
setFilterSearchLoadingByKey((current) => ({ ...current, indicatorTree: false }));
setFilterSearchErrorByKey((current) => ({ ...current, indicatorTree: null }));
};
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);
if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) {
setDraftFilterNodes(defaultIndicatorTreeNodes);
} else {
setDraftFilterNodes(appliedFilters[filterKey]);
}
setFilterSearchValue('');
lastFilterSearchRef.current = '';
if (filterSearchTimerRef.current != null) {
window.clearTimeout(filterSearchTimerRef.current);
filterSearchTimerRef.current = null;
}
ensureFilterTreeLoaded(filterKey);
};
useEffect(() => {
ensureFilterTreeLoaded('indicatorTree');
}, [selectedTemplateId]);
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;
}
if (isTemplateFilterKey(filterKey) || filterKey === 'indicatorTree') {
filterSearchTimerRef.current = window.setTimeout(() => {
setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: true }));
setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null }));
setFilterSearchTreeByKey((current) => ({
...current,
[filterKey]: filterTreeNodesByKeyword(filterTreeByKey[filterKey], keyword),
}));
setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false }));
}, 300);
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 (isSingleSelectFilterKey(currentFilterKey)) {
if (exists) {
return current;
}
return [{ id: node.id, filterKey: currentFilterKey, label: node.label }];
}
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;
let nextDraftNodes = draftFilterNodes;
if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) {
nextDraftNodes = getDefaultTemplateFilterNodes();
}
if (isIndicatorTreeFilterKey(filterModalKey)) {
if (nextDraftNodes.length === 0) {
nextDraftNodes = defaultIndicatorTreeNodes;
}
if (isDefaultIndicatorTreeSelection(nextDraftNodes, filterTreeByKey.indicatorTree)) {
nextDraftNodes = [];
}
}
const shouldReloadIndicatorTree = isTemplateFilterKey(filterModalKey) && !isSameFilterSelection(appliedFilters.templateLibrary, nextDraftNodes);
setAppliedFilters((current) => {
const nextFilters = {
...current,
[filterModalKey]: nextDraftNodes,
};
if (shouldReloadIndicatorTree) {
nextFilters.indicatorTree = [];
}
return nextFilters;
});
if (shouldReloadIndicatorTree) {
resetIndicatorTreeState();
}
setChartDataBySelection({});
setChartSummaryBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
closeFilterModal();
};
const clearFilter = (filterKey: FilterKey) => {
const nextNodes = isTemplateFilterKey(filterKey)
? getDefaultTemplateFilterNodes()
: isIndicatorTreeFilterKey(filterKey)
? defaultIndicatorTreeNodes
: [];
if (isSameFilterSelection(appliedFilters[filterKey], nextNodes)) {
return;
}
setAppliedFilters((current) => {
const nextFilters = {
...current,
[filterKey]: nextNodes,
};
if (isTemplateFilterKey(filterKey)) {
nextFilters.indicatorTree = [];
}
return nextFilters;
});
if (isTemplateFilterKey(filterKey)) {
resetIndicatorTreeState();
}
setChartDataBySelection({});
setChartSummaryBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
};
const updateMetricKey = (nextMetricKey: MetricKey) => {
setMetricKey(nextMetricKey);
setChartDataBySelection({});
setChartSummaryBySelection({});
setLoadError(null);
setLoadingHint('正在重新加载数据');
setLoading(true);
setChartQueryVersion((version) => version + 1);
};
const togglePivotView = useCallback(() => {
setChartViewKey((current) => (current === 'trend' ? 'pivot' : 'trend'));
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
}, []);
const toggleWorkspaceFullscreen = useCallback(() => {
const fullscreenTarget = workspaceRef.current;
if (!fullscreenTarget) return;
if (document.fullscreenElement === fullscreenTarget) {
void document.exitFullscreen();
} else {
void fullscreenTarget.requestFullscreen();
}
}, []);
const openGridFilterModal = (filterKey: 'templateLibrary' | 'indicatorTree') => {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal(filterKey);
};
useEffect(() => {
const handleFullscreenChange = () => {
setWorkspaceFullscreen(document.fullscreenElement === workspaceRef.current);
};
handleFullscreenChange();
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
};
}, []);
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(() => {
if (filterModalKey !== 'indicatorTree') return;
if (draftFilterNodes.length > 0) return;
if (defaultIndicatorTreeNodes.length === 0) return;
setDraftFilterNodes(defaultIndicatorTreeNodes);
}, [defaultIndicatorTreeNodes, draftFilterNodes.length, filterModalKey]);
useEffect(() => {
const controller = new AbortController();
async function loadStats() {
if (selectedContentNodes.length === 0) {
setChartDataBySelection({});
}
setLoading(true);
setLoadingHint('正在加载数据');
setLoadError(null);
try {
const hasAnyMissingNode = selectedContentNodes.some((node) => !chartDataBySelection[getSelectionKey(node.contentKey, node.id)]);
if (selectedContentNodes.length > 0 && !hasAnyMissingNode && chartSummaryBySelection[overallSummaryKey]) {
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: 'year',
metric: requestMetricKey,
templateId: selectedTemplateId,
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);
const summaries = (payload.data ?? [])
.filter((item) => item.key && item.summary)
.map((item) => [item.key as string, normalizeStat(item.summary as ApiBuildingFunctionStat)] as const);
const overallSummary = (payload.data ?? []).find((item) => item.summary)?.summary;
setChartDataBySelection((current) => ({
...current,
...Object.fromEntries(results),
}));
setChartSummaryBySelection((current) => ({
...current,
...Object.fromEntries(summaries),
...(overallSummary ? { [overallSummaryKey]: normalizeStat(overallSummary) } : {}),
}));
} 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, metricKey, requestMetricKey, selectedContentNodes, selectedTemplateId]);
useEffect(() => {
const frame = chartFrameRef.current;
const fullscreenTarget = workspaceRef.current;
if (!frame || !fullscreenTarget) return;
const getTemplateButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-template')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getIndicatorButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-indicator')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getFullscreenButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getStatisticButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-statistic')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getPivotButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-pivot')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const setButtonAttribute = (button: HTMLButtonElement, name: string, value: string) => {
if (button.getAttribute(name) !== value) {
button.setAttribute(name, value);
}
};
const enableCustomToolbarButton = (button: HTMLButtonElement) => {
if (button.disabled) {
button.disabled = false;
}
if (button.hasAttribute('disabled')) {
button.removeAttribute('disabled');
}
setButtonAttribute(button, 'aria-disabled', 'false');
};
const syncToolbarButtons = () => {
const templateButton = getTemplateButton();
if (templateButton) {
templateButton.classList.add('chart-template-button');
enableCustomToolbarButton(templateButton);
setButtonAttribute(templateButton, 'aria-expanded', String(filterModalKey === 'templateLibrary'));
}
const indicatorButton = getIndicatorButton();
if (indicatorButton) {
indicatorButton.classList.add('chart-indicator-button');
enableCustomToolbarButton(indicatorButton);
setButtonAttribute(indicatorButton, 'aria-expanded', String(filterModalKey === 'indicatorTree'));
setButtonAttribute(indicatorButton, 'title', '指标树形');
}
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.add('chart-fullscreen-button');
button.classList.toggle('ag-charts-toolbar__button--active', isFullscreen);
enableCustomToolbarButton(button);
setButtonAttribute(button, 'aria-pressed', String(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');
enableCustomToolbarButton(statisticButton);
setButtonAttribute(statisticButton, 'aria-expanded', String(statisticMenuOpen));
}
const pivotButton = getPivotButton();
if (pivotButton) {
pivotButton.classList.add('chart-pivot-button');
pivotButton.classList.toggle('ag-charts-toolbar__button--active', chartViewKey === 'pivot');
enableCustomToolbarButton(pivotButton);
setButtonAttribute(pivotButton, 'aria-pressed', String(chartViewKey === 'pivot'));
setButtonAttribute(pivotButton, 'aria-label', pivotToggleActionLabel);
setButtonAttribute(pivotButton, 'title', pivotToggleTitle);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'F11') return;
event.preventDefault();
event.stopPropagation();
toggleWorkspaceFullscreen();
};
const handleFullscreenChange = () => {
syncToolbarButtons();
};
const handleToolbarAction = (button: HTMLButtonElement) => {
if (button.classList.contains('chart-template-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal('templateLibrary');
} else if (button.classList.contains('chart-indicator-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal('indicatorTree');
} else if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else if (button.classList.contains('chart-pivot-button')) {
togglePivotView();
} else {
toggleWorkspaceFullscreen();
}
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
handleToolbarAction(button);
};
const handleToolbarKeyDown = (event: KeyboardEvent) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
handleToolbarAction(button);
};
const suppressBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
const observer = new MutationObserver(syncToolbarButtons);
const initialSyncTimers = [
window.setTimeout(syncToolbarButtons, 0),
window.setTimeout(syncToolbarButtons, 100),
window.setTimeout(syncToolbarButtons, 500),
window.setTimeout(syncToolbarButtons, 1000),
];
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 () => {
initialSyncTimers.forEach((timer) => window.clearTimeout(timer));
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();
};
}, [
activeContentKey,
appliedFilters,
filterModalKey,
filterTreeByKey,
chartViewKey,
pivotToggleActionLabel,
pivotToggleTitle,
selectedContentNodes.length,
statisticMenuOpen,
togglePivotView,
toggleWorkspaceFullscreen,
]);
const chartOptions = useMemo<AgCartesianChartOptions>(() => {
const trendData = groupNames.map((groupName) => {
const row: Record<string, string | number | null> = { groupName };
selectedContentNodes.forEach((node) => {
const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
row[getSeriesValueKey(node.contentKey, node.id)] = datum?.[selectedValueKey] ?? null;
});
return row;
});
const series = selectedContentNodes.map((node) => ({
type: 'line' as const,
xKey: 'groupName',
yKey: getSeriesValueKey(node.contentKey, node.id),
yName: `${node.label} ${seriesValueLabel}`,
stroke: node.color,
strokeWidth: 2,
connectMissingData: true,
marker: {
enabled: true,
fill: node.color,
stroke: node.color,
size: 5,
},
interpolation: {
type: 'smooth' as const,
},
tooltip: {
renderer: ({ datum, yKey, yName }: { datum: Record<string, unknown>; yKey: string; yName?: string }) => ({
title: yName ?? '',
data: [
{ label: selectedMetric.label, value: formatChartValue(Number(datum[yKey]), metricKey) },
],
}),
},
}));
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: trendData,
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: 'callout',
tooltip: '模板库',
label: '<span class="ag-charts-myButton-template ag-charts-diy-button">库</span>',
},
{
value: 'text',
tooltip: '指标树形',
label: '<span class="ag-charts-myButton-indicator ag-charts-diy-button">指</span>',
},
{
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',
},
{
value: 'comment',
tooltip: '全屏(F11)',
label: '<i class="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button"></i>',
},
{
icon: 'delete',
value: 'clear',
tooltip: 'Clear annotations',
},
{
value: 'comment',
tooltip: pivotToggleActionLabel,
label: `<span class="ag-charts-myButton-pivot ag-charts-diy-button">${currentViewShortLabel}</span>`,
},
] as unknown as NonNullable<NonNullable<AgCartesianChartOptions['annotations']>['toolbar']>['buttons']),
},
},
series,
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,
groupNames,
metricKey,
currentViewShortLabel,
pivotToggleActionLabel,
requestMetricKey,
selectedContentNodes,
selectedMetric.label,
seriesValueLabel,
selectedValueKey,
statisticKey,
]);
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
<div className={`metric-switcher metric-switcher--${variant}`}>
<button
className={`metric-switcher-button metric-switcher-button--${variant}`}
type="button"
title={`切换纵坐标指标:${selectedMetric.label}`}
aria-expanded={metricMenuOpen}
aria-haspopup="menu"
aria-label={`纵坐标:${selectedMetric.label}`}
onClick={() => {
setStatisticMenuOpen(false);
setMetricMenuOpen((open) => !open);
}}
>
{variant === 'grid' ? selectedMetricShortLabel : 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>
);
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}>
{indicatorSelectionLabel ? <div className="chart-indicator-selection-label" title={indicatorSelectionLabel}>{indicatorSelectionLabel}</div> : null}
<div className="chart-filter-bar chart-filter-bar--workspace" aria-label="筛选条件">
{chartFilterOptions.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={() => {
const shouldReloadIndicatorTree = !isDefaultTemplateSelection(appliedFilters.templateLibrary);
if (shouldReloadIndicatorTree) {
resetIndicatorTreeState();
}
setAppliedFilters({
templateLibrary: getDefaultTemplateFilterNodes(),
indicatorTree: [],
region: [],
geoLocation: [],
facilityType: [],
constructionStage: [],
planningForm: [],
});
setChartDataBySelection({});
setChartSummaryBySelection({});
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}
{chartViewKey === 'trend' ? renderMetricSwitcher('chart') : null}
{loading || loadError ? <div className="chart-status">{loading ? loadingHint || '加载中' : loadError}</div> : null}
<AgCharts options={chartOptions} />
{chartViewKey === 'pivot' ? (
<div className="chart-pivot-grid-panel ag-theme-quartz">
<button
className="chart-grid-tool-button chart-grid-tool-button--template"
type="button"
title="模板库"
aria-label="模板库"
onClick={() => openGridFilterModal('templateLibrary')}
>
</button>
<button
className="chart-grid-tool-button chart-grid-tool-button--indicator"
type="button"
title="指标树形"
aria-label="指标树形"
onClick={() => openGridFilterModal('indicatorTree')}
>
</button>
<button
className="chart-grid-tool-button chart-grid-tool-button--trend"
type="button"
title={pivotToggleTitle}
aria-label={pivotToggleActionLabel}
onClick={togglePivotView}
>
{currentViewShortLabel}
</button>
<button
className="chart-grid-tool-button chart-grid-tool-button--fullscreen"
type="button"
title={fullscreenToggleLabel}
aria-label={fullscreenToggleLabel}
aria-pressed={workspaceFullscreen}
onClick={toggleWorkspaceFullscreen}
>
<i
className={`anticon ${workspaceFullscreen ? 'anticon-shrink' : 'anticon-arrow-salt'} ag-charts-myButton-fullScreen ag-charts-diy-button`}
aria-hidden="true"
/>
</button>
{renderMetricSwitcher('grid')}
<AgGridReact<PivotGridRow>
rowData={pivotGridRowData}
pinnedBottomRowData={pivotGridPinnedBottomRowData}
columnDefs={pivotGridColumnDefs}
containerStyle={{ width: '100%', height: '100%' }}
theme="legacy"
defaultColDef={{
sortable: true,
resizable: true,
filter: true,
}}
suppressCellFocus
overlayNoRowsTemplate={selectedContentNodes.length === 0 ? '请选择右侧分类项' : '暂无透视数据'}
/>
</div>
) : null}
{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>
{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(
isTemplateFilterKey(filterModalKey)
? getDefaultTemplateFilterNodes()
: isIndicatorTreeFilterKey(filterModalKey)
? defaultIndicatorTreeNodes
: [],
)}
>
</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}
</section>
</main>
);
}
export default App;