feat: TaskSelector v2 全链路展示 + 同步检查 + MCP Server + 服务器 Git 排除

- admin-web: TaskSelector 重构为按域+层全链路展示,新增同步检查功能
- admin-web: TaskConfig 动态加载 Flow/处理模式定义,DWD 表过滤内嵌域面板
- admin-web: App hydrate 完成前显示 loading,避免误跳 /login
- backend: 新增 /tasks/sync-check 对比后端与 ETL 真实注册表
- backend: 新增 /tasks/flows 返回 Flow 和处理模式定义
- apps/mcp-server: 新增 MCP Server 模块(百炼 AI PostgreSQL 只读查询)
- scripts/server: 新增 setup-server-git.py + server-exclude.txt
- docs: 更新 LAUNCH-CHECKLIST 添加 Git 排除配置步骤
- pyproject.toml: workspace members 新增 mcp-server
This commit is contained in:
Neo
2026-02-19 10:31:16 +08:00
parent 4eac07da47
commit 254ccb1e77
16 changed files with 2375 additions and 1285 deletions

View File

@@ -1,307 +1,445 @@
/**
* 按业务域分组的任务选择器。
* 按业务域全链路展示的任务选择器v2
*
* 从 /api/tasks/registry 获取任务注册表,按业务域折叠展示,
* 支持全选/反选和按 Flow 层级过滤。
* 当 Flow 包含 DWD 层时,在 DWD 任务下方内嵌表过滤子选项。
* 每个业务域一个折叠面板,内部按层分组展示完整链路:
* ODS 任务 → DWD 表(该域的) → DWS/INDEX 任务
*
* 功能:
* - 同步检查:工具栏右侧 Badge 指示,点击展示差异 Modal
* - 全选常用 / 全选 / 反选 / 清空 按钮
* - DWD 表选中 = 过滤 DWD_LOAD_FROM_ODS 的装载范围
*/
import React, { useEffect, useState, useMemo, useCallback } from "react";
import {
Collapse,
Checkbox,
Spin,
Alert,
Button,
Space,
Typography,
Tag,
Divider,
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, fetchDwdTables } from "../api/tasks";
import type { TaskDefinition } from "../types";
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;
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
/* 层排序 / 标签 / 颜色 */
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 {
/** 当前 Flow 包含的层(如 ["ODS", "DWD"] */
layers: string[];
/** 已选中的任务编码列表 */
selectedTasks: string[];
/** 选中任务变化回调 */
onTasksChange: (tasks: string[]) => void;
/** DWD 表过滤:已选中的表名列表 */
selectedDwdTables?: string[];
/** DWD 表过滤变化回调 */
onDwdTablesChange?: (tables: string[]) => void;
}
/* ------------------------------------------------------------------ */
/* 过滤逻辑 */
/* ------------------------------------------------------------------ */
export function filterTasksByLayers(
tasks: TaskDefinition[],
layers: string[],
): TaskDefinition[] {
if (layers.length === 0) return [];
return tasks;
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,
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);
// DWD 表定义(按域分组)
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, string[]>>({});
const showDwdFilter = layers.includes("DWD") && !!onDwdTablesChange;
/* ---------- 加载任务注册表 ---------- */
/* 加载数据 */
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 ?? "获取任务列表失败"); }),
];
// 如果包含 DWD 层,同时加载 DWD 表定义
if (layers.includes("DWD")) {
promises.push(
fetchDwdTables()
.then((data) => { if (!cancelled) setDwdTableGroups(data); })
.catch(() => { /* DWD 表加载失败不阻塞任务列表 */ }),
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]);
/* ---------- 按 layers 过滤后的分组 ---------- */
const filteredGroups = useMemo(() => {
const result: Record<string, TaskDefinition[]> = {};
for (const [domain, tasks] of Object.entries(registry)) {
const visible = filterTasksByLayers(tasks, layers);
if (visible.length > 0) {
result[domain] = [...visible].sort((a, b) => {
if (a.is_common === b.is_common) return 0;
return a.is_common ? -1 : 1;
});
}
}
return result;
}, [registry, layers]);
/* 首次加载后自动同步检查 */
useEffect(() => {
if (Object.keys(registry).length > 0) handleSyncCheck();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registry]);
const domainGroups = useMemo(
() => buildDomainGroups(registry, dwdTableGroups, layers),
[registry, dwdTableGroups, layers],
);
const allVisibleCodes = useMemo(
() => Object.values(filteredGroups).flatMap((t) => t.map((d) => d.code)),
[filteredGroups],
() => 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],
);
// DWD 表扁平列表
const allDwdTableNames = useMemo(
() => Object.values(dwdTableGroups).flat(),
[dwdTableGroups],
);
/* 同步检查 */
const handleSyncCheck = useCallback(async () => {
setSyncLoading(true);
try { setSyncResult(await checkTaskSync()); }
catch { setSyncResult({ in_sync: false, backend_only: [], etl_only: [], error: "检查失败" }); }
finally { setSyncLoading(false); }
}, []);
/* ---------- 事件处理 ---------- */
const handleDomainChange = useCallback(
(domain: string, checkedCodes: string[]) => {
const otherDomainCodes = selectedTasks.filter(
(code) => !filteredGroups[domain]?.some((t) => t.code === code),
);
onTasksChange([...otherDomainCodes, ...checkedCodes]);
},
[selectedTasks, filteredGroups, onTasksChange],
);
const handleSelectAll = useCallback(() => {
onTasksChange(allVisibleCodes);
}, [allVisibleCodes, onTasksChange]);
const handleInvertSelection = useCallback(() => {
const currentSet = new Set(selectedTasks);
const inverted = allVisibleCodes.filter((code) => !currentSet.has(code));
onTasksChange(inverted);
/* 任务选择 */
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]);
/* ---------- DWD 表过滤事件 ---------- */
const handleDwdDomainTableChange = useCallback(
(domain: string, checked: string[]) => {
if (!onDwdTablesChange) return;
const domainTables = new Set(dwdTableGroups[domain] ?? []);
const otherSelected = selectedDwdTables.filter((t) => !domainTables.has(t));
onDwdTablesChange([...otherSelected, ...checked]);
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)));
}
},
[selectedDwdTables, dwdTableGroups, onDwdTablesChange],
[selectedTasks, onTasksChange],
);
const handleDwdSelectAll = useCallback(() => {
onDwdTablesChange?.(allDwdTableNames);
}, [allDwdTableNames, onDwdTablesChange]);
const handleTaskToggle = useCallback(
(code: string, checked: boolean) => {
onTasksChange(checked ? [...selectedTasks, code] : selectedTasks.filter((c) => c !== code));
},
[selectedTasks, onTasksChange],
);
const handleDwdClearAll = useCallback(() => {
onDwdTablesChange?.([]);
}, [onDwdTablesChange]);
/* 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} />;
const domainEntries = Object.entries(filteredGroups);
if (domainEntries.length === 0) return <Text type="secondary"> Flow </Text>;
if (domainGroups.length === 0) return <Text type="secondary"> Flow </Text>;
const selectedCount = selectedTasks.filter((c) => allVisibleCodes.includes(c)).length;
// DWD 装载任务是否被选中
const dwdLoadSelected = selectedTasks.includes("DWD_LOAD_FROM_ODS");
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 === 0 ? "(未选 = 全部装载)" : `${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>
<Space style={{ marginBottom: 8 }}>
<Button size="small" onClick={handleSelectAll}></Button>
<Button size="small" onClick={handleInvertSelection}></Button>
<Text type="secondary"> {selectedCount} / {allVisibleCodes.length}</Text>
</Space>
{/* 工具栏 */}
<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={domainEntries.map(([d]) => d)}
items={domainEntries.map(([domain, tasks]) => {
const domainCodes = tasks.map((t) => t.code);
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 = domainSelected.length === domainCodes.length;
const allChecked = domainCodes.length > 0 && domainSelected.length === domainCodes.length;
const indeterminate = domainSelected.length > 0 && !allChecked;
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
handleDomainChange(domain, e.target.checked ? domainCodes : []);
};
return {
key: domain,
key: group.domain,
label: (
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
indeterminate={indeterminate}
checked={allChecked}
onChange={handleDomainCheckAll}
indeterminate={indeterminate} checked={allChecked}
onChange={(e: CheckboxChangeEvent) => handleDomainToggle(group, e.target.checked)}
style={{ marginRight: 8 }}
/>
{domain}
<Text type="secondary" style={{ marginLeft: 4 }}>
({domainSelected.length}/{domainCodes.length})
</Text>
{group.domain}
<Text type="secondary" style={{ marginLeft: 4 }}>({domainSelected.length}/{domainCodes.length})</Text>
</span>
),
children: (
<Checkbox.Group
value={domainSelected}
onChange={(checked) => handleDomainChange(domain, checked as string[])}
>
<Space direction="vertical" style={{ width: "100%" }}>
{tasks.map((t) => (
<Checkbox key={t.code} value={t.code}>
<Text strong style={t.is_common === false ? { color: "#999" } : undefined}>{t.code}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}>{t.name}</Text>
{t.is_common === false && (
<Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}></Tag>
)}
</Checkbox>
))}
</Space>
</Checkbox.Group>
<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.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>
),
};
})}
/>
{/* DWD 表过滤:仅在 DWD 层且 DWD_LOAD_FROM_ODS 被选中时显示 */}
{showDwdFilter && dwdLoadSelected && allDwdTableNames.length > 0 && (
<>
<Divider style={{ margin: "12px 0 8px" }} />
<div style={{ padding: "0 4px" }}>
<Space style={{ marginBottom: 6 }}>
<Text strong style={{ fontSize: 13 }}>DWD </Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedDwdTables.length === 0
? "(未选择 = 全部装载)"
: `已选 ${selectedDwdTables.length} / ${allDwdTableNames.length}`}
</Text>
</Space>
<div style={{ marginBottom: 6 }}>
<Space size={4}>
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdSelectAll}>
</Button>
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdClearAll}>
</Button>
</Space>
</div>
<Collapse
size="small"
items={Object.entries(dwdTableGroups).map(([domain, tables]) => {
const domainSelected = selectedDwdTables.filter((t) => tables.includes(t));
const allDomainChecked = domainSelected.length === tables.length;
const domainIndeterminate = domainSelected.length > 0 && !allDomainChecked;
return {
key: domain,
label: (
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
indeterminate={domainIndeterminate}
checked={allDomainChecked}
onChange={(e: CheckboxChangeEvent) =>
handleDwdDomainTableChange(domain, e.target.checked ? tables : [])
}
style={{ marginRight: 8 }}
/>
{domain}
<Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>
({domainSelected.length}/{tables.length})
</Text>
</span>
),
children: (
<Checkbox.Group
value={domainSelected}
onChange={(checked) => handleDwdDomainTableChange(domain, checked as string[])}
>
<Space direction="vertical">
{tables.map((table) => (
<Checkbox key={table} value={table}>
<Text style={{ fontSize: 12 }}>{table}</Text>
</Checkbox>
))}
</Space>
</Checkbox.Group>
),
};
})}
/>
{/* 同步差异 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>
);
};