在准备环境前提交次全部更改。
This commit is contained in:
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal file
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal 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;
|
||||
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal file
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
79
apps/admin-web/src/components/LogStream.tsx
Normal file
79
apps/admin-web/src/components/LogStream.tsx
Normal 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;
|
||||
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal file
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal 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;
|
||||
309
apps/admin-web/src/components/TaskSelector.tsx
Normal file
309
apps/admin-web/src/components/TaskSelector.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user