From cfc68659863442148b217d24a5bbc6e67697629a Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Sat, 9 May 2026 18:22:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 3 + package.json | 1 + src/App.tsx | 646 ++++++++++++++++++++++++++++++++++++++++++++++++- src/styles.css | 267 +++++++++++++++++++- vite-dev.log | 76 ++++++ 5 files changed, 982 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 5642808..77b5072 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "ag-charts-community": "^13.2.1", "ag-charts-enterprise": "13.2.1", "ag-charts-react": "^13.2.1", + "lucide-react": "^1.14.0", "react": "^19.1.0", "react-dom": "^19.1.0", }, @@ -233,6 +234,8 @@ "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=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], diff --git a/package.json b/package.json index a06878e..a1a331f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "ag-charts-community": "^13.2.1", "ag-charts-enterprise": "13.2.1", "ag-charts-react": "^13.2.1", + "lucide-react": "^1.14.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, diff --git a/src/App.tsx b/src/App.tsx index 9d9515c..f0d1000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { AgCharts } from 'ag-charts-react'; import type { AgCartesianChartOptions } from 'ag-charts-community'; import { ModuleRegistry } from 'ag-charts-community'; +import { Building2, Construction, LayoutGrid, LocateFixed, MapPinned } from 'lucide-react'; import { AnnotationsModule, ContextMenuModule, @@ -15,6 +16,7 @@ LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762 ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]); const API_BASE_URL = 'https://nest.zwgczx.com/api/v1'; +// const API_BASE_URL = 'http://127.0.0.1:9089/api/v1'; const statisticOptions = [ { key: 'minValue', label: '最低值', shortLabel: '低' }, @@ -38,6 +40,14 @@ const contentOptions = [ { key: 'planningForm', label: '规划形式' }, ] as const; +const filterOptions = [ + { key: 'region', label: '省市区', icon: MapPinned }, + { key: 'geoLocation', label: '自然地理区位', icon: LocateFixed }, + { key: 'facilityType', label: '设施类别', icon: Building2 }, + { key: 'constructionStage', label: '建设阶段', icon: Construction }, + { key: 'planningForm', label: '规划形式', icon: LayoutGrid }, +] as const; + const browserTreeDefaults = { treetype: '256', checkStrictly: 'true', @@ -118,6 +128,7 @@ const chartLineColors = ['#0078a8', '#d14d72', '#1f8f4d', '#d96f23', '#6b5cc8', type StatisticKey = (typeof statisticOptions)[number]['key']; type MetricKey = (typeof metricOptions)[number]['key']; type ContentKey = (typeof contentOptions)[number]['key']; +type FilterKey = (typeof filterOptions)[number]['key']; type GroupKey = 'year'; type ApiBuildingFunctionStat = { group_key?: string | number | null; @@ -156,6 +167,11 @@ type SelectedContentNode = { label: string; color: string; }; +type SelectedFilterNode = { + id: string; + filterKey: FilterKey; + label: string; +}; function formatNumber(value: number, maximumFractionDigits: number) { return value.toLocaleString('zh-CN', { @@ -275,6 +291,81 @@ function normalizeTreeRows(rows: unknown[]): TreeNode[] { }); } +const regionFieldKeys = { + provinceId: ['provinceId', 'province_id', 'provinceid', 'sfid', 'sf_id', 'shengId', 'sheng_id', 'sheng'], + provinceName: ['provinceName', 'province_name', 'province', 'sfmc', 'sf', 'shengName', 'sheng_name', 'shengmc'], + cityId: ['cityId', 'city_id', 'cityid', 'sid', 's_id', 'shiId', 'shi_id', 'shi'], + cityName: ['cityName', 'city_name', 'city', 'smc', 's', 'shiName', 'shi_name', 'shimc'], + districtId: ['districtId', 'district_id', 'districtid', 'countyId', 'county_id', 'qid', 'q_id', 'xid', 'x_id', 'id'], + districtName: ['districtName', 'district_name', 'district', 'countyName', 'county_name', 'county', 'qmc', 'q', 'xmc', 'x', 'name', 'shortname'], +}; + +function createFilterTreeNode(id: string, label: string, children: TreeNode[] = [], expanded = false): TreeNode { + return { + id, + label, + children, + hasChildren: children.length > 0, + canClick: true, + expanded, + loading: false, + loaded: true, + }; +} + +function normalizeFlatRegionRows(rows: unknown[]): TreeNode[] { + const sourceRows = rows.filter((row): row is Record => !!row && typeof row === 'object'); + const hasRegionShape = sourceRows.some((row) => + readText(row, regionFieldKeys.provinceName) && + readText(row, regionFieldKeys.cityName) && + readText(row, regionFieldKeys.districtName), + ); + if (!hasRegionShape) return []; + + const provinceMap = new Map; + }>; + }>(); + + sourceRows.forEach((row) => { + const provinceLabel = readText(row, regionFieldKeys.provinceName); + const cityLabel = readText(row, regionFieldKeys.cityName); + const districtLabel = readText(row, regionFieldKeys.districtName); + if (!provinceLabel || !cityLabel || !districtLabel) return; + + const provinceId = `region:province:${readText(row, regionFieldKeys.provinceId) || provinceLabel}`; + const cityId = `region:city:${provinceLabel}:${readText(row, regionFieldKeys.cityId) || cityLabel}`; + const districtId = `region:district:${readText(row, regionFieldKeys.districtId) || `${provinceLabel}:${cityLabel}:${districtLabel}`}`; + + const province = provinceMap.get(provinceId) ?? { + id: provinceId, + label: provinceLabel, + cityMap: new Map }>(), + }; + const city = province.cityMap.get(cityId) ?? { + id: cityId, + label: cityLabel, + districts: new Map(), + }; + + city.districts.set(districtId, createFilterTreeNode(districtId, districtLabel)); + province.cityMap.set(cityId, city); + provinceMap.set(provinceId, province); + }); + + return Array.from(provinceMap.values()).map((province) => { + const cityNodes = Array.from(province.cityMap.values()).map((city) => + createFilterTreeNode(city.id, city.label, Array.from(city.districts.values()), false), + ); + return createFilterTreeNode(province.id, province.label, cityNodes, true); + }); +} + function updateNode(nodes: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] { return nodes.map((node) => { if (node.id === nodeId) return updater(node); @@ -289,10 +380,40 @@ function getSelectionKey(contentKey: ContentKey, nodeId: string) { return `${contentKey}:${nodeId}`; } +function getFilterSelectionKey(filterKey: FilterKey, nodeId: string) { + return `${filterKey}:${nodeId}`; +} + function getSeriesValueKey(index: number) { return `amount${index}`; } +function isContentFilterKey(filterKey: FilterKey): filterKey is ContentKey { + return filterKey !== 'region'; +} + +function filterTreeNodesByKeyword(nodes: TreeNode[], keyword: string): TreeNode[] { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) return nodes; + + const expandDescendants = (targetNodes: TreeNode[]): TreeNode[] => targetNodes.map((node) => ({ + ...node, + children: expandDescendants(node.children), + expanded: node.hasChildren || node.children.length > 0 ? true : node.expanded, + })); + + return nodes.flatMap((node) => { + const matched = node.label.toLowerCase().includes(normalizedKeyword); + const children = filterTreeNodesByKeyword(node.children, keyword); + if (!matched && children.length === 0) return []; + return [{ + ...node, + children: matched ? expandDescendants(node.children) : children, + expanded: true, + }]; + }); +} + function renderTreeNodes( nodes: TreeNode[], contentKey: ContentKey, @@ -340,6 +461,51 @@ function renderTreeNodes( ); } +function renderFilterTreeNodes( + nodes: TreeNode[], + filterKey: FilterKey, + selectedNodeKeys: Set, + onToggle: (nodeId: string) => void, + onSelect: (node: TreeNode) => void, + depth = 0, +) { + return ( +
    + {nodes.map((node) => { + const selected = selectedNodeKeys.has(getFilterSelectionKey(filterKey, node.id)); + + return ( +
  • +
    + {node.hasChildren ? ( + + ) : ( + + )} + + {node.loading ? 加载中 : null} +
    + {node.expanded && node.children.length > 0 + ? renderFilterTreeNodes(node.children, filterKey, selectedNodeKeys, onToggle, onSelect, depth + 1) + : null} +
  • + ); + })} +
