/** * 按业务域全链路展示的任务选择器(v2)。 * * 每个业务域一个折叠面板,内部按层分组展示完整链路: * ODS 任务 → DWD 表(该域的) → DWS/INDEX 任务 * * 功能: * - 同步检查:工具栏右侧 Badge 指示,点击展示差异 Modal * - 全选常用 / 全选 / 反选 / 清空 按钮 * - DWD 表勾选 = 选择要装载的 DWD 表(正向选择,和 ODS/DWS 一致) */ import React, { useEffect, useState, useMemo, useCallback } from "react"; import { Collapse, Checkbox, Spin, Alert, Button, Space, Typography, Tag, Badge, Modal, Tooltip, Divider, } from "antd"; import { CheckCircleOutlined, WarningOutlined, SyncOutlined, TableOutlined, } from "@ant-design/icons"; import type { CheckboxChangeEvent } from "antd/es/checkbox"; import { fetchTaskRegistry, fetchDwdTablesRich, checkTaskSync } from "../api/tasks"; import type { DwdTableItem as ApiDwdTableItem, SyncCheckResult } from "../api/tasks"; import type { TaskDefinition, DwdTableItem } from "../types"; const { Text } = Typography; /* 层排序 / 标签 / 颜色 */ const LAYER_ORDER: Record = { ODS: 0, DWD: 1, DWS: 2, INDEX: 3, UTILITY: 4 }; const LAYER_LABELS: Record = { ODS: "ODS 抽取", DWD: "DWD 装载", DWS: "DWS 汇总", INDEX: "DWS 指数", UTILITY: "工具", }; const LAYER_COLORS: Record = { ODS: "blue", DWD: "green", DWS: "orange", INDEX: "purple", UTILITY: "default", }; /* 域排序 */ const DOMAIN_ORDER: Record = { 助教: 0, 结算: 1, 台桌: 2, 会员: 3, 商品: 4, 团购: 5, 库存: 6, 财务: 7, 指数: 8, 通用: 9, 工具: 10, }; export interface TaskSelectorProps { layers: string[]; selectedTasks: string[]; onTasksChange: (tasks: string[]) => void; selectedDwdTables?: string[]; onDwdTablesChange?: (tables: string[]) => void; } interface DomainGroup { domain: string; layerTasks: { layer: string; tasks: TaskDefinition[] }[]; dwdTables: DwdTableItem[]; } /** 当 layers 包含 DWD 时,DWD_LOAD_FROM_ODS 由 DWD 表过滤区块隐含,不单独显示 */ const HIDDEN_WHEN_DWD_VISIBLE = new Set(["DWD_LOAD_FROM_ODS"]); /** 按域 + 层构建分组 */ function buildDomainGroups( registry: Record, dwdTableGroups: Record, layers: string[], ): DomainGroup[] { const hideDwdTasks = layers.includes("DWD"); const domainSet = new Set(); const tasksByDomainLayer = new Map>(); for (const tasks of Object.values(registry)) { for (const t of tasks) { if (!layers.includes(t.layer)) continue; if (hideDwdTasks && HIDDEN_WHEN_DWD_VISIBLE.has(t.code)) continue; domainSet.add(t.domain); if (!tasksByDomainLayer.has(t.domain)) tasksByDomainLayer.set(t.domain, new Map()); const layerMap = tasksByDomainLayer.get(t.domain)!; if (!layerMap.has(t.layer)) layerMap.set(t.layer, []); layerMap.get(t.layer)!.push(t); } } if (layers.includes("DWD")) { for (const domain of Object.keys(dwdTableGroups)) domainSet.add(domain); } const groups: DomainGroup[] = []; for (const domain of domainSet) { const layerMap = tasksByDomainLayer.get(domain) ?? new Map(); const layerTasks: { layer: string; tasks: TaskDefinition[] }[] = []; const sortedLayers = [...layerMap.keys()].sort( (a, b) => (LAYER_ORDER[a] ?? 99) - (LAYER_ORDER[b] ?? 99), ); for (const layer of sortedLayers) { const tasks = layerMap.get(layer)!; tasks.sort((a, b) => (a.is_common === b.is_common ? 0 : a.is_common ? -1 : 1)); layerTasks.push({ layer, tasks }); } const dwdTables = layers.includes("DWD") ? (dwdTableGroups[domain] ?? []) : []; if (layerTasks.length > 0 || dwdTables.length > 0) { groups.push({ domain, layerTasks, dwdTables }); } } groups.sort((a, b) => (DOMAIN_ORDER[a.domain] ?? 99) - (DOMAIN_ORDER[b.domain] ?? 99)); return groups; } const TaskSelector: React.FC = ({ layers, selectedTasks, onTasksChange, selectedDwdTables = [], onDwdTablesChange, }) => { const [registry, setRegistry] = useState>({}); const [dwdTableGroups, setDwdTableGroups] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [syncResult, setSyncResult] = useState(null); const [syncLoading, setSyncLoading] = useState(false); const [syncModalOpen, setSyncModalOpen] = useState(false); /* 加载数据 */ useEffect(() => { let cancelled = false; setLoading(true); setError(null); const promises: Promise[] = [ fetchTaskRegistry() .then((data) => { if (!cancelled) setRegistry(data); }) .catch((err) => { if (!cancelled) setError(err?.message ?? "获取任务列表失败"); }), ]; if (layers.includes("DWD")) { promises.push( fetchDwdTablesRich() .then((data) => { if (cancelled) return; const converted: Record = {}; for (const [domain, items] of Object.entries(data)) { converted[domain] = items.map((item: ApiDwdTableItem) => ({ table_name: item.table_name, display_name: item.display_name, domain: item.domain, ods_source: item.ods_source, is_dimension: item.is_dimension, })); } setDwdTableGroups(converted); }) .catch(() => {}), ); } Promise.all(promises).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [layers]); /* 首次加载后自动同步检查 */ useEffect(() => { if (Object.keys(registry).length > 0) handleSyncCheck(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [registry]); /* CHANGE [2026-02-19] intent: DWD 表正向勾选,加载后默认全选 */ useEffect(() => { if (!onDwdTablesChange) return; const allTables = Object.values(dwdTableGroups).flat().map((t) => t.table_name); if (allTables.length > 0 && selectedDwdTables.length === 0) { onDwdTablesChange(allTables); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dwdTableGroups]); const domainGroups = useMemo( () => buildDomainGroups(registry, dwdTableGroups, layers), [registry, dwdTableGroups, layers], ); const allVisibleCodes = useMemo( () => domainGroups.flatMap((g) => g.layerTasks.flatMap((lt) => lt.tasks.map((t) => t.code))), [domainGroups], ); const allCommonCodes = useMemo( () => domainGroups.flatMap((g) => g.layerTasks.flatMap((lt) => lt.tasks.filter((t) => t.is_common).map((t) => t.code)), ), [domainGroups], ); /* 同步检查 */ const handleSyncCheck = useCallback(async () => { setSyncLoading(true); try { setSyncResult(await checkTaskSync()); } catch { setSyncResult({ in_sync: false, backend_only: [], etl_only: [], error: "检查失败" }); } finally { setSyncLoading(false); } }, []); /* 任务选择 */ const handleSelectAll = useCallback(() => onTasksChange(allVisibleCodes), [allVisibleCodes, onTasksChange]); const handleSelectCommon = useCallback(() => onTasksChange(allCommonCodes), [allCommonCodes, onTasksChange]); const handleInvert = useCallback(() => { const set = new Set(selectedTasks); onTasksChange(allVisibleCodes.filter((c) => !set.has(c))); }, [allVisibleCodes, selectedTasks, onTasksChange]); const handleClear = useCallback(() => onTasksChange([]), [onTasksChange]); const handleDomainToggle = useCallback( (group: DomainGroup, checked: boolean) => { const codes = new Set(group.layerTasks.flatMap((lt) => lt.tasks.map((t) => t.code))); if (checked) { const merged = new Set(selectedTasks); codes.forEach((c) => merged.add(c)); onTasksChange([...merged]); } else { onTasksChange(selectedTasks.filter((c) => !codes.has(c))); } }, [selectedTasks, onTasksChange], ); const handleTaskToggle = useCallback( (code: string, checked: boolean) => { onTasksChange(checked ? [...selectedTasks, code] : selectedTasks.filter((c) => c !== code)); }, [selectedTasks, onTasksChange], ); /* DWD 表选择 */ const handleDwdTableToggle = useCallback( (tableName: string, checked: boolean) => { if (!onDwdTablesChange) return; onDwdTablesChange(checked ? [...selectedDwdTables, tableName] : selectedDwdTables.filter((t) => t !== tableName)); }, [selectedDwdTables, onDwdTablesChange], ); const handleDwdDomainToggle = useCallback( (tables: DwdTableItem[], checked: boolean) => { if (!onDwdTablesChange) return; const names = new Set(tables.map((t) => t.table_name)); if (checked) { const merged = new Set(selectedDwdTables); names.forEach((n) => merged.add(n)); onDwdTablesChange([...merged]); } else { onDwdTablesChange(selectedDwdTables.filter((t) => !names.has(t))); } }, [selectedDwdTables, onDwdTablesChange], ); /* 渲染 */ if (loading) return ; if (error) return ; if (domainGroups.length === 0) return 当前 Flow 无可选任务; const selectedCount = selectedTasks.filter((c) => allVisibleCodes.includes(c)).length; const showDwdFilter = layers.includes("DWD") && !!onDwdTablesChange; /** 渲染某个域下的 DWD 表过滤区块 */ const renderDwdTableFilter = (dwdTables: DwdTableItem[]) => { if (!showDwdFilter || dwdTables.length === 0) return null; const domainDwdSelected = selectedDwdTables.filter((t) => dwdTables.some((d) => d.table_name === t)); return (
DWD 装载表 {`${domainDwdSelected.length}/${dwdTables.length}`}
{dwdTables.map((dt) => (
handleDwdTableToggle(dt.table_name, e.target.checked)} > {dt.table_name} {dt.display_name} {dt.is_dimension && 维度}
))}
); }; return (
{/* 工具栏 */}
已选 {selectedCount} / {allVisibleCodes.length} {syncLoading ? ( ) : syncResult === null ? ( ) : syncResult.in_sync ? ( ) : ( )}
{/* 域折叠面板 */} g.domain !== "工具" && g.domain !== "通用").map((g) => g.domain)} items={domainGroups.map((group) => { const domainCodes = group.layerTasks.flatMap((lt) => lt.tasks.map((t) => t.code)); const domainSelected = selectedTasks.filter((c) => domainCodes.includes(c)); const allChecked = domainCodes.length > 0 && domainSelected.length === domainCodes.length; const indeterminate = domainSelected.length > 0 && !allChecked; return { key: group.domain, label: ( e.stopPropagation()}> handleDomainToggle(group, e.target.checked)} style={{ marginRight: 8 }} /> {group.domain} ({domainSelected.length}/{domainCodes.length}) ), children: (
{(() => { /* 找到 DWD 表过滤应插入的位置:ODS 之后、DWS/INDEX 之前 */ const hasDwdLayer = group.layerTasks.some((lt) => lt.layer === "DWD"); const shouldInsertDwd = !hasDwdLayer && group.dwdTables.length > 0 && showDwdFilter; /* 插入点:第一个 DWS/INDEX/UTILITY 层之前,若全是 ODS 则在末尾 */ const insertIdx = shouldInsertDwd ? group.layerTasks.findIndex((lt) => (LAYER_ORDER[lt.layer] ?? 99) >= (LAYER_ORDER["DWS"] ?? 2)) : -1; const effectiveInsertIdx = shouldInsertDwd && insertIdx === -1 ? group.layerTasks.length : insertIdx; const elements: React.ReactNode[] = []; group.layerTasks.forEach((lt, idx) => { /* 在此位置插入 DWD 表过滤 */ if (shouldInsertDwd && idx === effectiveInsertIdx) { elements.push(
{elements.length > 0 && }
DWD 装载
{renderDwdTableFilter(group.dwdTables)}
, ); } elements.push(
{elements.length > 0 && }
{LAYER_LABELS[lt.layer] ?? lt.layer}
{lt.tasks.map((t) => (
handleTaskToggle(t.code, e.target.checked)} > {t.code} {t.name} {t.description && (t.layer === "DWS" || t.layer === "INDEX") && ( ({t.description}) )} {!t.is_common && 不常用}
))}
{/* DWD 表过滤紧跟 DWD 层任务 */} {lt.layer === "DWD" && renderDwdTableFilter(group.dwdTables)}
, ); }); /* 所有层遍历完后,若插入点在末尾 */ if (shouldInsertDwd && effectiveInsertIdx >= group.layerTasks.length) { elements.push(
{elements.length > 0 && }
DWD 装载
{renderDwdTableFilter(group.dwdTables)}
, ); } return elements; })()}
), }; })} /> {/* 同步差异 Modal */} setSyncModalOpen(false)} footer={[ , , ]} > {syncResult?.error ? ( ) : (
{syncResult?.backend_only && syncResult.backend_only.length > 0 && (
后端有但 ETL 无({syncResult.backend_only.length}):
{syncResult.backend_only.map((code) => ( {code} ))}
)} {syncResult?.etl_only && syncResult.etl_only.length > 0 && (
ETL 有但后端无({syncResult.etl_only.length}):
{syncResult.etl_only.map((code) => ( {code} ))}
)} {syncResult?.in_sync && ( )}
)}
); }; export default TaskSelector;