优化
This commit is contained in:
parent
a891b5bccb
commit
cfc6865986
3
bun.lock
3
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=="],
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
646
src/App.tsx
646
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<string, unknown> => !!row && typeof row === 'object');
|
||||
const hasRegionShape = sourceRows.some((row) =>
|
||||
readText(row, regionFieldKeys.provinceName) &&
|
||||
readText(row, regionFieldKeys.cityName) &&
|
||||
readText(row, regionFieldKeys.districtName),
|
||||
);
|
||||
if (!hasRegionShape) return [];
|
||||
|
||||
const provinceMap = new Map<string, {
|
||||
id: string;
|
||||
label: string;
|
||||
cityMap: Map<string, {
|
||||
id: string;
|
||||
label: string;
|
||||
districts: Map<string, TreeNode>;
|
||||
}>;
|
||||
}>();
|
||||
|
||||
sourceRows.forEach((row) => {
|
||||
const provinceLabel = readText(row, regionFieldKeys.provinceName);
|
||||
const cityLabel = readText(row, regionFieldKeys.cityName);
|
||||
const districtLabel = readText(row, regionFieldKeys.districtName);
|
||||
if (!provinceLabel || !cityLabel || !districtLabel) return;
|
||||
|
||||
const provinceId = `region:province:${readText(row, regionFieldKeys.provinceId) || provinceLabel}`;
|
||||
const cityId = `region:city:${provinceLabel}:${readText(row, regionFieldKeys.cityId) || cityLabel}`;
|
||||
const districtId = `region:district:${readText(row, regionFieldKeys.districtId) || `${provinceLabel}:${cityLabel}:${districtLabel}`}`;
|
||||
|
||||
const province = provinceMap.get(provinceId) ?? {
|
||||
id: provinceId,
|
||||
label: provinceLabel,
|
||||
cityMap: new Map<string, { id: string; label: string; districts: Map<string, TreeNode> }>(),
|
||||
};
|
||||
const city = province.cityMap.get(cityId) ?? {
|
||||
id: cityId,
|
||||
label: cityLabel,
|
||||
districts: new Map<string, TreeNode>(),
|
||||
};
|
||||
|
||||
city.districts.set(districtId, createFilterTreeNode(districtId, districtLabel));
|
||||
province.cityMap.set(cityId, city);
|
||||
provinceMap.set(provinceId, province);
|
||||
});
|
||||
|
||||
return Array.from(provinceMap.values()).map((province) => {
|
||||
const cityNodes = Array.from(province.cityMap.values()).map((city) =>
|
||||
createFilterTreeNode(city.id, city.label, Array.from(city.districts.values()), false),
|
||||
);
|
||||
return createFilterTreeNode(province.id, province.label, cityNodes, true);
|
||||
});
|
||||
}
|
||||
|
||||
function updateNode(nodes: TreeNode[], nodeId: string, updater: (node: TreeNode) => TreeNode): TreeNode[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) return updater(node);
|
||||
@ -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<string>,
|
||||
onToggle: (nodeId: string) => void,
|
||||
onSelect: (node: TreeNode) => void,
|
||||
depth = 0,
|
||||
) {
|
||||
return (
|
||||
<ul className="content-tree-list filter-tree-list" role={depth === 0 ? 'tree' : 'group'}>
|
||||
{nodes.map((node) => {
|
||||
const selected = selectedNodeKeys.has(getFilterSelectionKey(filterKey, node.id));
|
||||
|
||||
return (
|
||||
<li className="content-tree-node" role="treeitem" aria-expanded={node.hasChildren ? node.expanded : undefined} key={node.id}>
|
||||
<div className="content-tree-row filter-tree-row" style={{ paddingLeft: 8 + depth * 18 }}>
|
||||
{node.hasChildren ? (
|
||||
<button className="content-tree-caret" type="button" aria-label={node.expanded ? '收起' : '展开'} onClick={() => onToggle(node.id)}>
|
||||
{node.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="content-tree-caret is-leaf" />
|
||||
)}
|
||||
<button
|
||||
className="content-tree-select filter-tree-select"
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(node)}
|
||||
title={node.label}
|
||||
>
|
||||
<span className="filter-tree-check" aria-hidden="true" />
|
||||
<span className="content-tree-label">{node.label}</span>
|
||||
</button>
|
||||
{node.loading ? <span className="content-tree-loading">加载中</span> : null}
|
||||
</div>
|
||||
{node.expanded && node.children.length > 0
|
||||
? renderFilterTreeNodes(node.children, filterKey, selectedNodeKeys, onToggle, onSelect, depth + 1)
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const workspaceRef = useRef<HTMLElement>(null);
|
||||
const chartFrameRef = useRef<HTMLDivElement>(null);
|
||||
@ -349,6 +515,13 @@ function App() {
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
});
|
||||
const filterTreeInitialLoadStartedRef = useRef<Record<FilterKey, boolean>>({
|
||||
region: false,
|
||||
geoLocation: false,
|
||||
facilityType: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
});
|
||||
const [statisticKey, setStatisticKey] = useState<StatisticKey>('avgValue');
|
||||
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
|
||||
const [groupKey, setGroupKey] = useState<GroupKey>('year');
|
||||
@ -379,11 +552,89 @@ function App() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loadingHint, setLoadingHint] = useState('');
|
||||
const [filterTreeByKey, setFilterTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
|
||||
region: [],
|
||||
geoLocation: [],
|
||||
facilityType: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
});
|
||||
const [filterTreeLoadingByKey, setFilterTreeLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||
region: false,
|
||||
geoLocation: false,
|
||||
facilityType: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
});
|
||||
const [filterTreeErrorByKey, setFilterTreeErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||
region: null,
|
||||
geoLocation: null,
|
||||
facilityType: null,
|
||||
constructionStage: null,
|
||||
planningForm: null,
|
||||
});
|
||||
const [filterSearchTreeByKey, setFilterSearchTreeByKey] = useState<Record<FilterKey, TreeNode[]>>({
|
||||
region: [],
|
||||
geoLocation: [],
|
||||
facilityType: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
});
|
||||
const [filterSearchLoadingByKey, setFilterSearchLoadingByKey] = useState<Record<FilterKey, boolean>>({
|
||||
region: false,
|
||||
geoLocation: false,
|
||||
facilityType: false,
|
||||
constructionStage: false,
|
||||
planningForm: false,
|
||||
});
|
||||
const [filterSearchErrorByKey, setFilterSearchErrorByKey] = useState<Record<FilterKey, string | null>>({
|
||||
region: null,
|
||||
geoLocation: null,
|
||||
facilityType: null,
|
||||
constructionStage: null,
|
||||
planningForm: null,
|
||||
});
|
||||
const [appliedFilters, setAppliedFilters] = useState<Record<FilterKey, SelectedFilterNode[]>>({
|
||||
region: [],
|
||||
geoLocation: [],
|
||||
facilityType: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
});
|
||||
const [filterModalKey, setFilterModalKey] = useState<FilterKey | null>(null);
|
||||
const [draftFilterNodes, setDraftFilterNodes] = useState<SelectedFilterNode[]>([]);
|
||||
const [filterSearchValue, setFilterSearchValue] = useState('');
|
||||
const filterSearchComposingRef = useRef(false);
|
||||
const filterSearchTimerRef = useRef<number | null>(null);
|
||||
const filterSearchRequestSeqRef = useRef<Record<FilterKey, number>>({
|
||||
region: 0,
|
||||
geoLocation: 0,
|
||||
facilityType: 0,
|
||||
constructionStage: 0,
|
||||
planningForm: 0,
|
||||
});
|
||||
const lastFilterSearchRef = useRef('');
|
||||
|
||||
const selectedStatistic = statisticOptions.find((option) => option.key === statisticKey) ?? statisticOptions[0];
|
||||
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
|
||||
const activeContent = contentOptions.find((option) => option.key === activeContentKey) ?? contentOptions[0];
|
||||
const activeTree = treeByContent[activeContentKey];
|
||||
const activeFilter = filterOptions.find((option) => option.key === filterModalKey);
|
||||
const activeFilterTree = filterModalKey ? filterTreeByKey[filterModalKey] : [];
|
||||
const trimmedFilterSearchValue = filterSearchValue.trim();
|
||||
const activeFilterDisplayTree = filterModalKey && trimmedFilterSearchValue ? filterSearchTreeByKey[filterModalKey] : activeFilterTree;
|
||||
const activeFilterTreeLoading = filterModalKey
|
||||
? trimmedFilterSearchValue ? filterSearchLoadingByKey[filterModalKey] : filterTreeLoadingByKey[filterModalKey]
|
||||
: false;
|
||||
const activeFilterTreeError = filterModalKey
|
||||
? trimmedFilterSearchValue ? filterSearchErrorByKey[filterModalKey] : filterTreeErrorByKey[filterModalKey]
|
||||
: null;
|
||||
const activeFilterCount = Object.values(appliedFilters).reduce((total, nodes) => total + nodes.length, 0);
|
||||
const chartEmptyText = selectedContentNodes.length === 0
|
||||
? '请选择右侧分类项'
|
||||
: activeFilterCount > 0
|
||||
? '当前筛选无数据'
|
||||
: '所选分类暂无数据';
|
||||
const selectedValueKey = metricKey === 'dataCount' ? 'dataCount' : statisticKey;
|
||||
const requestMetricKey = metricKey === 'dataCount' ? 'cost' : metricKey;
|
||||
const seriesValueLabel = metricKey === 'dataCount' ? selectedMetric.label : selectedStatistic.label;
|
||||
@ -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 (
|
||||
<main className="dashboard-shell">
|
||||
@ -966,6 +1491,51 @@ function App() {
|
||||
</div>
|
||||
|
||||
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
|
||||
<div className="chart-filter-bar chart-filter-bar--workspace" aria-label="筛选条件">
|
||||
{filterOptions.map((option) => {
|
||||
const count = appliedFilters[option.key].length;
|
||||
const FilterIcon = option.icon;
|
||||
return (
|
||||
<button
|
||||
className="chart-filter-button"
|
||||
type="button"
|
||||
key={option.key}
|
||||
aria-pressed={count > 0}
|
||||
title={count > 0 ? `${option.label}:已选${count}项` : option.label}
|
||||
onClick={() => openFilterModal(option.key)}
|
||||
>
|
||||
<FilterIcon className="chart-filter-icon" aria-hidden="true" strokeWidth={2} />
|
||||
<span>{option.label}</span>
|
||||
{count > 0 ? <strong>{count}</strong> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{activeFilterCount > 0 ? (
|
||||
<button
|
||||
className="chart-filter-clear"
|
||||
type="button"
|
||||
title="清空全部筛选"
|
||||
onClick={() => {
|
||||
setAppliedFilters({
|
||||
region: [],
|
||||
geoLocation: [],
|
||||
facilityType: [],
|
||||
constructionStage: [],
|
||||
planningForm: [],
|
||||
});
|
||||
setChartDataBySelection({});
|
||||
setLoadError(null);
|
||||
if (selectedContentNodes.length > 0) {
|
||||
setLoadingHint('正在按筛选条件重新计算');
|
||||
setLoading(true);
|
||||
}
|
||||
setChartQueryVersion((version) => version + 1);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<section className="chart-area" aria-label="年度总费用图表">
|
||||
<div className="chart-frame" ref={chartFrameRef}>
|
||||
{statisticMenuOpen ? (
|
||||
@ -1064,6 +1634,72 @@ 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([])}>
|
||||
清空当前
|
||||
</button>
|
||||
<button className="filter-modal-cancel" type="button" onClick={closeFilterModal}>
|
||||
取消
|
||||
</button>
|
||||
<button className="filter-modal-confirm" type="button" onClick={applyFilterModal}>
|
||||
确认
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
267
src/styles.css
267
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;
|
||||
|
||||
76
vite-dev.log
76
vite-dev.log
@ -113,3 +113,79 @@ Port 5173 is in use, trying another one...
|
||||
[2m10:04:35[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m10:06:06[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:06:25[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m14:21:06[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:26:18[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:26:51[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m14:28:15[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:28:24[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:07:03[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:07:31[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:08:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:13:59[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m16:47:31[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m16:48:22[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m16:48:32[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m17:00:07[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m17:15:44[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:06:45[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:07:01[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:07:22[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:07:43[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:08:03[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:08:20[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:08:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:09:09[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:09:41[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m18:10:20[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m18:11:00[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:08:22[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:09:58[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:10:10[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:12:48[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:24:09[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:24:54[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:25:33[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:25:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:31:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:32:05[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:32:12[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m09:32:32[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m09:45:56[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:00:51[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:01:08[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:01:23[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:01:36[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:01:46[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:01:56[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:02:58[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:08:33[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:08:56[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:09:20[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m10:09:29[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m10:12:15[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/styles.css[22m
|
||||
[2m10:17:02[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:54:20[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:54:44[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m10:59:54[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:00:16[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:00:32[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:00:55[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:12:15[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:16:52[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:20:34[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:22:39[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:30:32[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:42:14[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m11:48:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m12:07:11[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m12:07:25[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m12:07:50[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:44:40[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:47:46[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:48:15[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m14:51:05[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:13:54[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:14:22[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:17:54[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:18:05[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
[2m15:18:58[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [32mhmr update [39m[2m/src/App.tsx[22m
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user