1708 lines
60 KiB
TypeScript
1708 lines
60 KiB
TypeScript
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;
|