在准备环境前提交次全部更改。

This commit is contained in:
Neo
2026-02-19 08:35:13 +08:00
parent ded6dfb9d8
commit 4eac07da47
1387 changed files with 6107191 additions and 33002 deletions

View File

@@ -0,0 +1,187 @@
/**
* 按业务域分组的 DWD 表选择器。
*
* 从 /api/tasks/dwd-tables 获取 DWD 表定义,按业务域折叠展示,
* 支持全选/反选。仅在 Flow 包含 DWD 层时由父组件渲染。
*/
import React, { useEffect, useState, useMemo, useCallback } from "react";
import {
Collapse,
Checkbox,
Spin,
Alert,
Button,
Space,
Typography,
} from "antd";
import type { CheckboxChangeEvent } from "antd/es/checkbox";
import { fetchDwdTables } from "../api/tasks";
const { Text } = Typography;
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export interface DwdTableSelectorProps {
/** 已选中的 DWD 表名列表 */
selectedTables: string[];
/** 选中表变化回调 */
onTablesChange: (tables: string[]) => void;
}
/* ------------------------------------------------------------------ */
/* 组件 */
/* ------------------------------------------------------------------ */
const DwdTableSelector: React.FC<DwdTableSelectorProps> = ({
selectedTables,
onTablesChange,
}) => {
/** 按业务域分组的 DWD 表 */
const [tableGroups, setTableGroups] = useState<Record<string, string[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/* ---------- 加载 DWD 表定义 ---------- */
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchDwdTables()
.then((data) => {
if (!cancelled) setTableGroups(data);
})
.catch((err) => {
if (!cancelled) setError(err?.message ?? "获取 DWD 表列表失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
/** 所有表名的扁平列表 */
const allTableNames = useMemo(
() => Object.values(tableGroups).flat(),
[tableGroups],
);
/* ---------- 事件处理 ---------- */
/** 单个业务域的勾选变化 */
const handleDomainChange = useCallback(
(domain: string, checkedTables: string[]) => {
const domainTables = new Set(tableGroups[domain] ?? []);
const otherSelected = selectedTables.filter((t) => !domainTables.has(t));
onTablesChange([...otherSelected, ...checkedTables]);
},
[selectedTables, tableGroups, onTablesChange],
);
/** 全选 */
const handleSelectAll = useCallback(() => {
onTablesChange(allTableNames);
}, [allTableNames, onTablesChange]);
/** 反选 */
const handleInvertSelection = useCallback(() => {
const currentSet = new Set(selectedTables);
const inverted = allTableNames.filter((t) => !currentSet.has(t));
onTablesChange(inverted);
}, [allTableNames, selectedTables, onTablesChange]);
/* ---------- 渲染 ---------- */
if (loading) {
return <Spin tip="加载 DWD 表列表…" />;
}
if (error) {
return <Alert type="error" message="加载失败" description={error} />;
}
const domainEntries = Object.entries(tableGroups);
if (domainEntries.length === 0) {
return <Text type="secondary"> DWD </Text>;
}
const selectedCount = selectedTables.filter((t) =>
allTableNames.includes(t),
).length;
return (
<div>
{/* 全选 / 反选 */}
<Space style={{ marginBottom: 8 }}>
<Button size="small" onClick={handleSelectAll}>
</Button>
<Button size="small" onClick={handleInvertSelection}>
</Button>
<Text type="secondary">
{selectedCount} / {allTableNames.length}
</Text>
</Space>
<Collapse
defaultActiveKey={domainEntries.map(([d]) => d)}
items={domainEntries.map(([domain, tables]) => {
const domainSelected = selectedTables.filter((t) =>
tables.includes(t),
);
const allChecked = domainSelected.length === tables.length;
const indeterminate = domainSelected.length > 0 && !allChecked;
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
handleDomainChange(domain, e.target.checked ? tables : []);
};
return {
key: domain,
label: (
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
indeterminate={indeterminate}
checked={allChecked}
onChange={handleDomainCheckAll}
style={{ marginRight: 8 }}
/>
{domain}
<Text type="secondary" style={{ marginLeft: 4 }}>
({domainSelected.length}/{tables.length})
</Text>
</span>
),
children: (
<Checkbox.Group
value={domainSelected}
onChange={(checked) =>
handleDomainChange(domain, checked as string[])
}
>
<Space direction="vertical">
{tables.map((table) => (
<Checkbox key={table} value={table}>
{table}
</Checkbox>
))}
</Space>
</Checkbox.Group>
),
};
})}
/>
</div>
);
};
export default DwdTableSelector;

View File

@@ -0,0 +1,68 @@
/**
* 全局错误边界 — 捕获 React 渲染异常,显示错误信息而非白屏。
*/
import React from "react";
import { Result, Button, Typography } from "antd";
const { Paragraph, Text } = Typography;
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack);
}
handleReload = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 48 }}>
<Result
status="error"
title="页面渲染出错"
subTitle="请尝试刷新页面,如果问题持续请联系管理员。"
extra={
<Button type="primary" onClick={this.handleReload}>
</Button>
}
>
{this.state.error && (
<Paragraph>
<Text type="danger" code style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{this.state.error.message}
</Text>
</Paragraph>
)}
</Result>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,79 @@
/**
* 日志流展示组件。
*
* - 等宽字体展示日志行
* - 自动滚动到底部useRef + scrollIntoView
* - 提供"暂停自动滚动"按钮toggle
*/
import React, { useEffect, useRef, useState } from "react";
import { Button } from "antd";
import { PauseCircleOutlined, PlayCircleOutlined } from "@ant-design/icons";
export interface LogStreamProps {
/** 可选的执行 ID用于标题展示 */
executionId?: string;
/** 日志行数组 */
lines: string[];
}
const LogStream: React.FC<LogStreamProps> = ({ lines }) => {
const [autoscroll, setAutoscroll] = useState(true);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (autoscroll && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [lines, autoscroll]);
const handleToggle = () => {
const next = !autoscroll;
setAutoscroll(next);
// 恢复时立即滚动到底部
if (next && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ marginBottom: 8, textAlign: "right" }}>
<Button
size="small"
icon={autoscroll ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={handleToggle}
>
{autoscroll ? "暂停滚动" : "恢复滚动"}
</Button>
</div>
<div
style={{
flex: 1,
overflow: "auto",
background: "#1e1e1e",
color: "#d4d4d4",
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
fontSize: 13,
lineHeight: 1.6,
padding: 12,
borderRadius: 4,
minHeight: 300,
}}
>
{lines.length === 0 ? (
<div style={{ color: "#888" }}></div>
) : (
lines.map((line, i) => (
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{line}
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
);
};
export default LogStream;

View File

@@ -0,0 +1,407 @@
/**
* 调度管理 Tab 组件。
*
* 功能:
* - 调度任务列表(名称、调度类型、启用 Switch、下次执行、执行次数、最近状态、操作
* - 创建/编辑调度任务 Modal名称 + 调度配置)
* - 删除确认
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
Input, Select, InputNumber, TimePicker, Checkbox, message,
} from 'antd';
import { PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { ScheduledTask, ScheduleConfig } from '../types';
import {
fetchSchedules,
createSchedule,
updateSchedule,
deleteSchedule,
toggleSchedule,
} from '../api/schedules';
/* ------------------------------------------------------------------ */
/* 常量 & 工具 */
/* ------------------------------------------------------------------ */
const STATUS_COLOR: Record<string, string> = {
success: 'success',
failed: 'error',
running: 'processing',
cancelled: 'warning',
};
const SCHEDULE_TYPE_LABEL: Record<string, string> = {
once: '一次性',
interval: '固定间隔',
daily: '每日',
weekly: '每周',
cron: 'Cron',
};
const INTERVAL_UNIT_LABEL: Record<string, string> = {
minutes: '分钟',
hours: '小时',
days: '天',
};
const WEEKDAY_OPTIONS = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 0 },
];
/** 格式化时间 */
function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('zh-CN');
}
/** 根据调度配置生成可读描述 */
function describeSchedule(cfg: ScheduleConfig): string {
switch (cfg.schedule_type) {
case 'once':
return '一次性';
case 'interval':
return `${cfg.interval_value} ${INTERVAL_UNIT_LABEL[cfg.interval_unit] ?? cfg.interval_unit}`;
case 'daily':
return `每日 ${cfg.daily_time}`;
case 'weekly': {
const days = (cfg.weekly_days ?? [])
.map((d) => WEEKDAY_OPTIONS.find((o) => o.value === d)?.label ?? `${d}`)
.join('、');
return `每周 ${days} ${cfg.weekly_time}`;
}
case 'cron':
return `Cron: ${cfg.cron_expression}`;
default:
return cfg.schedule_type;
}
}
/* ------------------------------------------------------------------ */
/* 调度配置表单子组件 */
/* ------------------------------------------------------------------ */
/** 根据调度类型动态渲染配置项 */
const ScheduleConfigFields: React.FC<{ scheduleType: string }> = ({ scheduleType }) => {
switch (scheduleType) {
case 'interval':
return (
<Space>
<Form.Item name={['schedule_config', 'interval_value']} noStyle rules={[{ required: true }]}>
<InputNumber min={1} placeholder="间隔值" />
</Form.Item>
<Form.Item name={['schedule_config', 'interval_unit']} noStyle rules={[{ required: true }]}>
<Select style={{ width: 100 }} options={[
{ label: '分钟', value: 'minutes' },
{ label: '小时', value: 'hours' },
{ label: '天', value: 'days' },
]} />
</Form.Item>
</Space>
);
case 'daily':
return (
<Form.Item name={['schedule_config', 'daily_time']} label="执行时间" rules={[{ required: true }]}>
<TimePicker format="HH:mm" />
</Form.Item>
);
case 'weekly':
return (
<>
<Form.Item name={['schedule_config', 'weekly_days']} label="星期" rules={[{ required: true }]}>
<Checkbox.Group options={WEEKDAY_OPTIONS} />
</Form.Item>
<Form.Item name={['schedule_config', 'weekly_time']} label="执行时间" rules={[{ required: true }]}>
<TimePicker format="HH:mm" />
</Form.Item>
</>
);
case 'cron':
return (
<Form.Item name={['schedule_config', 'cron_expression']} label="Cron 表达式" rules={[{ required: true }]}>
<Input placeholder="0 4 * * *" />
</Form.Item>
);
default:
return null;
}
};
/* ------------------------------------------------------------------ */
/* 主组件 */
/* ------------------------------------------------------------------ */
const ScheduleTab: React.FC = () => {
const [data, setData] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [submitting, setSubmitting] = useState(false);
const [scheduleType, setScheduleType] = useState<string>('daily');
const [form] = Form.useForm();
/* 加载列表 */
const load = useCallback(async () => {
setLoading(true);
try {
setData(await fetchSchedules());
} catch {
message.error('加载调度任务失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
/* 打开创建 Modal */
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({
schedule_config: {
schedule_type: 'daily',
interval_value: 1,
interval_unit: 'hours',
daily_time: dayjs('04:00', 'HH:mm'),
weekly_days: [1],
weekly_time: dayjs('04:00', 'HH:mm'),
cron_expression: '0 4 * * *',
},
});
setScheduleType('daily');
setModalOpen(true);
};
/* 打开编辑 Modal */
const openEdit = (record: ScheduledTask) => {
setEditing(record);
const cfg = record.schedule_config;
form.setFieldsValue({
name: record.name,
schedule_config: {
...cfg,
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
weekly_time: cfg.weekly_time ? dayjs(cfg.weekly_time, 'HH:mm') : undefined,
},
});
setScheduleType(cfg.schedule_type);
setModalOpen(true);
};
/* 提交创建/编辑 */
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
// 将 dayjs 对象转为字符串
const cfg = { ...values.schedule_config };
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
cfg.daily_time = cfg.daily_time.format('HH:mm');
}
if (cfg.weekly_time && typeof cfg.weekly_time !== 'string') {
cfg.weekly_time = cfg.weekly_time.format('HH:mm');
}
const scheduleConfig: ScheduleConfig = {
schedule_type: cfg.schedule_type ?? 'daily',
interval_value: cfg.interval_value ?? 1,
interval_unit: cfg.interval_unit ?? 'hours',
daily_time: cfg.daily_time ?? '04:00',
weekly_days: cfg.weekly_days ?? [1],
weekly_time: cfg.weekly_time ?? '04:00',
cron_expression: cfg.cron_expression ?? '0 4 * * *',
enabled: true,
start_date: null,
end_date: null,
};
if (editing) {
await updateSchedule(editing.id, {
name: values.name,
schedule_config: scheduleConfig,
});
message.success('调度任务已更新');
} else {
// 创建时使用默认 task_config简化实现
await createSchedule({
name: values.name,
task_codes: [],
task_config: {
tasks: [],
pipeline: 'api_full',
processing_mode: 'increment_only',
pipeline_flow: 'FULL',
dry_run: false,
window_mode: 'lookback',
window_start: null,
window_end: null,
window_split: null,
window_split_days: null,
lookback_hours: 24,
overlap_seconds: 600,
fetch_before_verify: false,
skip_ods_when_fetch_before_verify: false,
ods_use_local_json: false,
store_id: null,
dwd_only_tables: null,
force_full: false,
extra_args: {},
},
schedule_config: scheduleConfig,
});
message.success('调度任务已创建');
}
setModalOpen(false);
load();
} catch {
// 表单验证失败,不做额外处理
} finally {
setSubmitting(false);
}
};
/* 删除 */
const handleDelete = async (id: string) => {
try {
await deleteSchedule(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
/* 启用/禁用 */
const handleToggle = async (id: string) => {
try {
await toggleSchedule(id);
load();
} catch {
message.error('切换状态失败');
}
};
/* 表格列定义 */
const columns: ColumnsType<ScheduledTask> = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
},
{
title: '调度类型',
key: 'schedule_type',
render: (_: unknown, record: ScheduledTask) => describeSchedule(record.schedule_config),
},
{
title: '启用',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (enabled: boolean, record: ScheduledTask) => (
<Switch checked={enabled} onChange={() => handleToggle(record.id)} size="small" />
),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
render: fmtTime,
},
{
title: '执行次数',
dataIndex: 'run_count',
key: 'run_count',
width: 90,
},
{
title: '最近状态',
dataIndex: 'last_status',
key: 'last_status',
width: 100,
render: (s: string | null) =>
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
},
{
title: '操作',
key: 'action',
width: 140,
render: (_: unknown, record: ScheduledTask) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm title="确认删除该调度任务?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger icon={<DeleteOutlined />} size="small">
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ marginBottom: 12 }}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
</Button>
</Space>
</div>
<Table<ScheduledTask>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
size="middle"
/>
{/* 创建/编辑 Modal */}
<Modal
title={editing ? '编辑调度任务' : '新建调度任务'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={submitting}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入调度任务名称' }]}>
<Input placeholder="例如:每日全量同步" />
</Form.Item>
<Form.Item name={['schedule_config', 'schedule_type']} label="调度类型" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHEDULE_TYPE_LABEL).map(([value, label]) => ({ value, label }))}
onChange={(v: string) => setScheduleType(v)}
/>
</Form.Item>
<ScheduleConfigFields scheduleType={scheduleType} />
</Form>
</Modal>
</>
);
};
export default ScheduleTab;

