|
|
@@ -53,15 +53,34 @@ type ElementNode = {
|
|
|
rov_score: number | null;
|
|
|
};
|
|
|
|
|
|
+type RovScale = { min: number; max: number };
|
|
|
+
|
|
|
+type RovScales = {
|
|
|
+ category_by_level: Record<string, RovScale>;
|
|
|
+ element: RovScale;
|
|
|
+};
|
|
|
+
|
|
|
+type ElementCountInfo = {
|
|
|
+ total: number;
|
|
|
+ valid: number;
|
|
|
+};
|
|
|
+
|
|
|
type TreeResponse = {
|
|
|
dt: string;
|
|
|
available_dates: string[];
|
|
|
- category_min_rov_score: number;
|
|
|
- category_max_rov_score: number;
|
|
|
- element_min_rov_score: number;
|
|
|
- element_max_rov_score: number;
|
|
|
+ element_invalid_count: number;
|
|
|
+ element_counts_by_category: Record<string, ElementCountInfo>;
|
|
|
+ rov_scales: RovScales;
|
|
|
categories: CategoryNode[];
|
|
|
+};
|
|
|
+
|
|
|
+type CategoryElementsResponse = {
|
|
|
+ dt: string;
|
|
|
+ category_id: string;
|
|
|
elements: ElementNode[];
|
|
|
+ rov_scales: {
|
|
|
+ element: RovScale;
|
|
|
+ };
|
|
|
};
|
|
|
|
|
|
type LayoutKind = "category" | "element";
|
|
|
@@ -183,25 +202,6 @@ function buildChildrenMap(categories: CategoryNode[]): Map<string, CategoryNode[
|
|
|
return map;
|
|
|
}
|
|
|
|
|
|
-function buildElementsByCategory(elements: ElementNode[]): Map<string, ElementNode[]> {
|
|
|
- const map = new Map<string, ElementNode[]>();
|
|
|
- for (const element of elements) {
|
|
|
- const categoryId = element.stable_id;
|
|
|
- if (!categoryId) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- const siblings = map.get(categoryId) ?? [];
|
|
|
- siblings.push(element);
|
|
|
- map.set(categoryId, siblings);
|
|
|
- }
|
|
|
- for (const siblings of map.values()) {
|
|
|
- siblings.sort((a, b) =>
|
|
|
- a.element_id.localeCompare(b.element_id, undefined, { numeric: true }),
|
|
|
- );
|
|
|
- }
|
|
|
- return map;
|
|
|
-}
|
|
|
-
|
|
|
function isMissingParent(category: CategoryNode, allIds: Set<string>): boolean {
|
|
|
return !category.parent_stable_id || !allIds.has(category.parent_stable_id);
|
|
|
}
|
|
|
@@ -302,49 +302,53 @@ function isCategoryVisibleByExpandFilter(
|
|
|
return getEffectiveCategoryLevel(category, depthMap) <= levelFilter;
|
|
|
}
|
|
|
|
|
|
-type RovScale = { min: number; max: number };
|
|
|
-
|
|
|
-function buildCategoryRovScalesByLevel(
|
|
|
- categories: CategoryNode[],
|
|
|
- depthMap: Map<string, number>,
|
|
|
+function parseCategoryRovScalesByLevel(
|
|
|
+ scales: Record<string, RovScale> | undefined,
|
|
|
): Map<number, RovScale> {
|
|
|
- const scales = new Map<number, RovScale>();
|
|
|
- for (const category of categories) {
|
|
|
- if (isZeroRovScore(category.rov_score) || category.rov_score === null) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- const level = getEffectiveCategoryLevel(category, depthMap);
|
|
|
- const score = category.rov_score;
|
|
|
- const current = scales.get(level);
|
|
|
- if (!current) {
|
|
|
- scales.set(level, { min: score, max: score });
|
|
|
- continue;
|
|
|
- }
|
|
|
- current.min = Math.min(current.min, score);
|
|
|
- current.max = Math.max(current.max, score);
|
|
|
+ const map = new Map<number, RovScale>();
|
|
|
+ if (!scales) {
|
|
|
+ return map;
|
|
|
}
|
|
|
- return scales;
|
|
|
+ for (const [level, scale] of Object.entries(scales)) {
|
|
|
+ map.set(Number(level), scale);
|
|
|
+ }
|
|
|
+ return map;
|
|
|
}
|
|
|
|
|
|
-function buildElementRovScale(elements: ElementNode[]): RovScale {
|
|
|
- let min = 0;
|
|
|
- let max = 0;
|
|
|
- let initialized = false;
|
|
|
- for (const element of elements) {
|
|
|
- if (isZeroRovScore(element.rov_score) || element.rov_score === null) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- const score = element.rov_score;
|
|
|
- if (!initialized) {
|
|
|
- min = score;
|
|
|
- max = score;
|
|
|
- initialized = true;
|
|
|
- continue;
|
|
|
- }
|
|
|
- min = Math.min(min, score);
|
|
|
- max = Math.max(max, score);
|
|
|
+function getElementCountInfo(
|
|
|
+ counts: Record<string, ElementCountInfo> | undefined,
|
|
|
+ categoryId: string,
|
|
|
+): ElementCountInfo {
|
|
|
+ return counts?.[categoryId] ?? { total: 0, valid: 0 };
|
|
|
+}
|
|
|
+
|
|
|
+function getElementBadgeCount(
|
|
|
+ countInfo: ElementCountInfo,
|
|
|
+ loadedElements: ElementNode[] | undefined,
|
|
|
+ showInvalidNodes: boolean,
|
|
|
+): number {
|
|
|
+ if (loadedElements !== undefined) {
|
|
|
+ return showInvalidNodes
|
|
|
+ ? loadedElements.length
|
|
|
+ : loadedElements.filter((item) => !isZeroRovScore(item.rov_score)).length;
|
|
|
+ }
|
|
|
+ return showInvalidNodes ? countInfo.total : countInfo.valid;
|
|
|
+}
|
|
|
+
|
|
|
+function categoryHasVisibleElements(
|
|
|
+ countInfo: ElementCountInfo,
|
|
|
+ showInvalidNodes: boolean,
|
|
|
+): boolean {
|
|
|
+ return showInvalidNodes ? countInfo.total > 0 : countInfo.valid > 0;
|
|
|
+}
|
|
|
+
|
|
|
+function getTotalElementCount(
|
|
|
+ counts: Record<string, ElementCountInfo> | undefined,
|
|
|
+): number {
|
|
|
+ if (!counts) {
|
|
|
+ return 0;
|
|
|
}
|
|
|
- return { min, max };
|
|
|
+ return Object.values(counts).reduce((sum, item) => sum + item.total, 0);
|
|
|
}
|
|
|
|
|
|
function resolveNodeRovScale(
|
|
|
@@ -413,6 +417,7 @@ type TreeNodeCardProps = {
|
|
|
elementRovScale: RovScale;
|
|
|
categoryDepthMap: Map<string, number>;
|
|
|
active: boolean;
|
|
|
+ elementsLoading: boolean;
|
|
|
onToggleCategory: () => void;
|
|
|
onToggleElements: () => void;
|
|
|
};
|
|
|
@@ -423,6 +428,7 @@ function TreeNodeCard({
|
|
|
elementRovScale,
|
|
|
categoryDepthMap,
|
|
|
active,
|
|
|
+ elementsLoading,
|
|
|
onToggleCategory,
|
|
|
onToggleElements,
|
|
|
}: TreeNodeCardProps) {
|
|
|
@@ -533,13 +539,14 @@ function TreeNodeCard({
|
|
|
<button
|
|
|
type="button"
|
|
|
className={`cet-node-badge cet-node-badge--element${node.elementsExpanded ? " cet-node-badge--expanded" : ""}`}
|
|
|
+ disabled={elementsLoading}
|
|
|
onClick={(event) => {
|
|
|
event.stopPropagation();
|
|
|
onToggleElements();
|
|
|
}}
|
|
|
>
|
|
|
- {node.elementBadgeCount} 元素
|
|
|
- {node.elementsExpanded ? <CaretDownFilled /> : <CaretRightFilled />}
|
|
|
+ {elementsLoading ? "加载中" : `${node.elementBadgeCount} 元素`}
|
|
|
+ {!elementsLoading && (node.elementsExpanded ? <CaretDownFilled /> : <CaretRightFilled />)}
|
|
|
</button>
|
|
|
) : null}
|
|
|
</div>
|
|
|
@@ -554,6 +561,10 @@ export default function CategoryEffectTreeApp() {
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
const [error, setError] = useState("");
|
|
|
const [data, setData] = useState<TreeResponse | null>(null);
|
|
|
+ const [loadedElementsByCategory, setLoadedElementsByCategory] = useState<
|
|
|
+ Map<string, ElementNode[]>
|
|
|
+ >(new Map());
|
|
|
+ const [loadingElementCategoryId, setLoadingElementCategoryId] = useState<string | null>(null);
|
|
|
const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<string>>(new Set());
|
|
|
const [expandedElementParents, setExpandedElementParents] = useState<Set<string>>(new Set());
|
|
|
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
|
@@ -585,7 +596,18 @@ export default function CategoryEffectTreeApp() {
|
|
|
throw new Error(detail || `HTTP ${response.status}`);
|
|
|
}
|
|
|
const payload = (await response.json()) as TreeResponse;
|
|
|
- setData(payload);
|
|
|
+ setData({
|
|
|
+ ...payload,
|
|
|
+ categories: payload.categories ?? [],
|
|
|
+ element_counts_by_category: payload.element_counts_by_category ?? {},
|
|
|
+ element_invalid_count: payload.element_invalid_count ?? 0,
|
|
|
+ rov_scales: payload.rov_scales ?? {
|
|
|
+ category_by_level: {},
|
|
|
+ element: { min: 0, max: 0 },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ setLoadedElementsByCategory(new Map());
|
|
|
+ setLoadingElementCategoryId(null);
|
|
|
setAppliedDt(payload.dt);
|
|
|
setSelectedDate(dayjs(payload.dt, "YYYYMMDD"));
|
|
|
setExpandedCategoryIds(new Set());
|
|
|
@@ -598,11 +620,52 @@ export default function CategoryEffectTreeApp() {
|
|
|
queryError instanceof Error ? queryError.message : "查询失败,请重试",
|
|
|
);
|
|
|
setData(null);
|
|
|
+ setLoadedElementsByCategory(new Map());
|
|
|
+ setExpandedCategoryIds(new Set());
|
|
|
+ setExpandedElementParents(new Set());
|
|
|
+ setLoadingElementCategoryId(null);
|
|
|
} finally {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
}, []);
|
|
|
|
|
|
+ const fetchCategoryElements = useCallback(
|
|
|
+ async (categoryId: string, dt: string) => {
|
|
|
+ setLoadingElementCategoryId(categoryId);
|
|
|
+ try {
|
|
|
+ const resolvedBase = getResolvedApiBaseUrl();
|
|
|
+ const baseWithSlash = resolvedBase.endsWith("/")
|
|
|
+ ? resolvedBase
|
|
|
+ : `${resolvedBase}/`;
|
|
|
+ const url = new URL(
|
|
|
+ `vertical-category/categories/${encodeURIComponent(categoryId)}/elements`,
|
|
|
+ baseWithSlash,
|
|
|
+ );
|
|
|
+ url.searchParams.set("dt", dt);
|
|
|
+ const response = await fetch(url.toString(), {
|
|
|
+ method: "GET",
|
|
|
+ headers: { Accept: "application/json" },
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ const detail = await response.text();
|
|
|
+ throw new Error(detail || `HTTP ${response.status}`);
|
|
|
+ }
|
|
|
+ const payload = (await response.json()) as CategoryElementsResponse;
|
|
|
+ setLoadedElementsByCategory((prev) => {
|
|
|
+ const next = new Map(prev);
|
|
|
+ next.set(categoryId, payload.elements ?? []);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ return payload.elements ?? [];
|
|
|
+ } finally {
|
|
|
+ setLoadingElementCategoryId((current) =>
|
|
|
+ current === categoryId ? null : current,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [],
|
|
|
+ );
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
void fetchTree();
|
|
|
}, [fetchTree]);
|
|
|
@@ -611,10 +674,29 @@ export default function CategoryEffectTreeApp() {
|
|
|
() => buildChildrenMap(data?.categories ?? []),
|
|
|
[data?.categories],
|
|
|
);
|
|
|
- const elementsByCategory = useMemo(
|
|
|
- () => buildElementsByCategory(data?.elements ?? []),
|
|
|
- [data?.elements],
|
|
|
- );
|
|
|
+ const elementsByCategory = useMemo(() => {
|
|
|
+ const map = new Map<string, ElementNode[]>();
|
|
|
+ for (const [categoryId, elements] of loadedElementsByCategory.entries()) {
|
|
|
+ map.set(categoryId, elements);
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }, [loadedElementsByCategory]);
|
|
|
+ const elementLookup = useMemo(() => {
|
|
|
+ const map = new Map<string, ElementNode>();
|
|
|
+ for (const elements of loadedElementsByCategory.values()) {
|
|
|
+ for (const element of elements) {
|
|
|
+ map.set(element.element_id, element);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }, [loadedElementsByCategory]);
|
|
|
+ const categoryLookup = useMemo(() => {
|
|
|
+ const map = new Map<string, CategoryNode>();
|
|
|
+ for (const category of data?.categories ?? []) {
|
|
|
+ map.set(category.category_id, category);
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }, [data?.categories]);
|
|
|
const roots = useMemo(
|
|
|
() => findRootCategories(data?.categories ?? []),
|
|
|
[data?.categories],
|
|
|
@@ -756,7 +838,12 @@ export default function CategoryEffectTreeApp() {
|
|
|
): void => {
|
|
|
const layoutDepth = getLayoutDepth(category, categoryDepthMap);
|
|
|
const childCategories = childrenMap.get(category.category_id) ?? [];
|
|
|
- const allChildElements = elementsByCategory.get(category.category_id) ?? [];
|
|
|
+ const elementCountInfo = getElementCountInfo(
|
|
|
+ data.element_counts_by_category,
|
|
|
+ category.category_id,
|
|
|
+ );
|
|
|
+ const loadedChildElements = elementsByCategory.get(category.category_id);
|
|
|
+ const allChildElements = loadedChildElements ?? [];
|
|
|
const childElements = showInvalidNodes
|
|
|
? allChildElements
|
|
|
: allChildElements.filter((item) => !isZeroRovScore(item.rov_score));
|
|
|
@@ -765,19 +852,25 @@ export default function CategoryEffectTreeApp() {
|
|
|
: childCategories.filter((item) => !isZeroRovScore(item.rov_score));
|
|
|
|
|
|
const hasCategoryChildren = childCategories.length > 0;
|
|
|
- const hasElements = childElements.length > 0;
|
|
|
+ const hasElements = categoryHasVisibleElements(elementCountInfo, showInvalidNodes);
|
|
|
const categoryExpanded = expandedCategoryIds.has(category.category_id);
|
|
|
const elementsExpanded = expandedElementParents.has(category.category_id);
|
|
|
const showSelf = showInvalidNodes || !isZeroRovScore(category.rov_score);
|
|
|
const passThrough = !showSelf;
|
|
|
const traverseCategories = passThrough || categoryExpanded;
|
|
|
+ const elementBadgeCount = getElementBadgeCount(
|
|
|
+ elementCountInfo,
|
|
|
+ loadedChildElements,
|
|
|
+ showInvalidNodes,
|
|
|
+ );
|
|
|
|
|
|
const categoryChildKeys =
|
|
|
traverseCategories && hasCategoryChildren
|
|
|
? childCategories.map((child) => `category:${child.category_id}`)
|
|
|
: [];
|
|
|
const shouldShowElements =
|
|
|
- hasElements && (elementsExpanded || (passThrough && childElements.length > 0));
|
|
|
+ childElements.length > 0 &&
|
|
|
+ (elementsExpanded || (passThrough && childElements.length > 0));
|
|
|
const elementChildKeys = shouldShowElements
|
|
|
? childElements.map((element) => `element:${element.element_id}`)
|
|
|
: [];
|
|
|
@@ -793,7 +886,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
categoryExpanded,
|
|
|
elementsExpanded,
|
|
|
categoryBadgeCount: visibleChildCategories.length,
|
|
|
- elementBadgeCount: childElements.length,
|
|
|
+ elementBadgeCount,
|
|
|
});
|
|
|
linkParentKey = node.key;
|
|
|
}
|
|
|
@@ -801,7 +894,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
if (traverseCategories) {
|
|
|
for (const childKey of categoryChildKeys) {
|
|
|
const childId = childKey.slice("category:".length);
|
|
|
- const childCategory = data.categories.find((item) => item.category_id === childId);
|
|
|
+ const childCategory = categoryLookup.get(childId);
|
|
|
if (childCategory) {
|
|
|
buildVisibleTree(childCategory, linkParentKey);
|
|
|
}
|
|
|
@@ -815,9 +908,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
? Math.min(
|
|
|
...categoryChildKeys.map((childKey) => {
|
|
|
const childId = childKey.slice("category:".length);
|
|
|
- const childCategory = data.categories.find(
|
|
|
- (item) => item.category_id === childId,
|
|
|
- );
|
|
|
+ const childCategory = categoryLookup.get(childId);
|
|
|
return childCategory
|
|
|
? getLayoutDepth(childCategory, categoryDepthMap)
|
|
|
: parentLayoutDepth + 1;
|
|
|
@@ -826,7 +917,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
: parentLayoutDepth + 1;
|
|
|
for (const childKey of elementChildKeys) {
|
|
|
const childId = childKey.slice("element:".length);
|
|
|
- const childElement = data.elements.find((item) => item.element_id === childId);
|
|
|
+ const childElement = elementLookup.get(childId);
|
|
|
if (childElement) {
|
|
|
buildElementNode(childElement, elementLayoutDepth, linkParentKey);
|
|
|
}
|
|
|
@@ -988,7 +1079,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
canvasWidth: (maxDepth + 1) * (NODE_WIDTH + COLUMN_GAP) + CANVAS_PADDING * 2,
|
|
|
canvasHeight,
|
|
|
};
|
|
|
- }, [data, roots, detachedCategories, childrenMap, elementsByCategory, expandedCategoryIds, expandedElementParents, showInvalidNodes, expandLevelFilter, categoryDepthMap]);
|
|
|
+ }, [data, roots, detachedCategories, childrenMap, elementsByCategory, categoryLookup, elementLookup, expandedCategoryIds, expandedElementParents, showInvalidNodes, expandLevelFilter, categoryDepthMap]);
|
|
|
|
|
|
const fitToView = useCallback(() => {
|
|
|
const viewport = viewportRef.current;
|
|
|
@@ -1102,15 +1193,47 @@ export default function CategoryEffectTreeApp() {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- setExpandedElementParents((prev) => {
|
|
|
- const next = new Set(prev);
|
|
|
- if (next.has(category.category_id)) {
|
|
|
- next.delete(category.category_id);
|
|
|
- } else {
|
|
|
- next.add(category.category_id);
|
|
|
- }
|
|
|
- return next;
|
|
|
- });
|
|
|
+ const categoryId = category.category_id;
|
|
|
+ const willCollapse = expandedElementParents.has(categoryId);
|
|
|
+ if (willCollapse) {
|
|
|
+ setExpandedElementParents((prev) => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ next.delete(categoryId);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (loadingElementCategoryId === categoryId) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const expandCategory = () => {
|
|
|
+ setExpandedElementParents((prev) => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ next.add(categoryId);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ if (loadedElementsByCategory.has(categoryId)) {
|
|
|
+ expandCategory();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!appliedDt) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ void fetchCategoryElements(categoryId, appliedDt)
|
|
|
+ .then(() => {
|
|
|
+ expandCategory();
|
|
|
+ })
|
|
|
+ .catch((queryError) => {
|
|
|
+ setError(
|
|
|
+ queryError instanceof Error ? queryError.message : "元素加载失败,请重试",
|
|
|
+ );
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
const handleCollapseAll = () => {
|
|
|
@@ -1132,12 +1255,12 @@ export default function CategoryEffectTreeApp() {
|
|
|
};
|
|
|
|
|
|
const categoryRovScalesByLevel = useMemo(
|
|
|
- () => buildCategoryRovScalesByLevel(data?.categories ?? [], categoryDepthMap),
|
|
|
- [data?.categories, categoryDepthMap],
|
|
|
+ () => parseCategoryRovScalesByLevel(data?.rov_scales?.category_by_level),
|
|
|
+ [data?.rov_scales?.category_by_level],
|
|
|
);
|
|
|
const elementRovScale = useMemo(
|
|
|
- () => buildElementRovScale(data?.elements ?? []),
|
|
|
- [data?.elements],
|
|
|
+ () => data?.rov_scales?.element ?? { min: 0, max: 0 },
|
|
|
+ [data?.rov_scales?.element],
|
|
|
);
|
|
|
|
|
|
const invalidNodeCount = useMemo(() => {
|
|
|
@@ -1145,8 +1268,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
return 0;
|
|
|
}
|
|
|
const invalidCategories = data.categories.filter((item) => isZeroRovScore(item.rov_score)).length;
|
|
|
- const invalidElements = data.elements.filter((item) => isZeroRovScore(item.rov_score)).length;
|
|
|
- return invalidCategories + invalidElements;
|
|
|
+ return invalidCategories + (data.element_invalid_count ?? 0);
|
|
|
}, [data]);
|
|
|
|
|
|
return (
|
|
|
@@ -1227,7 +1349,7 @@ export default function CategoryEffectTreeApp() {
|
|
|
<div className="cet-stats">
|
|
|
<span>日期 {formatDtLabel(appliedDt)}</span>
|
|
|
<span>分类 {data?.categories.length ?? 0}</span>
|
|
|
- <span>元素 {data?.elements.length ?? 0}</span>
|
|
|
+ <span>元素 {getTotalElementCount(data?.element_counts_by_category)}</span>
|
|
|
<span>节点 {layoutNodes.length}</span>
|
|
|
</div>
|
|
|
) : null}
|
|
|
@@ -1343,6 +1465,11 @@ export default function CategoryEffectTreeApp() {
|
|
|
elementRovScale={elementRovScale}
|
|
|
categoryDepthMap={categoryDepthMap}
|
|
|
active={activeKey === node.key}
|
|
|
+ elementsLoading={
|
|
|
+ node.kind === "category" &&
|
|
|
+ node.category !== undefined &&
|
|
|
+ loadingElementCategoryId === node.category.category_id
|
|
|
+ }
|
|
|
onToggleCategory={() => handleToggleCategory(node)}
|
|
|
onToggleElements={() => handleToggleElements(node)}
|
|
|
/>
|