This commit is contained in:
wintsa 2026-08-14 18:12:25 +08:00
parent 92f2584232
commit 3a82078bd2
2 changed files with 348 additions and 87 deletions

View File

@ -12,7 +12,7 @@ import {
} from 'ag-grid-community';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import { Building2, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, SquareFunction, Waypoints } from 'lucide-react';
import { Building2, CalendarRange, Construction, LayoutGrid, Library, LocateFixed, MapPinned, PanelRightClose, PanelRightOpen, Ruler, SquareFunction, Waypoints } from 'lucide-react';
import {
AnnotationsModule,
ContextMenuModule,
@ -77,6 +77,8 @@ const filterOptions = [
{ key: 'buildingFunction', label: '建筑功能', icon: SquareFunction },
{ key: 'constructionStage', label: '建设阶段', icon: Construction },
{ key: 'planningForm', label: '规划形式', icon: LayoutGrid },
{ key: 'time', label: '时间', icon: CalendarRange },
{ key: 'area', label: '面积', icon: Ruler },
] as const;
const chartFilterOptions = filterOptions.filter((option) => option.key !== 'templateLibrary' && option.key !== 'indicatorTree');
@ -145,6 +147,17 @@ const defaultTemplateFilterNode = {
label: '默认模板',
} as const;
const overallSummaryKey = 'summary';
const pivotGridWanValueFields = new Set([
'lowValue',
'centerValue',
'highValue',
'maxValue',
'minValue',
'avgValue',
'medianValue',
'standardDeviation',
'interquartileRange',
]);
// const mockGeoLocationPayload = {
// checkStrictly: true,
@ -178,6 +191,7 @@ 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 RangeFilterKey = Extract<FilterKey, 'time' | 'area'>;
type GroupKey = 'year';
type ChartViewKey = 'trend' | 'pivot';
type ApiBuildingFunctionStat = {
@ -289,20 +303,31 @@ function formatChartValue(value: number, metricKey: MetricKey) {
return formatAreaMetricValue(value);
}
function formatGridWanValue(value: number) {
const wanValue = value / 10000;
const fractionDigits = Math.abs(wanValue) > 0 && Math.abs(wanValue) < 1 ? 4 : 2;
return formatNumber(wanValue, fractionDigits);
}
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);
const thresholdLowValue = row.threshold_low_value ?? fallbackThresholdLowValue;
const thresholdHighValue = row.threshold_high_value ?? fallbackThresholdHighValue;
const thresholdCenterValue = thresholdLowValue == null || thresholdHighValue == null
? null
: (thresholdLowValue + thresholdHighValue) / 2;
return {
groupName: row.group_name || String(row.group_key ?? '未命名'),
minValue: row.min_value ?? null,
maxValue: row.max_value ?? null,
avgValue,
medianValue,
thresholdLowValue: row.threshold_low_value ?? fallbackThresholdLowValue,
thresholdCenterValue: row.threshold_center_value ?? medianValue,
thresholdHighValue: row.threshold_high_value ?? fallbackThresholdHighValue,
thresholdLowValue,
thresholdCenterValue,
thresholdHighValue,
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,
@ -578,6 +603,10 @@ function isIndicatorTreeFilterKey(filterKey: FilterKey): filterKey is 'indicator
return filterKey === 'indicatorTree';
}
function isRangeFilterKey(filterKey: FilterKey): filterKey is RangeFilterKey {
return filterKey === 'time' || filterKey === 'area';
}
function isSingleSelectFilterKey(filterKey: FilterKey) {
return isTemplateFilterKey(filterKey) || isIndicatorTreeFilterKey(filterKey);
}
@ -741,6 +770,8 @@ function App() {
buildingFunction: false,
constructionStage: false,
planningForm: false,
time: false,
area: false,
});
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
@ -789,6 +820,8 @@ function App() {
buildingFunction: [],
constructionStage: [],
planningForm: [],
time: [],
area: [],
});
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
templateLibrary: false,
@ -799,6 +832,8 @@ function App() {
buildingFunction: false,
constructionStage: false,
planningForm: false,
time: false,
area: false,
});
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
templateLibrary: null,
@ -809,6 +844,8 @@ function App() {
buildingFunction: null,
constructionStage: null,
planningForm: null,
time: null,
area: null,
});
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
templateLibrary: [],
@ -819,6 +856,8 @@ function App() {
buildingFunction: [],
constructionStage: [],
planningForm: [],
time: [],
area: [],
});
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
templateLibrary: false,
@ -829,6 +868,8 @@ function App() {
buildingFunction: false,
constructionStage: false,
planningForm: false,
time: false,
area: false,
});
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
templateLibrary: null,
@ -839,6 +880,8 @@ function App() {
buildingFunction: null,
constructionStage: null,
planningForm: null,
time: null,
area: null,
});
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
templateLibrary: getDefaultTemplateFilterNodes(),
@ -849,9 +892,12 @@ function App() {
buildingFunction: [],
constructionStage: [],
planningForm: [],
time: [],
area: [],
});
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
const [draftRangeValues, setDraftRangeValues] = useState({ min: '', max: '' });
const [filterSearchValue, setFilterSearchValue] = useState('');
const filterSearchComposingRef = useRef(false);
const filterSearchTimerRef = useRef<number | null>(null);
@ -864,6 +910,8 @@ function App() {
buildingFunction: 0,
constructionStage: 0,
planningForm: 0,
time: 0,
area: 0,
});
const lastFilterSearchRef = useRef('');
@ -887,6 +935,26 @@ function App() {
const activeFilterTreeError = filterModalKey
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
: null;
const draftRangeMin = draftRangeValues.min === '' ? null : Number(draftRangeValues.min);
const draftRangeMax = draftRangeValues.max === '' ? null : Number(draftRangeValues.max);
const draftRangeError = filterModalKey && isRangeFilterKey(filterModalKey)
? (draftRangeMin != null && !Number.isFinite(draftRangeMin))
|| (draftRangeMax != null && !Number.isFinite(draftRangeMax))
? '请输入有效数字'
: draftRangeMin != null && draftRangeMax != null && draftRangeMin > draftRangeMax
? '起始值不能大于截止值'
: filterModalKey === 'time' && (
(draftRangeMin != null && (!Number.isInteger(draftRangeMin) || draftRangeMin < 1900 || draftRangeMin > 2100))
|| (draftRangeMax != null && (!Number.isInteger(draftRangeMax) || draftRangeMax < 1900 || draftRangeMax > 2100))
)
? '年份请输入1900至2100之间的整数'
: filterModalKey === 'area' && (
(draftRangeMin != null && draftRangeMin < 0)
|| (draftRangeMax != null && draftRangeMax < 0)
)
? '面积不能小于0'
: ''
: '';
const selectedTemplateId = appliedFilters.templateLibrary[0]?.id || defaultTemplateFilterNode.id;
const defaultIndicatorTreeNodes = useMemo(
() => getDefaultIndicatorTreeFilterNodes(filterTreeByKey.indicatorTree),
@ -907,11 +975,31 @@ function App() {
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
const appliedTimeRange = useMemo(() => {
const [minText = '', maxText = ''] = (appliedFilters.time[0]?.id ?? ':').split(':');
return {
min: minText === '' ? null : Number(minText),
max: maxText === '' ? null : Number(maxText),
};
}, [appliedFilters.time]);
const hasTimeFilter = appliedFilters.time.length > 0;
const visibleChartDataBySelection = useMemo(() => {
if (!hasTimeFilter) return chartDataBySelection;
return Object.fromEntries(Object.entries(chartDataBySelection).map(([key, rows]) => [
key,
rows.filter((datum) => {
const year = Number(datum.groupName);
return Number.isFinite(year)
&& (appliedTimeRange.min == null || year >= appliedTimeRange.min)
&& (appliedTimeRange.max == null || year <= appliedTimeRange.max);
}),
]));
}, [appliedTimeRange.max, appliedTimeRange.min, chartDataBySelection, hasTimeFilter]);
const groupNames = useMemo(() => {
const names: string[] = [];
const seen = new Set<string>();
selectedContentNodes.forEach((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
rows.forEach((datum) => {
if (seen.has(datum.groupName)) return;
seen.add(datum.groupName);
@ -919,10 +1007,10 @@ function App() {
});
});
return names.sort(compareGroupNames);
}, [chartDataBySelection, selectedContentNodes]);
}, [selectedContentNodes, visibleChartDataBySelection]);
const pivotGridRowData = useMemo<PivotGridRow[]>(
() => selectedContentNodes.flatMap((node) => {
const rows = chartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
const rows = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)] ?? [];
return rows.map((datum) => ({
year: datum.groupName,
name: node.label,
@ -940,10 +1028,11 @@ function App() {
dataCount: datum.dataCount,
}));
}),
[chartDataBySelection, selectedContentNodes],
[selectedContentNodes, visibleChartDataBySelection],
);
const pivotGridPinnedBottomRowData = useMemo<PivotGridRow[]>(
() => {
if (hasTimeFilter) return [];
const datum = chartSummaryBySelection[overallSummaryKey]
?? selectedContentNodes
.map((node) => chartSummaryBySelection[getSelectionKey(node.contentKey, node.id)])
@ -966,13 +1055,14 @@ function App() {
dataCount: datum.dataCount,
}];
},
[chartSummaryBySelection, selectedContentNodes],
[chartSummaryBySelection, hasTimeFilter, selectedContentNodes],
);
const pivotGridSampleCount = pivotGridPinnedBottomRowData[0]?.dataCount ?? 0;
const pivotGridColumnDefs = useMemo<(ColDef<PivotGridRow> | ColGroupDef<PivotGridRow>)[]>(
() => {
const valueUnit = requestMetricKey === 'cost' ? '万元' : '万元/m²';
const valueColumnMinWidth = requestMetricKey === 'cost' ? 112 : 142;
const valueFormatter = ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatChartValue(Number(value), requestMetricKey)
value == null ? '' : formatGridWanValue(Number(value))
);
return [
@ -994,75 +1084,75 @@ function App() {
children: [
{
field: 'lowValue',
headerName: '低值',
headerName: `低值(${valueUnit}`,
type: 'numericColumn',
minWidth: 64,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'centerValue',
headerName: '中心值',
headerName: `中心值(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'highValue',
headerName: '高值',
headerName: `高值(${valueUnit}`,
type: 'numericColumn',
minWidth: 64,
minWidth: valueColumnMinWidth,
valueFormatter,
},
],
},
{
headerName: `样本统计值(${formatNumber(pivotGridSampleCount, 0)}`,
headerName: '样本统计值',
children: [
{
field: 'maxValue',
headerName: '最大值',
headerName: `最大值(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'minValue',
headerName: '最小值',
headerName: `最小值(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'avgValue',
headerName: '平均值',
headerName: `平均值(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'medianValue',
headerName: '中位数',
headerName: `中位数(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'standardDeviation',
headerName: '标准差',
headerName: `标准差(${valueUnit}`,
type: 'numericColumn',
minWidth: 68,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'interquartileRange',
headerName: '四分位距',
headerName: `四分位距(${valueUnit}`,
type: 'numericColumn',
minWidth: 76,
minWidth: valueColumnMinWidth,
valueFormatter,
},
{
field: 'coefficientOfVariation',
headerName: '变异系数',
headerName: '变异系数(无量纲)',
type: 'numericColumn',
minWidth: 76,
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
@ -1071,9 +1161,18 @@ function App() {
},
],
},
{
field: 'dataCount',
headerName: '样本数量(个)',
type: 'numericColumn',
minWidth: 104,
valueFormatter: ({ value }: ValueFormatterParams<PivotGridRow, number | null>) => (
value == null ? '' : formatNumber(Number(value), 0)
),
},
];
},
[pivotGridSampleCount, requestMetricKey],
[requestMetricKey],
);
const fitPivotGridColumns = useCallback(() => {
window.requestAnimationFrame(() => {
@ -1087,7 +1186,15 @@ function App() {
});
}, []);
const exportPivotGrid = useCallback(() => {
pivotGridApiRef.current?.exportDataAsCsv({ fileName: '建筑设施对象统计表.csv' });
pivotGridApiRef.current?.exportDataAsCsv({
fileName: '建筑设施对象统计表.csv',
processCellCallback: ({ column, value }) => {
if (value == null) return '';
return pivotGridWanValueFields.has(column.getColId())
? String(Number(value) / 10000)
: String(value);
},
});
setGridContextMenuPosition(null);
}, []);
@ -1127,12 +1234,23 @@ function App() {
}, [defaultIndicatorTreeNodes]);
const appliedFilterPayload = useMemo(
() => filterOptions
.filter((option) => option.key !== 'time')
.map((option) => ({
key: option.key,
nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })),
}))
.filter((filter) => filter.nodes.length > 0),
[appliedFilters],
[
appliedFilters.area,
appliedFilters.buildingFunction,
appliedFilters.constructionStage,
appliedFilters.facilityType,
appliedFilters.geoLocation,
appliedFilters.indicatorTree,
appliedFilters.planningForm,
appliedFilters.region,
appliedFilters.templateLibrary,
],
);
const getNodeColor = (contentKey: ContentKey, nodeId: string) => {
@ -1418,6 +1536,7 @@ function App() {
};
const ensureFilterTreeLoaded = (filterKey: FilterKey) => {
if (isRangeFilterKey(filterKey)) return;
if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return;
filterTreeInitialLoadStartedRef.current[filterKey] = true;
@ -1445,6 +1564,10 @@ function App() {
const openFilterModal = (filterKey: FilterKey) => {
setFilterModalKey(filterKey);
if (isRangeFilterKey(filterKey)) {
const [min = '', max = ''] = (appliedFilters[filterKey][0]?.id ?? ':').split(':');
setDraftRangeValues({ min, max });
}
if (isIndicatorTreeFilterKey(filterKey) && appliedFilters[filterKey].length === 0 && defaultIndicatorTreeNodes.length > 0) {
setDraftFilterNodes(defaultIndicatorTreeNodes);
} else {
@ -1456,7 +1579,9 @@ function App() {
window.clearTimeout(filterSearchTimerRef.current);
filterSearchTimerRef.current = null;
}
ensureFilterTreeLoaded(filterKey);
if (!isRangeFilterKey(filterKey)) {
ensureFilterTreeLoaded(filterKey);
}
};
const closeFilterModal = () => {
@ -1470,6 +1595,7 @@ function App() {
lastFilterSearchRef.current = '';
setFilterModalKey(null);
setDraftFilterNodes([]);
setDraftRangeValues({ min: '', max: '' });
setFilterSearchValue('');
};
@ -1605,6 +1731,40 @@ function App() {
const applyFilterModal = () => {
if (!filterModalKey) return;
if (isRangeFilterKey(filterModalKey)) {
if (draftRangeError) return;
const min = draftRangeValues.min.trim();
const max = draftRangeValues.max.trim();
const unit = filterModalKey === 'time' ? '年' : 'm²';
const label = min && max
? `${min} - ${max}${unit}`
: min
? `${min}${unit}及以上`
: `${max}${unit}及以下`;
const nextRangeNodes: SelectedFilterNode[] = min || max ? [{
id: `${min}:${max}`,
filterKey: filterModalKey,
label,
}] : [];
setAppliedFilters((current) => ({
...current,
[filterModalKey]: nextRangeNodes,
}));
if (filterModalKey === 'time') {
closeFilterModal();
return;
}
setChartDataBySelection({});
setChartSummaryBySelection({});
setLoadError(null);
if (selectedContentNodes.length > 0) {
setLoadingHint('正在按筛选条件重新计算');
setLoading(true);
}
setChartQueryVersion((version) => version + 1);
closeFilterModal();
return;
}
let nextDraftNodes = draftFilterNodes;
if (isTemplateFilterKey(filterModalKey) && nextDraftNodes.length === 0) {
nextDraftNodes = getDefaultTemplateFilterNodes();
@ -1657,6 +1817,9 @@ function App() {
}
return nextFilters;
});
if (filterKey === 'time') {
return;
}
if (isTemplateFilterKey(filterKey)) {
resetIndicatorTreeState();
}
@ -2007,7 +2170,7 @@ function App() {
const visibleData = groupNames.map((groupName) => {
const row: Record<string, string | number | null> = { groupName };
selectedContentNodes.forEach((node) => {
const datum = chartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
const datum = visibleChartDataBySelection[getSelectionKey(node.contentKey, node.id)]?.find((item) => item.groupName === groupName);
row[getSeriesValueKey(node.contentKey, node.id)] = datum?.[selectedValueKey] ?? null;
});
return row;
@ -2211,7 +2374,6 @@ function App() {
};
}, [
activeFilterCount,
chartDataBySelection,
chartEmptyText,
groupNames,
metricKey,
@ -2223,6 +2385,7 @@ function App() {
seriesValueLabel,
selectedValueKey,
statisticKey,
visibleChartDataBySelection,
]);
const renderMetricSwitcher = (variant: 'chart' | 'grid') => (
@ -2311,6 +2474,8 @@ function App() {
buildingFunction: [],
constructionStage: [],
planningForm: [],
time: [],
area: [],
});
setChartDataBySelection({});
setChartSummaryBySelection({});
@ -2495,7 +2660,7 @@ function App() {
{filterModalKey && activeFilter ? (
<div className="filter-modal-backdrop" role="presentation" onMouseDown={closeFilterModal}>
<section
className="filter-modal"
className={`filter-modal${isRangeFilterKey(filterModalKey) ? ' filter-modal--range' : ''}`}
role="dialog"
aria-modal="true"
aria-label={`${activeFilter.label}筛选`}
@ -2505,63 +2670,102 @@ function App() {
<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>
{isRangeFilterKey(filterModalKey) ? (
<div className="filter-range-fields">
<label>
<span>{filterModalKey === 'time' ? '起始年份' : '最小面积'}</span>
<input
type="number"
min={filterModalKey === 'time' ? 1900 : 0}
max={filterModalKey === 'time' ? 2100 : undefined}
step={filterModalKey === 'time' ? 1 : 'any'}
value={draftRangeValues.min}
placeholder="不限"
onChange={(event) => setDraftRangeValues((current) => ({ ...current, min: event.target.value }))}
/>
</label>
<span className="filter-range-separator"></span>
<label>
<span>{filterModalKey === 'time' ? '截止年份' : '最大面积'}</span>
<input
type="number"
min={filterModalKey === 'time' ? 1900 : 0}
max={filterModalKey === 'time' ? 2100 : undefined}
step={filterModalKey === 'time' ? 1 : 'any'}
value={draftRangeValues.max}
placeholder="不限"
onChange={(event) => setDraftRangeValues((current) => ({ ...current, max: event.target.value }))}
/>
</label>
<div className="filter-range-error" role="status">{draftRangeError}</div>
</div>
) : (
<>
<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
: [],
)}
onClick={() => {
if (isRangeFilterKey(filterModalKey)) {
setDraftRangeValues({ min: '', max: '' });
return;
}
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 className="filter-modal-confirm" type="button" disabled={Boolean(draftRangeError)} onClick={applyFilterModal}>
</button>
</footer>

View File

@ -171,10 +171,11 @@ button {
.chart-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
overflow: visible;
justify-content: center;
}
@ -412,7 +413,7 @@ button {
font-size: 15px;
font-weight: 600;
line-height: 20px;
overflow: hidden;
overflow: visible;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
@ -1041,6 +1042,57 @@ button {
overflow: hidden;
}
.filter-modal--range {
grid-template-rows: auto auto auto;
height: auto;
min-height: 0;
}
.filter-range-fields {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 12px;
padding: 22px 18px 26px;
}
.filter-range-fields label {
display: grid;
gap: 7px;
color: #5d554d;
font-size: 13px;
}
.filter-range-fields input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid rgba(90, 82, 72, 0.2);
border-radius: 3px;
color: #262a33;
background: #fffdfa;
font-size: 14px;
outline: none;
}
.filter-range-fields input:focus {
border-color: rgba(0, 120, 168, 0.46);
box-shadow: 0 0 0 2px rgba(0, 120, 168, 0.12);
}
.filter-range-separator {
padding-bottom: 8px;
color: #776e65;
font-size: 13px;
}
.filter-range-error {
grid-column: 1 / -1;
min-height: 18px;
color: #a13f2c;
font-size: 12px;
}
.filter-modal-header {
display: flex;
align-items: center;
@ -1249,3 +1301,8 @@ button {
flex-wrap: wrap;
}
}
.filter-modal-confirm:disabled {
cursor: not-allowed;
opacity: 0.45;
}