View File

@@ -0,0 +1,309 @@
/**
* 按业务域分组的任务选择器。
*
* 从 /api/tasks/registry 获取任务注册表,按业务域折叠展示,
* 支持全选/反选和按 Flow 层级过滤。
* 当 Flow 包含 DWD 层时,在 DWD 任务下方内嵌表过滤子选项。
*/
import React, { useEffect, useState, useMemo, useCallback } from "react";
import {
Collapse,
Checkbox,
Spin,
Alert,
Button,
Space,
Typography,
Tag,
Divider,
} from "antd";
import type { CheckboxChangeEvent } from "antd/es/checkbox";
import { fetchTaskRegistry, fetchDwdTables } from "../api/tasks";
import type { TaskDefinition } from "../types";
const { Text } = Typography;
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
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;
}
/* ------------------------------------------------------------------ */
/* 组件 */
/* ------------------------------------------------------------------ */
const TaskSelector: React.FC<TaskSelectorProps> = ({
layers,
selectedTasks,
onTasksChange,
selectedDwdTables = [],
onDwdTablesChange,
}) => {
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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 表加载失败不阻塞任务列表 */ }),
);
}
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]);
const allVisibleCodes = useMemo(
() => Object.values(filteredGroups).flatMap((t) => t.map((d) => d.code)),
[filteredGroups],
);
// DWD 表扁平列表
const allDwdTableNames = useMemo(
() => Object.values(dwdTableGroups).flat(),
[dwdTableGroups],
);
/* ---------- 事件处理 ---------- */
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);
}, [allVisibleCodes, selectedTasks, 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]);
},
[selectedDwdTables, dwdTableGroups, onDwdTablesChange],
);
const handleDwdSelectAll = useCallback(() => {
onDwdTablesChange?.(allDwdTableNames);
}, [allDwdTableNames, onDwdTablesChange]);
const handleDwdClearAll = useCallback(() => {
onDwdTablesChange?.([]);
}, [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>;
const selectedCount = selectedTasks.filter((c) => allVisibleCodes.includes(c)).length;
// DWD 装载任务是否被选中
const dwdLoadSelected = selectedTasks.includes("DWD_LOAD_FROM_ODS");
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>
<Collapse
defaultActiveKey={domainEntries.map(([d]) => d)}
items={domainEntries.map(([domain, tasks]) => {
const domainCodes = tasks.map((t) => t.code);
const domainSelected = selectedTasks.filter((c) => domainCodes.includes(c));
const allChecked = domainSelected.length === domainCodes.length;
const indeterminate = domainSelected.length > 0 && !allChecked;
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
handleDomainChange(domain, e.target.checked ? domainCodes : []);
};
return {
key: domain,
label: (
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
indeterminate={indeterminate}
checked={allChecked}
onChange={handleDomainCheckAll}
style={{ marginRight: 8 }}
/>
{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>
),
};
})}
/>
{/* 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>
),
};
})}
/>
</div>
</>
)}
</div>
);
};
export default TaskSelector;