1061 lines
35 KiB
TypeScript
1061 lines
35 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 {
|
||
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 statisticOptions = [
|
||
{ key: 'minValue', label: '最低值', shortLabel: '低' },
|
||
{ key: 'maxValue', label: '最高值', shortLabel: '高' },
|
||
{ key: 'avgValue', label: '平均值', shortLabel: '均' },
|
||
{ key: 'medianValue', label: '中位数', shortLabel: '中' },
|
||
{ key: 'dataCount', label: '数据量', shortLabel: '量' },
|
||
] as const;
|
||
|
||
const metricOptions = [
|
||
{ key: 'cost', label: '造价(元)' },
|
||
{ key: 'buildingArea', label: '建筑面积指标(元/m²)' },
|
||
{ key: 'builtArea', label: '建造面积指标(元/m²)' },
|
||
{ key: 'usableArea', label: '使用面积指标(元/m²)' },
|
||
] as const;
|
||
|
||
const contentOptions = [
|
||
{ key: 'geoLocation', label: '自然地理区位' },
|
||
{ key: 'facilityType', label: '设施类别' },
|
||
{ key: 'constructionStage', label: '建设阶段' },
|
||
{ key: 'planningForm', label: '规划形式' },
|
||
] 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 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;
|
||
};
|
||
|
||
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, statisticKey: StatisticKey) {
|
||
if (statisticKey === '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,
|
||
};
|
||
});
|
||
}
|
||
|
||
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 getSeriesValueKey(index: number) {
|
||
return `amount${index}`;
|
||
}
|
||
|
||
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 = node.canClick ? getNodeColor(contentKey, node.id) : undefined;
|
||
|
||
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}
|
||
disabled={!node.canClick}
|
||
onClick={() => {
|
||
if (node.canClick) onSelect(node);
|
||
}}
|
||
title={node.label}
|
||
>
|
||
{node.canClick ? <span className="content-tree-series-mark" style={{ backgroundColor: color }} /> : null}
|
||
<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 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 [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 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 selectedNodeKeys = useMemo(
|
||
() => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))),
|
||
[selectedContentNodes],
|
||
);
|
||
|
||
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) => {
|
||
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',
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
return 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 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 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: metricKey,
|
||
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();
|
||
};
|
||
}, [chartQueryVersion, groupKey, metricKey, 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 isCount = statisticKey === 'dataCount';
|
||
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?.[statisticKey] ?? 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: '请选择右侧分类项',
|
||
},
|
||
},
|
||
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} ${selectedStatistic.label}`,
|
||
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, statisticKey) },
|
||
],
|
||
}),
|
||
},
|
||
})),
|
||
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, statisticKey),
|
||
},
|
||
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,
|
||
},
|
||
};
|
||
}, [chartDataBySelection, metricKey, selectedContentNodes, selectedMetric.label, selectedStatistic.label, 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}>
|
||
<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={() => setActiveContentKey(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>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default App;
|