+ ); +} + function App() { const workspaceRef = useRef(null); const chartFrameRef = useRef(null); @@ -349,6 +515,13 @@ function App() { constructionStage: false, planningForm: false, }); + const filterTreeInitialLoadStartedRef = useRef>({ + region: false, + geoLocation: false, + facilityType: false, + constructionStage: false, + planningForm: false, + }); const [statisticKey, setStatisticKey] = useState('avgValue'); const [metricKey, setMetricKey] = useState('cost'); const [groupKey, setGroupKey] = useState('year'); @@ -379,11 +552,89 @@ function App() { const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [loadingHint, setLoadingHint] = useState(''); + const [filterTreeByKey, setFilterTreeByKey] = useState>({ + region: [], + geoLocation: [], + facilityType: [], + constructionStage: [], + planningForm: [], + }); + const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState>({ + region: false, + geoLocation: false, + facilityType: false, + constructionStage: false, + planningForm: false, + }); + const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState>({ + region: null, + geoLocation: null, + facilityType: null, + constructionStage: null, + planningForm: null, + }); + const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState>({ + region: [], + geoLocation: [], + facilityType: [], + constructionStage: [], + planningForm: [], + }); + const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState>({ + region: false, + geoLocation: false, + facilityType: false, + constructionStage: false, + planningForm: false, + }); + const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState>({ + region: null, + geoLocation: null, + facilityType: null, + constructionStage: null, + planningForm: null, + }); + const [appliedFilters, setAppliedFilters] = useState>({ + region: [], + geoLocation: [], + facilityType: [], + constructionStage: [], + planningForm: [], + }); + const [filterModalKey, setFilterModalKey] = useState(null); + const [draftFilterNodes, setDraftFilterNodes] = useState([]); + const [filterSearchValue, setFilterSearchValue] = useState(''); + const filterSearchComposingRef = useRef(false); + const filterSearchTimerRef = useRef(null); + const filterSearchRequestSeqRef = useRef>({ + region: 0, + geoLocation: 0, + facilityType: 0, + constructionStage: 0, + planningForm: 0, + }); + const lastFilterSearchRef = useRef(''); const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0]; const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0]; const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0]; const activeTree = treeByContent[activeContentKey]; + const activeFilter = filterOptions.find((option) => option.key === filterModalKey); + const activeFilterTree = filterModalKey ? filterTreeByKey[filterModalKey] : []; + const trimmedFilterSearchValue = filterSearchValue.trim(); + const activeFilterDisplayTree = filterModalKey && trimmedFilterSearchValue ? filterSearchTreeByKey[filterModalKey] : activeFilterTree; + const activeFilterTreeLoading = filterModalKey + ? trimmedFilterSearchValue ? filterSearchLoadingByKey[filterModalKey] : filterTreeLoadingByKey[filterModalKey] + : false; + const activeFilterTreeError = filterModalKey + ? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey] + : null; + const activeFilterCount = Object.values(appliedFilters).reduce((total, nodes) => total + nodes.length, 0); + const chartEmptyText = selectedContentNodes.length === 0 + ? '请选择右侧分类项' + : activeFilterCount > 0 + ? '当前筛选无数据' + : '所选分类暂无数据'; const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey; const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey; const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label; @@ -391,6 +642,19 @@ function App() { () => new Set(selectedContentNodes.map((node) => getSelectionKey(node.contentKey, node.id))), [selectedContentNodes], ); + const draftFilterNodeKeys = useMemo( + () => new Set(draftFilterNodes.map((node) => getFilterSelectionKey(node.filterKey, node.id))), + [draftFilterNodes], + ); + const appliedFilterPayload = useMemo( + () => filterOptions + .map((option) => ({ + key: option.key, + nodes: appliedFilters[option.key].map((node) => ({ nodeId: node.id })), + })) + .filter((filter) => filter.nodes.length > 0), + [appliedFilters], + ); const getNodeColor = (contentKey: ContentKey, nodeId: string) => { let hash = 0; @@ -401,7 +665,7 @@ function App() { return chartLineColors[hash]; }; - const fetchContentTree = async (contentKey: ContentKey, nodeId?: string) => { + const fetchContentTree = async (contentKey: ContentKey, nodeId?: string, signal?: AbortSignal) => { const config = contentTreeConfigs[contentKey]; if (!config) { throw new Error('接口待接入'); @@ -428,6 +692,7 @@ function App() { }; const response = await fetch(`${config.endpoint}?${buildQuery(params)}`, { credentials: 'include', + signal, headers: { 'X-Requested-With': 'XMLHttpRequest', }, @@ -435,7 +700,65 @@ function App() { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - return normalizeTreeRows(pickArray(await response.json())); + const rows = pickArray(await response.json()); + return normalizeTreeRows(rows); + }; + + const normalizeBackendTree = (nodes: TreeNode[]): TreeNode[] => nodes.map((node) => ({ + ...node, + hasChildren: node.children.length > 0 || node.hasChildren, + expanded: node.children.length > 0, + loaded: true, + children: normalizeBackendTree(node.children), + })); + + const getTreeNodePrefix = (nodes: TreeNode[]) => { + const stack = [...nodes]; + while (stack.length) { + const node = stack.shift(); + if (!node) { + continue; + } + const matched = node.id.match(/^(.*_)\d+$/); + if (matched) { + return matched[1]; + } + stack.push(...node.children); + } + return ''; + }; + + const fetchRegionFilterTree = async (signal?: AbortSignal) => { + const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTree?${buildQuery({ key: 'region' })}`, { + signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const rows = pickArray(await response.json()); + const flatRegionTree = normalizeFlatRegionRows(rows); + return flatRegionTree.length > 0 ? flatRegionTree : normalizeBackendTree(normalizeTreeRows(rows)); + }; + + const fetchBackendFilterTreeSearch = async (filterKey: FilterKey, keyword: string, signal?: AbortSignal) => { + const response = await fetch(`${API_BASE_URL}/zw/getBuildingFunctionCostFilterTreeSearch?${buildQuery({ + key: filterKey, + keyword, + nodePrefix: isContentFilterKey(filterKey) ? getTreeNodePrefix(filterTreeByKey[filterKey]) : '', + })}`, { + signal, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error(`搜索接口请求失败:HTTP ${response.status}${errorText ? ` ${errorText.slice(0, 160)}` : ''}`); + } + return normalizeBackendTree(normalizeTreeRows(pickArray(await response.json()))); }; const loadContentTreeWithDefaultExpansion = async (contentKey: ContentKey) => { @@ -463,6 +786,17 @@ function App() { return loadChildren(await fetchContentTree(contentKey), 1); }; + const loadFilterTree = async (filterKey: FilterKey, keyword?: string, signal?: AbortSignal) => { + if (filterKey === 'region') { + const nodes = await fetchRegionFilterTree(signal); + return keyword?.trim() ? filterTreeNodesByKeyword(nodes, keyword) : nodes; + } + if (keyword?.trim()) { + return fetchBackendFilterTreeSearch(filterKey, keyword, signal); + } + return loadContentTreeWithDefaultExpansion(filterKey); + }; + const toggleContentNode = (nodeId: string) => { if (!contentTreeConfigs[activeContentKey]) return; const target = activeTree.find((node) => node.id === nodeId); @@ -546,6 +880,196 @@ function App() { setLoading(false); }; + const ensureFilterTreeLoaded = (filterKey: FilterKey) => { + if (filterTreeByKey[filterKey].length > 0 || filterTreeInitialLoadStartedRef.current[filterKey]) return; + + filterTreeInitialLoadStartedRef.current[filterKey] = true; + setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: true })); + setFilterTreeErrorByKey((current) => ({ ...current, [filterKey]: null })); + loadFilterTree(filterKey) + .then((nodes) => { + setFilterTreeByKey((current) => ({ ...current, [filterKey]: nodes })); + }) + .catch((error) => { + filterTreeInitialLoadStartedRef.current[filterKey] = false; + setFilterTreeErrorByKey((current) => ({ + ...current, + [filterKey]: error instanceof Error ? error.message : '加载失败', + })); + }) + .finally(() => { + setFilterTreeLoadingByKey((current) => ({ ...current, [filterKey]: false })); + }); + }; + + const openFilterModal = (filterKey: FilterKey) => { + setFilterModalKey(filterKey); + setDraftFilterNodes(appliedFilters[filterKey]); + setFilterSearchValue(''); + lastFilterSearchRef.current = ''; + if (filterSearchTimerRef.current != null) { + window.clearTimeout(filterSearchTimerRef.current); + filterSearchTimerRef.current = null; + } + ensureFilterTreeLoaded(filterKey); + }; + + const closeFilterModal = () => { + if (filterModalKey) { + filterSearchRequestSeqRef.current[filterModalKey] += 1; + } + if (filterSearchTimerRef.current != null) { + window.clearTimeout(filterSearchTimerRef.current); + filterSearchTimerRef.current = null; + } + lastFilterSearchRef.current = ''; + setFilterModalKey(null); + setDraftFilterNodes([]); + setFilterSearchValue(''); + }; + + const scheduleFilterSearch = (filterKey: FilterKey, rawKeyword: string) => { + if (filterSearchTimerRef.current != null) { + window.clearTimeout(filterSearchTimerRef.current); + filterSearchTimerRef.current = null; + } + + const keyword = rawKeyword.trim(); + const searchKey = `${filterKey}:${keyword}`; + if (keyword && lastFilterSearchRef.current === searchKey) { + return; + } + lastFilterSearchRef.current = searchKey; + filterSearchRequestSeqRef.current[filterKey] += 1; + const requestSeq = filterSearchRequestSeqRef.current[filterKey]; + + if (!keyword) { + setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: [] })); + setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null })); + setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false })); + return; + } + + filterSearchTimerRef.current = window.setTimeout(() => { + setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: true })); + setFilterSearchErrorByKey((current) => ({ ...current, [filterKey]: null })); + void loadFilterTree(filterKey, keyword) + .then((nodes) => { + if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; + setFilterSearchTreeByKey((current) => ({ ...current, [filterKey]: nodes })); + }) + .catch((error) => { + if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; + setFilterSearchErrorByKey((current) => ({ + ...current, + [filterKey]: error instanceof Error ? error.message : '加载失败', + })); + }) + .finally(() => { + if (filterSearchRequestSeqRef.current[filterKey] !== requestSeq) return; + setFilterSearchLoadingByKey((current) => ({ ...current, [filterKey]: false })); + }); + }, 500); + }; + + const toggleFilterTreeNode = (nodeId: string) => { + if (!filterModalKey) return; + const visit = (nodes: TreeNode[]): TreeNode | null => { + for (const node of nodes) { + if (node.id === nodeId) return node; + const matched = visit(node.children); + if (matched) return matched; + } + return null; + }; + const node = visit(filterTreeByKey[filterModalKey]); + if (!node?.hasChildren) return; + + setFilterTreeByKey((current) => ({ + ...current, + [filterModalKey]: updateNode(current[filterModalKey], nodeId, (currentNode) => ({ + ...currentNode, + expanded: currentNode.loaded ? !currentNode.expanded : currentNode.expanded, + loading: currentNode.loaded ? currentNode.loading : true, + })), + })); + + if (node.loaded || !isContentFilterKey(filterModalKey)) return; + + const currentFilterKey = filterModalKey; + fetchContentTree(currentFilterKey, nodeId) + .then((children) => { + setFilterTreeByKey((current) => ({ + ...current, + [currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({ + ...currentNode, + children, + expanded: true, + loading: false, + loaded: true, + hasChildren: currentNode.hasChildren || children.length > 0, + })), + })); + }) + .catch((error) => { + setFilterTreeByKey((current) => ({ + ...current, + [currentFilterKey]: updateNode(current[currentFilterKey], nodeId, (currentNode) => ({ + ...currentNode, + loading: false, + })), + })); + setFilterTreeErrorByKey((current) => ({ + ...current, + [currentFilterKey]: error instanceof Error ? error.message : '加载失败', + })); + }); + }; + + const toggleDraftFilterNode = (node: TreeNode) => { + if (!filterModalKey) return; + const currentFilterKey = filterModalKey; + setDraftFilterNodes((current) => { + const selectionKey = getFilterSelectionKey(currentFilterKey, node.id); + const exists = current.some((item) => getFilterSelectionKey(item.filterKey, item.id) === selectionKey); + if (exists) { + return current.filter((item) => getFilterSelectionKey(item.filterKey, item.id) !== selectionKey); + } + return [...current, { id: node.id, filterKey: currentFilterKey, label: node.label }]; + }); + }; + + const applyFilterModal = () => { + if (!filterModalKey) return; + setAppliedFilters((current) => ({ + ...current, + [filterModalKey]: draftFilterNodes, + })); + setChartDataBySelection({}); + setLoadError(null); + if (selectedContentNodes.length > 0) { + setLoadingHint('正在按筛选条件重新计算'); + setLoading(true); + } + setChartQueryVersion((version) => version + 1); + closeFilterModal(); + }; + + const clearFilter = (filterKey: FilterKey) => { + if (appliedFilters[filterKey].length === 0) return; + setAppliedFilters((current) => ({ + ...current, + [filterKey]: [], + })); + setChartDataBySelection({}); + setLoadError(null); + if (selectedContentNodes.length > 0) { + setLoadingHint('正在按筛选条件重新计算'); + setLoading(true); + } + setChartQueryVersion((version) => version + 1); + }; + const updateMetricKey = (nextMetricKey: MetricKey) => { setMetricKey(nextMetricKey); setChartDataBySelection({}); @@ -612,6 +1136,7 @@ function App() { body: JSON.stringify({ groupBy: groupKey, metric: requestMetricKey, + filters: appliedFilterPayload, nodes: selectedContentNodes.map((node) => ({ key: getSelectionKey(node.contentKey, node.id), contentKey: node.contentKey, @@ -646,7 +1171,7 @@ function App() { return () => { controller.abort(); }; - }, [chartQueryVersion, groupKey, metricKey, requestMetricKey, selectedContentNodes]); + }, [appliedFilterPayload, chartQueryVersion, groupKey, metricKey, requestMetricKey, selectedContentNodes]); useEffect(() => { const frame = chartFrameRef.current; @@ -831,7 +1356,7 @@ function App() { }, overlays: { noData: { - text: '请选择右侧分类项', + text: chartEmptyText, }, }, annotations: { @@ -955,7 +1480,7 @@ function App() { pagination: true, }, }; - }, [chartDataBySelection, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]); + }, [activeFilterCount, chartDataBySelection, chartEmptyText, metricKey, requestMetricKey, selectedContentNodes, selectedMetric.label, seriesValueLabel, selectedValueKey, statisticKey]); return (
@@ -966,6 +1491,51 @@ function App() {
+
+ {filterOptions.map((option) => { + const count = appliedFilters[option.key].length; + const FilterIcon = option.icon; + return ( + + ); + })} + {activeFilterCount > 0 ? ( + + ) : null} +
{statisticMenuOpen ? ( @@ -1064,6 +1634,72 @@ function App() {
+ {filterModalKey && activeFilter ? ( +
+
event.stopPropagation()} + > +
+

{activeFilter.label}

+ +
+
+ { + 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); + } + }} + /> +
+
+ {draftFilterNodes.length > 0 ? `已选 ${draftFilterNodes.length} 项` : '未选择'} +
+
+ {activeFilterTreeLoading ? ( +
加载中
+ ) : activeFilterTreeError ? ( +
{activeFilterTreeError}
+ ) : activeFilterDisplayTree.length > 0 ? ( + renderFilterTreeNodes(activeFilterDisplayTree, filterModalKey, draftFilterNodeKeys, toggleFilterTreeNode, toggleDraftFilterNode) + ) : ( +
{trimmedFilterSearchValue ? '无匹配结果' : '暂无数据'}
+ )} +
+ +
+
+ ) : null}
); } diff --git a/src/styles.css b/src/styles.css index abcbb60..9c95a23 100644 --- a/src/styles.css +++ b/src/styles.css @@ -65,9 +65,10 @@ button { z-index: 1; display: grid; grid-template-columns: minmax(540px, 52vw) 1fr; - gap: 28px; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px 28px; height: 100vh; - padding: 8px 28px 18px 64px; + padding: 30px 28px 18px 64px; } .chart-area { @@ -157,7 +158,84 @@ button { position: relative; min-width: 0; min-height: 0; - padding: 26px 0 0; + padding: 16px 0 0; +} + +.chart-filter-bar { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + justify-content: center; +} + +.chart-filter-bar--workspace { + grid-column: 1 / -1; + position: relative; + z-index: 2; + margin: 0 28px 0 64px; + padding-right: 58px; +} + +.chart-filter-button, +.chart-filter-clear { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + max-width: 136px; + padding: 0 10px; + border: 1px solid rgba(90, 82, 72, 0.18); + border-radius: 3px; + color: #46413b; + background: rgba(255, 249, 241, 0.76); + font-size: 13px; + line-height: 30px; + white-space: nowrap; + cursor: pointer; +} + +.chart-filter-icon { + flex: 0 0 14px; + width: 14px; + height: 14px; + display: block; + color: currentColor; +} + +.chart-filter-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-filter-button strong { + display: inline-grid; + min-width: 18px; + height: 18px; + place-items: center; + padding: 0 5px; + border-radius: 9px; + color: #fff; + background: #0078a8; + font-size: 12px; + font-weight: 600; + line-height: 18px; +} + +.chart-filter-button:hover, +.chart-filter-button[aria-pressed="true"], +.chart-filter-clear:hover { + color: #0078a8; + border-color: rgba(0, 120, 168, 0.36); + background: rgba(255, 252, 248, 0.96); + box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12); +} + +.chart-filter-clear { + flex: 0 0 auto; + color: #7a3f2b; } .workspace:fullscreen { @@ -165,7 +243,8 @@ button { z-index: 10; display: grid; grid-template-columns: minmax(540px, 52vw) 1fr; - gap: 28px; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px 28px; width: 100vw; height: 100vh; padding: 8px 28px 18px 64px; @@ -173,7 +252,7 @@ button { } .workspace:fullscreen .chart-area { - height: calc(100vh - 26px); + min-height: 0; } .workspace:fullscreen .right-panel { @@ -182,6 +261,11 @@ button { height: 100%; } +.workspace:fullscreen .chart-filter-bar--workspace { + margin: 0; + padding-right: 0; +} + .chart-frame > div { height: 100%; } @@ -193,7 +277,7 @@ button { .chart-loading-mask { position: absolute; - inset: 26px 0 0; + inset: 16px 0 0; z-index: 13; display: flex; align-items: center; @@ -688,6 +772,177 @@ button { line-height: 24px; } +.filter-modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 28px; + background: rgba(35, 30, 25, 0.28); +} + +.filter-modal { + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; + width: min(560px, calc(100vw - 56px)); + height: min(680px, calc(100vh - 56px)); + min-height: 420px; + border: 1px solid rgba(90, 82, 72, 0.2); + border-radius: 6px; + background: #fff7ef; + box-shadow: 0 18px 50px rgba(35, 30, 25, 0.22); + overflow: hidden; +} + +.filter-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 16px 18px 12px; + border-bottom: 1px solid rgba(90, 82, 72, 0.12); +} + +.filter-modal-header h2 { + margin: 0; + color: #262a33; + font-size: 18px; + font-weight: 600; + line-height: 24px; +} + +.filter-modal-close { + display: grid; + width: 30px; + height: 30px; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + color: #6d6258; + background: transparent; + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.filter-modal-close:hover { + color: #0078a8; + border-color: rgba(0, 120, 168, 0.28); + background: rgba(255, 252, 248, 0.9); +} + +.filter-modal-search { + padding: 12px 18px 8px; +} + +.filter-modal-search input { + width: 100%; + height: 34px; + 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-modal-search input:focus { + border-color: rgba(0, 120, 168, 0.46); + box-shadow: 0 0 0 2px rgba(0, 120, 168, 0.12); +} + +.filter-modal-selected { + padding: 0 18px 8px; + color: #6d6258; + font-size: 13px; + line-height: 20px; +} + +.filter-modal-tree { + min-height: 0; + overflow: auto; + padding: 4px 12px 12px; + border-top: 1px solid rgba(90, 82, 72, 0.1); +} + +.filter-tree-row { + min-height: 36px; + font-size: 15px; + line-height: 36px; +} + +.filter-tree-select { + height: 32px; + gap: 8px; +} + +.filter-tree-check { + position: relative; + width: 16px; + height: 16px; + flex: 0 0 16px; + border: 1px solid rgba(90, 82, 72, 0.32); + border-radius: 3px; + background: rgba(255, 252, 248, 0.82); +} + +.filter-tree-select[aria-pressed="true"] .filter-tree-check { + border-color: #0078a8; + background: #0078a8; +} + +.filter-tree-select[aria-pressed="true"] .filter-tree-check::after { + position: absolute; + left: 4px; + top: 1px; + width: 5px; + height: 9px; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(45deg); + content: ""; +} + +.filter-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 18px 16px; + border-top: 1px solid rgba(90, 82, 72, 0.12); +} + +.filter-modal-actions button { + height: 32px; + min-width: 72px; + padding: 0 14px; + border: 1px solid rgba(90, 82, 72, 0.18); + border-radius: 3px; + background: rgba(255, 249, 241, 0.76); + color: #46413b; + font-size: 14px; + cursor: pointer; +} + +.filter-modal-actions button:hover { + color: #0078a8; + border-color: rgba(0, 120, 168, 0.36); + background: rgba(255, 252, 248, 0.96); +} + +.filter-modal-confirm { + color: #fff !important; + border-color: #0078a8 !important; + background: #0078a8 !important; +} + +.filter-modal-confirm:hover { + background: #006f9b !important; +} + @media (max-width: 900px) { body { overflow: auto; diff --git a/vite-dev.log b/vite-dev.log index aa7c22c..361d8e1 100644 --- a/vite-dev.log +++ b/vite-dev.log @@ -113,3 +113,79 @@ Port 5173 is in use, trying another one... 10:04:35 [vite] (client) hmr update /src/styles.css 10:06:06 [vite] (client) hmr update /src/App.tsx 10:06:25 [vite] (client) hmr update /src/styles.css +14:21:06 [vite] (client) hmr update /src/App.tsx +14:26:18 [vite] (client) hmr update /src/App.tsx +14:26:51 [vite] (client) hmr update /src/styles.css +14:28:15 [vite] (client) hmr update /src/App.tsx +14:28:24 [vite] (client) hmr update /src/App.tsx +15:07:03 [vite] (client) hmr update /src/App.tsx +15:07:31 [vite] (client) hmr update /src/App.tsx +15:08:11 [vite] (client) hmr update /src/App.tsx +15:13:59 [vite] (client) hmr update /src/App.tsx +16:47:31 [vite] (client) hmr update /src/App.tsx +16:48:22 [vite] (client) hmr update /src/App.tsx +16:48:32 [vite] (client) hmr update /src/App.tsx +17:00:07 [vite] (client) hmr update /src/App.tsx +17:15:44 [vite] (client) hmr update /src/App.tsx +18:06:45 [vite] (client) hmr update /src/App.tsx +18:07:01 [vite] (client) hmr update /src/App.tsx +18:07:22 [vite] (client) hmr update /src/App.tsx +18:07:43 [vite] (client) hmr update /src/App.tsx +18:08:03 [vite] (client) hmr update /src/App.tsx +18:08:20 [vite] (client) hmr update /src/App.tsx +18:08:55 [vite] (client) hmr update /src/App.tsx +18:09:09 [vite] (client) hmr update /src/App.tsx +18:09:41 [vite] (client) hmr update /src/App.tsx +18:10:20 [vite] (client) hmr update /src/styles.css +18:11:00 [vite] (client) hmr update /src/styles.css +09:08:22 [vite] (client) hmr update /src/App.tsx +09:09:58 [vite] (client) hmr update /src/App.tsx +09:10:10 [vite] (client) hmr update /src/styles.css +09:12:48 [vite] (client) hmr update /src/styles.css +09:24:09 [vite] (client) hmr update /src/styles.css +09:24:54 [vite] (client) hmr update /src/styles.css +09:25:33 [vite] (client) hmr update /src/App.tsx +09:25:55 [vite] (client) hmr update /src/styles.css +09:31:55 [vite] (client) hmr update /src/App.tsx +09:32:05 [vite] (client) hmr update /src/App.tsx +09:32:12 [vite] (client) hmr update /src/App.tsx +09:32:32 [vite] (client) hmr update /src/styles.css +09:45:56 [vite] (client) hmr update /src/App.tsx +10:00:51 [vite] (client) hmr update /src/App.tsx +10:01:08 [vite] (client) hmr update /src/App.tsx +10:01:23 [vite] (client) hmr update /src/App.tsx +10:01:36 [vite] (client) hmr update /src/App.tsx +10:01:46 [vite] (client) hmr update /src/App.tsx +10:01:56 [vite] (client) hmr update /src/App.tsx +10:02:58 [vite] (client) hmr update /src/App.tsx +10:08:33 [vite] (client) hmr update /src/App.tsx +10:08:56 [vite] (client) hmr update /src/App.tsx +10:09:20 [vite] (client) hmr update /src/styles.css +10:09:29 [vite] (client) hmr update /src/styles.css +10:12:15 [vite] (client) hmr update /src/styles.css +10:17:02 [vite] (client) hmr update /src/App.tsx +10:54:20 [vite] (client) hmr update /src/App.tsx +10:54:44 [vite] (client) hmr update /src/App.tsx +10:59:54 [vite] (client) hmr update /src/App.tsx +11:00:16 [vite] (client) hmr update /src/App.tsx +11:00:32 [vite] (client) hmr update /src/App.tsx +11:00:55 [vite] (client) hmr update /src/App.tsx +11:12:15 [vite] (client) hmr update /src/App.tsx +11:16:52 [vite] (client) hmr update /src/App.tsx +11:20:34 [vite] (client) hmr update /src/App.tsx +11:22:39 [vite] (client) hmr update /src/App.tsx +11:30:32 [vite] (client) hmr update /src/App.tsx +11:42:14 [vite] (client) hmr update /src/App.tsx +11:48:40 [vite] (client) hmr update /src/App.tsx +12:07:11 [vite] (client) hmr update /src/App.tsx +12:07:25 [vite] (client) hmr update /src/App.tsx +12:07:50 [vite] (client) hmr update /src/App.tsx +14:44:40 [vite] (client) hmr update /src/App.tsx +14:47:46 [vite] (client) hmr update /src/App.tsx +14:48:15 [vite] (client) hmr update /src/App.tsx +14:51:05 [vite] (client) hmr update /src/App.tsx +15:13:54 [vite] (client) hmr update /src/App.tsx +15:14:22 [vite] (client) hmr update /src/App.tsx +15:17:54 [vite] (client) hmr update /src/App.tsx +15:18:05 [vite] (client) hmr update /src/App.tsx +15:18:58 [vite] (client) hmr update /src/App.tsx