Files
Neo-ZQYY/apps/admin-web/src/components/TaskSelector.tsx

461 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 按业务域全链路展示的任务选择器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<string, number> = { ODS: 0, DWD: 1, DWS: 2, INDEX: 3, UTILITY: 4 };
const LAYER_LABELS: Record<string, string> = {
ODS: "ODS 抽取", DWD: "DWD 装载", DWS: "DWS 汇总", INDEX: "DWS 指数", UTILITY: "工具",
};
const LAYER_COLORS: Record<string, string> = {
ODS: "blue", DWD: "green", DWS: "orange", INDEX: "purple", UTILITY: "default",
};
/* 域排序 */
const DOMAIN_ORDER: Record<string, number> = {
助教: 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<string, TaskDefinition[]>,
dwdTableGroups: Record<string, DwdTableItem[]>,
layers: string[],
): DomainGroup[] {
const hideDwdTasks = layers.includes("DWD");
const domainSet = new Set<string>();
const tasksByDomainLayer = new Map<string, Map<string, TaskDefinition[]>>();
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<string, TaskDefinition[]>();
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<TaskSelectorProps> = ({
layers, selectedTasks, onTasksChange,
selectedDwdTables = [], onDwdTablesChange,
}) => {
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, DwdTableItem[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<SyncCheckResult | null>(null);
const [syncLoading, setSyncLoading] = useState(false);
const [syncModalOpen, setSyncModalOpen] = useState(false);
/* 加载数据 */
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const promises: Promise<void>[] = [
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<string, DwdTableItem[]> = {};
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 <Spin tip="加载任务列表…" />;
if (error) return <Alert type="error" message="加载失败" description={error} />;
if (domainGroups.length === 0) return <Text type="secondary"> Flow </Text>;
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 (
<div style={{
marginTop: 6, marginLeft: 4, padding: "6px 8px",
background: "#f6ffed", borderRadius: 4, border: "1px solid #d9f7be",
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
<Space size={4}>
<TableOutlined style={{ color: "#52c41a", fontSize: 12 }} />
<Text style={{ fontSize: 12, fontWeight: 500 }}>DWD </Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{`${domainDwdSelected.length}/${dwdTables.length}`}
</Text>
</Space>
<Space size={4}>
<Button size="small" type="link" style={{ padding: 0, fontSize: 11, height: "auto" }}
onClick={() => handleDwdDomainToggle(dwdTables, true)}></Button>
<Button size="small" type="link" style={{ padding: 0, fontSize: 11, height: "auto" }}
onClick={() => handleDwdDomainToggle(dwdTables, false)}></Button>
</Space>
</div>
{dwdTables.map((dt) => (
<div key={dt.table_name} style={{ padding: "1px 0" }}>
<Checkbox
checked={selectedDwdTables.includes(dt.table_name)}
onChange={(e) => handleDwdTableToggle(dt.table_name, e.target.checked)}
>
<Text style={{ fontSize: 12 }}>{dt.table_name}</Text>
<Text type="secondary" style={{ marginLeft: 6, fontSize: 11 }}>{dt.display_name}</Text>
{dt.is_dimension && <Tag color="cyan" style={{ marginLeft: 4, fontSize: 10, lineHeight: "16px" }}></Tag>}
</Checkbox>
</div>
))}
</div>
);
};
return (
<div>
{/* 工具栏 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<Space size={4} wrap>
<Button size="small" onClick={handleSelectCommon}></Button>
<Button size="small" onClick={handleSelectAll}></Button>
<Button size="small" onClick={handleInvert}></Button>
<Button size="small" onClick={handleClear}></Button>
<Text type="secondary" style={{ marginLeft: 4 }}> {selectedCount} / {allVisibleCodes.length}</Text>
</Space>
<Tooltip title="对比后端注册表与 ETL 真实任务列表">
{syncLoading ? (
<Button size="small" icon={<SyncOutlined spin />} disabled></Button>
) : syncResult === null ? (
<Button size="small" icon={<SyncOutlined />} onClick={handleSyncCheck}></Button>
) : syncResult.in_sync ? (
<Button size="small" icon={<CheckCircleOutlined />} style={{ color: "#52c41a", borderColor: "#b7eb8f" }} onClick={handleSyncCheck}></Button>
) : (
<Badge dot>
<Button size="small" danger icon={<WarningOutlined />} onClick={() => setSyncModalOpen(true)}></Button>
</Badge>
)}
</Tooltip>
</div>
{/* 域折叠面板 */}
<Collapse
defaultActiveKey={domainGroups.filter((g) => 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: (
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
indeterminate={indeterminate} checked={allChecked}
onChange={(e: CheckboxChangeEvent) => handleDomainToggle(group, e.target.checked)}
style={{ marginRight: 8 }}
/>
{group.domain}
<Text type="secondary" style={{ marginLeft: 4 }}>({domainSelected.length}/{domainCodes.length})</Text>
</span>
),
children: (
<div>
{(() => {
/* 找到 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(
<div key="__dwd_filter__">
{elements.length > 0 && <Divider style={{ margin: "6px 0" }} />}
<div style={{ marginBottom: 4 }}>
<Tag color="green" style={{ fontSize: 11 }}>DWD </Tag>
</div>
{renderDwdTableFilter(group.dwdTables)}
</div>,
);
}
elements.push(
<div key={lt.layer}>
{elements.length > 0 && <Divider style={{ margin: "6px 0" }} />}
<div style={{ marginBottom: 4 }}>
<Tag color={LAYER_COLORS[lt.layer] ?? "default"} style={{ fontSize: 11 }}>
{LAYER_LABELS[lt.layer] ?? lt.layer}
</Tag>
</div>
<div style={{ paddingLeft: 4 }}>
{lt.tasks.map((t) => (
<div key={t.code} style={{ padding: "2px 0" }}>
<Checkbox
checked={selectedTasks.includes(t.code)}
onChange={(e) => handleTaskToggle(t.code, e.target.checked)}
>
<Text strong style={!t.is_common ? { color: "#999" } : undefined}>{t.code}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{t.name}</Text>
{t.description && (t.layer === "DWS" || t.layer === "INDEX") && (
<Text type="secondary" style={{ marginLeft: 6, fontSize: 10, color: "#8c8c8c" }}>({t.description})</Text>
)}
{!t.is_common && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}></Tag>}
</Checkbox>
</div>
))}
</div>
{/* DWD 表过滤紧跟 DWD 层任务 */}
{lt.layer === "DWD" && renderDwdTableFilter(group.dwdTables)}
</div>,
);
});
/* 所有层遍历完后,若插入点在末尾 */
if (shouldInsertDwd && effectiveInsertIdx >= group.layerTasks.length) {
elements.push(
<div key="__dwd_filter__">
{elements.length > 0 && <Divider style={{ margin: "6px 0" }} />}
<div style={{ marginBottom: 4 }}>
<Tag color="green" style={{ fontSize: 11 }}>DWD </Tag>
</div>
{renderDwdTableFilter(group.dwdTables)}
</div>,
);
}
return elements;
})()}
</div>
),
};
})}
/>
{/* 同步差异 Modal */}
<Modal
title="任务注册表同步检查"
open={syncModalOpen}
onCancel={() => setSyncModalOpen(false)}
footer={[
<Button key="refresh" icon={<SyncOutlined />} onClick={() => { handleSyncCheck(); }}></Button>,
<Button key="close" type="primary" onClick={() => setSyncModalOpen(false)}></Button>,
]}
>
{syncResult?.error ? (
<Alert type="error" message="检查出错" description={syncResult.error} />
) : (
<div>
{syncResult?.backend_only && syncResult.backend_only.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Text strong style={{ color: "#faad14" }}> ETL {syncResult.backend_only.length}</Text>
<div style={{ marginTop: 4 }}>
{syncResult.backend_only.map((code) => (
<Tag key={code} color="warning" style={{ marginBottom: 4 }}>{code}</Tag>
))}
</div>
</div>
)}
{syncResult?.etl_only && syncResult.etl_only.length > 0 && (
<div>
<Text strong style={{ color: "#ff4d4f" }}>ETL {syncResult.etl_only.length}</Text>
<div style={{ marginTop: 4 }}>
{syncResult.etl_only.map((code) => (
<Tag key={code} color="error" style={{ marginBottom: 4 }}>{code}</Tag>
))}
</div>
</div>
)}
{syncResult?.in_sync && (
<Alert type="success" message="后端与 ETL 任务列表完全一致" />
)}
</div>
)}
</Modal>
</div>
);
};
export default TaskSelector;