This commit is contained in:
wintsa 2026-05-21 09:24:15 +08:00
parent 58abe588aa
commit cfb6afcdce
7 changed files with 634 additions and 168 deletions

View File

@ -8,6 +8,8 @@
"ag-charts-community": "^13.2.1",
"ag-charts-enterprise": "13.2.1",
"ag-charts-react": "^13.2.1",
"ag-grid-community": "^35.3.0",
"ag-grid-react": "^35.3.0",
"lucide-react": "^1.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
@ -202,6 +204,10 @@
"ag-charts-types": ["ag-charts-types@13.2.1", "", {}, "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ=="],
"ag-grid-community": ["ag-grid-community@35.3.0", "", { "dependencies": { "ag-charts-types": "13.3.0" } }, "sha512-c9WQWB88J965IjBC/GPUX30aAZix10o6oYT86DWipcxgLZTIQlLSilJJEr1bno/245rPEAIMjhoU1gp9VIfURg=="],
"ag-grid-react": ["ag-grid-react@35.3.0", "", { "dependencies": { "ag-grid-community": "35.3.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c6YEFGQGNZxEi1PdK0b+WhKkKRJ7KxuYzsG4UmISyax5/J7N93f8B1TZK1pq+AgzPhdk/++vjZe3KhFdF3tog=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
@ -232,6 +238,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
@ -242,16 +250,22 @@
"node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
@ -271,5 +285,7 @@
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"ag-grid-community/ag-charts-types": ["ag-charts-types@13.3.0", "", {}, "sha512-UMoAn908LC4ZIJSNfUckSBEFa79Mi1vFRA8qIRx+NusEuuFgXDioCZx4MxM7O3rDXlxTWH9DvQmcDjh7vyd89w=="],
}
}

View File

@ -6,7 +6,7 @@
<title>AG Chart Service</title>
</head>
<body>
<div id="zbChart"></div>
<div id="sbChart"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -13,6 +13,8 @@
"ag-charts-community": "^13.2.1",
"ag-charts-enterprise": "13.2.1",
"ag-charts-react": "^13.2.1",
"ag-grid-community": "^35.3.0",
"ag-grid-react": "^35.3.0",
"lucide-react": "^1.14.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"

View File

@ -1,5 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AgGridReact } from 'ag-grid-react';
import { AgCharts } from 'ag-charts-react';
import {
AllCommunityModule as AgGridAllCommunityModule,
ModuleRegistry as AgGridModuleRegistry,
type ColDef,
type ColGroupDef,
type ValueFormatterParams,
} from 'ag-grid-community';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, SquareFunction, Waypoints } from 'lucide-react';
@ -14,6 +22,7 @@ import { AG_CHARTS_LOCALE_ZH_CN } from 'ag-charts-locale';
LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b');
ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]);
AgGridModuleRegistry.registerModules([AgGridAllCommunityModule]);
const API_BASE_URL = 'https://nest.zwgczx.com/api/v1';
// const API_BASE_URL = 'http://127.0.0.1:9089/api/v1';
@ -39,6 +48,14 @@ const metricOptions = [
{ key: 'dataCount', label: '数据量' },
] as const;
const metricShortLabels: Record<MetricKey, string> = {
cost: '价',
buildingArea: '建',
builtArea: '造',
usableArea: '用',
dataCount: '数',
};
const contentOptions = [
{ key: 'geoLocation', label: '自然地理区位' },
{ key: 'facilityType', label: '设施类别' },
@ -157,6 +174,7 @@ type MetricKey = (typeof metricOptions)[number]['key'];
type ContentKey = (typeof contentOptions)[number]['key'];
type FilterKey = (typeof filterOptions)[number]['key'];
type GroupKey = 'year';
type ChartViewKey = 'trend' | 'pivot';
type ApiBuildingFunctionStat = {
group_key?: string | number | null;
group_name?: string | null;
@ -164,6 +182,15 @@ type ApiBuildingFunctionStat = {
max_value?: number | null;
avg_value?: number | null;
median_value?: number | null;
threshold_low_value?: number | null;
threshold_center_value?: number | null;
threshold_high_value?: number | null;
stddev_value?: number | null;
standard_deviation?: number | null;
iqr_value?: number | null;
quartile_range?: number | null;
variation_coefficient?: number | null;
coefficient_of_variation?: number | null;
data_count?: number | null;
};
type ApiBuildingFunctionStatBatchItem = {
@ -176,6 +203,12 @@ type ChartDatum = {
maxValue: number | null;
avgValue: number | null;
medianValue: number | null;
thresholdLowValue: number | null;
thresholdCenterValue: number | null;
thresholdHighValue: number | null;
standardDeviation: number | null;
interquartileRange: number | null;
coefficientOfVariation: number | null;
dataCount: number | null;
};
type TreeNode = {
@ -201,6 +234,21 @@ type SelectedFilterNode = {
filterKey: FilterKey;
label: string;
};
type PivotGridRow = {
year: string;
name: string;
lowValue: number | null;
centerValue: number | null;
highValue: number | null;
maxValue: number | null;
minValue: number | null;
avgValue: number | null;
medianValue: number | null;
standardDeviation: number | null;
interquartileRange: number | null;
coefficientOfVariation: number | null;
dataCount: number | null;
};
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString('zh-CN', {
@ -235,12 +283,22 @@ function formatChartValue(value: number, metricKey: MetricKey) {
}
function normalizeStat(row: ApiBuildingFunctionStat): ChartDatum {
const avgValue = row.avg_value ?? null;
const medianValue = row.median_value ?? null;
const fallbackThresholdLowValue = avgValue == null || medianValue == null ? null : Math.min(avgValue, medianValue);
const fallbackThresholdHighValue = avgValue == null || medianValue == null ? null : Math.max(avgValue, medianValue);
return {
groupName: row.group_name || String(row.group_key ?? '未命名'),
minValue: row.min_value ?? null,
maxValue: row.max_value ?? null,
avgValue: row.avg_value ?? null,
medianValue: row.median_value ?? null,
avgValue,
medianValue,
thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue,
thresholdCenterValue: row.threshold_center_value ?? medianValue,
thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue,
standardDeviation: row.stddev_value ?? row.standard_deviation ?? null,
interquartileRange: row.iqr_value ?? row.quartile_range ?? null,
coefficientOfVariation: row.variation_coefficient ?? row.coefficient_of_variation ?? null,
dataCount: row.data_count ?? null,
};
}
@ -679,6 +737,7 @@ function App() {
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
const [groupKey, setGroupKey] = useState<GroupKey>('year');
const [chartViewKey, setChartViewKey] = useState<ChartViewKey>('trend');
const [statisticMenuOpen, setStatisticMenuOpen] = useState(false);
const [metricMenuOpen, setMetricMenuOpen] = useState(false);
const [activeContentKey, setActiveContentKey] = useState<ContentKey>('geoLocation');
@ -798,6 +857,10 @@ function App() {
const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0];
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
const selectedMetricShortLabel = metricShortLabels[metricKey];
const currentViewShortLabel = chartViewKey === 'pivot' ? '表' : '趋';
const pivotToggleActionLabel = chartViewKey === 'pivot' ? '切换到趋势图' : '切换到表格';
const pivotToggleTitle = `${chartViewKey === 'pivot' ? '当前表格' : '当前趋势图'}${pivotToggleActionLabel}`;
const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0];
const activeTree = treeByContent[activeContentKey];
const activeFilter = filterOptions.find((option) => option.key === filterModalKey);
@ -815,8 +878,12 @@ function App() {
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
[filterTreeByKey.indicatorTree],
);
const indicatorSelectionLabel = appliedFilters.indicatorTree[0]?.label ?? defaultIndicatorTreeNodes[0]?.label ?? '';
const activeFilterCount = Object.entries(appliedFilters).reduce((total, [key, nodes]) => (
key === 'templateLibrary' && isDefaultTemplateSelection(nodes) ? total : total + nodes.length
(key === 'templateLibrary' && isDefaultTemplateSelection(nodes))
|| (key === 'indicatorTree' && isDefaultIndicatorTreeSelection(nodes, filterTreeByKey.indicatorTree))
? total
: total + nodes.length
), 0);
const chartEmptyText = selectedContentNodes.length === 0
? '请选择右侧分类项'
@ -826,6 +893,145 @@ function App() {
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
const groupNames = useMemo(() => {
const names: string[] = [];
const seen = new Set<string>();
selectedContentNodes.forEach((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
rows.forEach((datum) => {
if (seen.has(datum.groupName)) return;
seen.add(datum.groupName);
names.push(datum.groupName);
});
});
return names.sort(compareGroupNames);
}, [chartDataBySelection, selectedContentNodes]);
const pivotGridRowData = useMemo<PivotGridRow[]>(
() => selectedContentNodes.flatMap((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
return rows.map((datum) => ({
year: datum.groupName,
name: node.label,
lowValue: datum.thresholdLowValue,
centerValue: datum.thresholdCenterValue,
highValue: datum.thresholdHighValue,
maxValue: datum.maxValue,
minValue: datum.minValue,
avgValue: datum.avgValue,
medianValue: datum.medianValue,
standardDeviation: datum.standardDeviation,
interquartileRange: datum.interquartileRange,
coefficientOfVariation: datum.coefficientOfVariation,
dataCount: datum.dataCount,
}));
}),
[chartDataBySelection, selectedContentNodes],
);
const pivotGridColumnDefs = useMemo<(ColDef<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
() => {
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatChartValue(Number(value), requestMetricKey)
);
return [
{
field: 'year',
headerName: '年度',
minWidth: 68,
width: 74,
},
{
field: 'name',
headerName: '名称',
flex: 1,
minWidth: 108,
},
{
headerName: '基准阀值',
children: [
{
field: 'lowValue',
headerName: '低值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'centerValue',
headerName: '中心值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'highValue',
headerName: '高值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
],
},
{
headerName: '样本统计值(括号显示样本数量)',
children: [
{
field: 'maxValue',
headerName: '最大值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'minValue',
headerName: '最小值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'avgValue',
headerName: '平均值',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'medianValue',
headerName: '中位数',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'standardDeviation',
headerName: '标准差',
type: 'numericColumn',
minWidth: 78,
valueFormatter,
},
{
field: 'interquartileRange',
headerName: '四分位距',
type: 'numericColumn',
minWidth: 88,
valueFormatter,
},
{
field: 'coefficientOfVariation',
headerName: '变异系数',
type: 'numericColumn',
minWidth: 88,
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatNumber(Number(value), 4)
),
},
],
},
];
},
[requestMetricKey],
);
const selectedNodeKeys = useMemo(
() => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))),
[selectedContentNodes],
@ -834,6 +1040,14 @@ function App() {
() => new Set(draftFilterNodes.map((node) => getFilterSelectionKey(node.filterKey, node.id))),
[draftFilterNodes],
);
useEffect(() => {
if (defaultIndicatorTreeNodes.length === 0) return;
setAppliedFilters((current) => {
if (current.indicatorTree.length > 0) return current;
return { ...current, indicatorTree: defaultIndicatorTreeNodes };
});
}, [defaultIndicatorTreeNodes]);
const appliedFilterPayload = useMemo(
() => filterOptions
.map((option) => ({
@ -1143,6 +1357,10 @@ function App() {
});
};
useEffect(() => {
ensureFilterTreeLoaded('indicatorTree');
}, [selectedTemplateId]);
const openFilterModal = (filterKey: FilterKey) => {
setFilterModalKey(filterKey);
if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) {
@ -1313,9 +1531,6 @@ function App() {
if (nextDraftNodes.length === 0) {
nextDraftNodes = defaultIndicatorTreeNodes;
}
if (isDefaultIndicatorTreeSelection(nextDraftNodes, filterTreeByKey.indicatorTree)) {
nextDraftNodes = [];
}
}
setAppliedFilters((current) => {
const nextFilters = {
@ -1380,6 +1595,18 @@ function App() {
setChartQueryVersion((version) => version + 1);
};
const togglePivotView = useCallback(() => {
setChartViewKey((current) => (current === 'trend' ? 'pivot' : 'trend'));
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
}, []);
const openGridFilterModal = (filterKey: 'templateLibrary' | 'indicatorTree') => {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal(filterKey);
};
useEffect(() => {
if (!contentTreeConfigs[activeContentKey]) return;
if (treeByContent[activeContentKey].length > 0 || treeInitialLoadStartedRef.current[activeContentKey]) return;
@ -1491,6 +1718,7 @@ function App() {
const getIndicatorButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-indicator')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getFullscreenButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getStatisticButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-statistic')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const getPivotButton = () => frame.querySelector<HTMLElement>('.ag-charts-myButton-pivot')?.closest<HTMLButtonElement>('.ag-charts-toolbar__button');
const setButtonAttribute = (button: HTMLButtonElement, name: string, value: string) => {
if (button.getAttribute(name) !== value) {
button.setAttribute(name, value);
@ -1543,6 +1771,16 @@ function App() {
enableCustomToolbarButton(statisticButton);
setButtonAttribute(statisticButton, 'aria-expanded', String(statisticMenuOpen));
}
const pivotButton = getPivotButton();
if (pivotButton) {
pivotButton.classList.add('chart-pivot-button');
pivotButton.classList.toggle('ag-charts-toolbar__button--active', chartViewKey === 'pivot');
enableCustomToolbarButton(pivotButton);
setButtonAttribute(pivotButton, 'aria-pressed', String(chartViewKey === 'pivot'));
setButtonAttribute(pivotButton, 'aria-label', pivotToggleActionLabel);
setButtonAttribute(pivotButton, 'title', pivotToggleTitle);
}
};
const toggleFullscreen = () => {
@ -1565,16 +1803,7 @@ function App() {
syncToolbarButtons();
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const handleToolbarAction = (button: HTMLButtonElement) => {
if (button.classList.contains('chart-template-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
@ -1586,37 +1815,39 @@ function App() {
} else if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else if (button.classList.contains('chart-pivot-button')) {
togglePivotView();
} else {
toggleFullscreen();
}
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
handleToolbarAction(button);
};
const handleToolbarKeyDown = (event: KeyboardEvent) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button',
'.chart-template-button, .chart-indicator-button, .chart-fullscreen-button, .chart-statistic-button, .chart-pivot-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (button.classList.contains('chart-template-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal('templateLibrary');
} else if (button.classList.contains('chart-indicator-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen(false);
openFilterModal('indicatorTree');
} else if (button.classList.contains('chart-statistic-button')) {
setMetricMenuOpen(false);
setStatisticMenuOpen((open) => !open);
} else {
toggleFullscreen();
}
handleToolbarAction(button);
};
const suppressBrowserContextMenu = (event: MouseEvent) => {
@ -1649,20 +1880,20 @@ function App() {
frame.removeEventListener('keydown', handleToolbarKeyDown, true);
observer.disconnect();
};
}, [activeContentKey, appliedFilters, filterModalKey, filterTreeByKey, selectedContentNodes.length, statisticMenuOpen]);
}, [
activeContentKey,
appliedFilters,
filterModalKey,
filterTreeByKey,
chartViewKey,
pivotToggleActionLabel,
pivotToggleTitle,
selectedContentNodes.length,
statisticMenuOpen,
togglePivotView,
]);
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);
});
});
groupNames.sort(compareGroupNames);
const visibleData = groupNames.map((groupName) => {
const row: Record<string, string | number | null> = { groupName };
selectedContentNodes.forEach((node) => {
@ -1738,7 +1969,7 @@ function App() {
{
value: 'text',
tooltip: '指标树形',
label: '<span class="ag-charts-myButton-indicator ag-charts-diy-button"></span>',
label: '<span class="ag-charts-myButton-indicator ag-charts-diy-button"></span>',
},
{
value: 'note',
@ -1775,6 +2006,11 @@ function App() {
value: 'clear',
tooltip: 'Clear annotations',
},
{
value: 'comment',
tooltip: pivotToggleActionLabel,
label: `<span class="ag-charts-myButton-pivot ag-charts-diy-button">${currentViewShortLabel}</span>`,
},
] as unknown as NonNullable<NonNullable<AgCartesianChartOptions['annotations']>['toolbar']>['buttons']),
},
},
@ -1863,7 +2099,59 @@ function App() {
pagination: true,
},
};
}, [activeFilterCount, chartDataBySelection, chartEmptyText, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]);
}, [
activeFilterCount,
chartDataBySelection,
chartEmptyText,
groupNames,
metricKey,
currentViewShortLabel,
pivotToggleActionLabel,
requestMetricKey,
selectedContentNodes,
selectedMetric.label,
seriesValueLabel,
selectedValueKey,
statisticKey,
]);
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
<div className={`metric-switcher metric-switcher--${variant}`}>
<button
className={`metric-switcher-button metric-switcher-button--${variant}`}
type="button"
title={`切换纵坐标指标:${selectedMetric.label}`}
aria-expanded={metricMenuOpen}
aria-haspopup="menu"
aria-label={`纵坐标:${selectedMetric.label}`}
onClick={() => {
setStatisticMenuOpen(false);
setMetricMenuOpen((open) => !open);
}}
>
{variant === 'grid' ? selectedMetricShortLabel : selectedMetric.label}
</button>
{metricMenuOpen ? (
<div className="metric-switcher-menu" role="menu" aria-label="切换纵坐标指标">
{metricOptions.map((option) => (
<button
className="metric-switcher-menu-item"
type="button"
role="menuitem"
key={option.key}
aria-current={option.key === metricKey}
onClick={() => {
updateMetricKey(option.key);
setMetricMenuOpen(false);
}}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
return (
<main className="dashboard-shell">
@ -1874,6 +2162,7 @@ function App() {
</div>
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
{indicatorSelectionLabel ? <div className="chart-indicator-selection-label" title={indicatorSelectionLabel}>{indicatorSelectionLabel}</div> : null}
<div className="chart-filter-bar chart-filter-bar--workspace" aria-label="筛选条件">
{chartFilterOptions.map((option) => {
const count = appliedFilters[option.key].length;
@ -1899,10 +2188,9 @@ function App() {
type="button"
title="清空全部筛选"
onClick={() => {
resetIndicatorTreeState();
setAppliedFilters({
templateLibrary: getDefaultTemplateFilterNodes(),
indicatorTree: [],
indicatorTree: defaultIndicatorTreeNodes,
region: [],
geoLocation: [],
facilityType: [],
@ -1944,43 +2232,54 @@ function App() {
))}
</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>
{chartViewKey === 'trend' ? renderMetricSwitcher('chart') : null}
{loading || loadError ? <div className="chart-status">{loading ? loadingHint || '加载中' : loadError}</div> : null}
<AgCharts options={chartOptions} />
{chartViewKey === 'pivot' ? (
<div className="chart-pivot-grid-panel ag-theme-quartz">
<button
className="chart-grid-tool-button chart-grid-tool-button--template"
type="button"
title="模板库"
aria-label="模板库"
onClick={() => openGridFilterModal('templateLibrary')}
>
</button>
<button
className="chart-grid-tool-button chart-grid-tool-button--indicator"
type="button"
title="指标树形"
aria-label="指标树形"
onClick={() => openGridFilterModal('indicatorTree')}
>
</button>
<button
className="chart-grid-tool-button chart-grid-tool-button--trend"
type="button"
title={pivotToggleTitle}
aria-label={pivotToggleActionLabel}
onClick={togglePivotView}
>
{currentViewShortLabel}
</button>
{renderMetricSwitcher('grid')}
<AgGridReact<PivotGridRow>
rowData={pivotGridRowData}
columnDefs={pivotGridColumnDefs}
containerStyle={{ width: '100%', height: '100%' }}
theme="legacy"
defaultColDef={{
sortable: true,
resizable: true,
filter: true,
}}
suppressCellFocus
overlayNoRowsTemplate={selectedContentNodes.length === 0 ? '请选择右侧分类项' : '暂无透视数据'}
/>
</div>
) : null}
{loading ? (
<div className="chart-loading-mask" aria-live="polite" aria-busy="true">
<div className="chart-loading-panel">{loadingHint || '加载中'}</div>
@ -2017,83 +2316,83 @@ function App() {
)}
</div>
</aside>
</section>
{filterModalKey && activeFilter ? (
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
<section
className="filter-modal"
role="dialog"
aria-modal="true"
aria-label={`${activeFilter.label}筛选`}
onMouseDown={(event) => event.stopPropagation()}
>
<header className="filter-modal-header">
<h2>{activeFilter.label}</h2>
<button className="filter-modal-close" type="button" aria-label="关闭" onClick={closeFilterModal}>×</button>
</header>
<div className="filter-modal-search">
<input
type="search"
value={filterSearchValue}
placeholder="搜索"
onChange={(event) => {
const nextValue = event.target.value;
setFilterSearchValue(nextValue);
if (!filterSearchComposingRef.current && filterModalKey) {
scheduleFilterSearch(filterModalKey, nextValue);
}
}}
onCompositionStart={() => {
filterSearchComposingRef.current = true;
}}
onCompositionEnd={(event) => {
filterSearchComposingRef.current = false;
const nextValue = event.currentTarget.value;
setFilterSearchValue(nextValue);
if (filterModalKey) {
scheduleFilterSearch(filterModalKey, nextValue);
}
}}
/>
</div>
<div className="filter-modal-selected">
{draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length}` : '未选择'}
</div>
<div className="filter-modal-tree">
{activeFilterTreeLoading ? (
<div className="content-tree-empty"></div>
) : activeFilterTreeError ? (
<div className="content-tree-empty">{activeFilterTreeError}</div>
) : activeFilterDisplayTree.length > 0 ? (
renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode)
) : (
<div className="content-tree-empty">{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}</div>
)}
</div>
<footer className="filter-modal-actions">
<button
className="filter-modal-clear"
type="button"
onClick={() => setDraftFilterNodes(
isTemplateFilterKey(filterModalKey)
? getDefaultTemplateFilterNodes()
: isIndicatorTreeFilterKey(filterModalKey)
? defaultIndicatorTreeNodes
: [],
{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>
)}
>
</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}
</div>
<footer className="filter-modal-actions">
<button
className="filter-modal-clear"
type="button"
onClick={() => setDraftFilterNodes(
isTemplateFilterKey(filterModalKey)
? getDefaultTemplateFilterNodes()
: isIndicatorTreeFilterKey(filterModalKey)
? defaultIndicatorTreeNodes
: [],
)}
>
</button>
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
</button>
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
</button>
</footer>
</section>
</div>
) : null}
</section>
</main>
);
}

View File

@ -8,16 +8,29 @@ ModuleRegistry.registerModules([AllCommunityModule]);
declare global {
interface Window {
__zbChartRoot?: ReturnType<typeof createRoot>;
__chartReactRoots?: WeakMap<Element, ReturnType<typeof createRoot>>;
}
}
const chartContainerId = 'sbChart';
const getRootRegistry = () => {
window.__chartReactRoots ??= new WeakMap<Element, ReturnType<typeof createRoot>>();
return window.__chartReactRoots;
};
const mount = () => {
const container = document.getElementById('zbChart');
const container = document.getElementById(chartContainerId);
if (!container) return false;
window.__zbChartRoot ??= createRoot(container);
window.__zbChartRoot.render(
const roots = getRootRegistry();
let root = roots.get(container);
if (!root) {
root = createRoot(container);
roots.set(container, root);
}
root.render(
<StrictMode>
<App />
</StrictMode>,
@ -32,7 +45,7 @@ if (!mount()) {
if (mount() || retryCount >= 40) {
window.clearInterval(timer);
if (retryCount >= 40) {
console.warn('zbChart container was not found.');
console.warn(`${chartContainerId} container was not found.`);
}
}
}, 50);

View File

@ -362,15 +362,19 @@ button {
.chart-frame .chart-template-button,
.chart-frame .chart-indicator-button,
.chart-frame .chart-statistic-button,
.chart-frame .chart-pivot-button,
.chart-frame .chart-fullscreen-button {
color: #46413b;
cursor: pointer !important;
opacity: 1 !important;
overflow: visible !important;
pointer-events: auto !important;
}
.chart-frame .ag-charts-myButton-template,
.chart-frame .ag-charts-myButton-indicator {
.chart-frame .ag-charts-myButton-indicator,
.chart-frame .ag-charts-myButton-pivot {
position: relative;
display: block;
min-width: 16px;
height: 16px;
@ -380,9 +384,36 @@ button {
text-align: center;
}
.chart-indicator-selection-label {
position: absolute;
left: 28px;
top: 6px;
z-index: 3;
max-width: min(560px, calc(100vw - 56px));
padding: 0;
border: 0;
border-radius: 0;
color: #0078a8;
background: transparent;
box-shadow: none;
font-size: 15px;
font-weight: 600;
line-height: 20px;
overflow: hidden;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace:fullscreen .chart-indicator-selection-label {
left: 28px;
top: 6px;
}
.chart-frame .chart-template-button[aria-expanded="true"],
.chart-frame .chart-indicator-button[aria-expanded="true"],
.chart-frame .chart-statistic-button[aria-expanded="true"],
.chart-frame .chart-pivot-button.ag-charts-toolbar__button--active,
.chart-frame .chart-fullscreen-button.ag-charts-toolbar__button--active {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
@ -390,11 +421,87 @@ button {
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.chart-pivot-grid-panel {
position: absolute;
inset: 16px 0 0;
z-index: 10;
min-width: 0;
min-height: 0;
padding: 12px 16px 16px 74px;
border: 1px solid rgba(90, 82, 72, 0.22);
border-radius: 3px;
background: rgba(255, 249, 241, 0.96);
box-shadow: 0 6px 18px rgba(69, 54, 36, 0.08);
}
.chart-pivot-grid-panel .ag-root-wrapper {
border-color: rgba(90, 82, 72, 0.18);
border-radius: 3px;
}
.chart-grid-tool-button {
position: absolute;
left: 24px;
z-index: 12;
width: 26px;
height: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border: 1px solid rgba(90, 82, 72, 0.22);
border-radius: 3px;
color: #46413b;
background: rgba(255, 249, 241, 0.72);
font-size: 14px;
font-weight: 600;
line-height: 24px;
text-align: center;
cursor: pointer;
}
.chart-grid-tool-button--template {
top: 12px;
}
.chart-grid-tool-button--indicator {
top: 46px;
}
.chart-grid-tool-button--trend {
top: 80px;
}
.chart-grid-tool-button:hover {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
background: rgba(255, 252, 248, 0.94);
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.chart-pivot-grid-panel.ag-theme-quartz {
--ag-active-color: #0078a8;
--ag-background-color: #fffaf4;
--ag-border-color: rgba(90, 82, 72, 0.18);
--ag-font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
--ag-font-size: 12px;
--ag-foreground-color: #262a33;
--ag-header-background-color: #f6eadc;
--ag-row-hover-color: rgba(0, 120, 168, 0.08);
}
.metric-switcher {
position: absolute;
z-index: 12;
}
.metric-switcher--chart {
left: 74px;
top: 272px;
z-index: 12;
}
.metric-switcher--grid {
left: 24px;
top: 114px;
}
.metric-switcher-button {
@ -420,6 +527,23 @@ button {
text-orientation: upright;
}
.metric-switcher-button--grid {
width: 26px;
height: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(90, 82, 72, 0.22);
color: #46413b;
background: rgba(255, 249, 241, 0.72);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
line-height: 24px;
writing-mode: horizontal-tb;
text-orientation: mixed;
}
.metric-switcher-button:hover,
.metric-switcher-button[aria-expanded="true"] {
color: #0078a8;

View File

@ -2,7 +2,19 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
plugins: [
react(),
{
name: 'weaver-script-scope-wrapper',
renderChunk(code, chunk) {
if (!chunk.isEntry) return null;
return {
code: `;(() => {\n${code}\n})();\n`,
map: null,
};
},
},
],
server: {
port: 5173,
},