包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
904 lines
34 KiB
TypeScript
904 lines
34 KiB
TypeScript
/**
|
||
* ETL 任务配置页面。
|
||
*
|
||
* 提供 Flow 选择、处理模式、时间窗口、高级选项等配置区域,
|
||
* 以及连接器/Store 选择、任务选择、DWD 表选择、CLI 命令预览和任务提交功能。
|
||
*/
|
||
|
||
import React, { useState, useEffect, useMemo } from "react";
|
||
import {
|
||
Card,
|
||
Radio,
|
||
Checkbox,
|
||
InputNumber,
|
||
DatePicker,
|
||
Button,
|
||
Space,
|
||
Typography,
|
||
Input,
|
||
message,
|
||
Row,
|
||
Col,
|
||
Badge,
|
||
Alert,
|
||
TreeSelect,
|
||
Tooltip,
|
||
Segmented,
|
||
Spin,
|
||
Modal,
|
||
Form,
|
||
Select,
|
||
TimePicker,
|
||
Descriptions,
|
||
Tag,
|
||
} from "antd";
|
||
import {
|
||
SendOutlined,
|
||
ThunderboltOutlined,
|
||
CodeOutlined,
|
||
SettingOutlined,
|
||
ClockCircleOutlined,
|
||
SyncOutlined,
|
||
ShopOutlined,
|
||
ApiOutlined,
|
||
ScheduleOutlined,
|
||
} from "@ant-design/icons";
|
||
import { useNavigate } from "react-router-dom";
|
||
import TaskSelector from "../components/TaskSelector";
|
||
import { validateTaskConfig, fetchFlows } from "../api/tasks";
|
||
import type { FlowDef, ProcessingModeDef } from "../api/tasks";
|
||
import { submitToQueue, executeDirectly, cleanupOutput } from "../api/execution";
|
||
import { createSchedule } from "../api/schedules";
|
||
import { useAuthStore } from "../store/authStore";
|
||
import BusinessDayHint from "../components/BusinessDayHint";
|
||
import type { RadioChangeEvent } from "antd";
|
||
import type { Dayjs } from "dayjs";
|
||
import dayjs from "dayjs";
|
||
import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types";
|
||
import type { MinRunIntervalItem } from "../types";
|
||
|
||
const { Title, Text } = Typography;
|
||
const { TextArea } = Input;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Flow / 处理模式 — 本地 fallback(API 不可用时兜底) */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
interface FlowEntry { name: string; layers: string[] }
|
||
|
||
const FALLBACK_FLOWS: Record<string, FlowEntry> = {
|
||
api_ods: { name: "API → ODS", layers: ["ODS"] },
|
||
api_ods_dwd: { name: "API → ODS → DWD", layers: ["ODS", "DWD"] },
|
||
api_full: { name: "API → ODS → DWD → DWS → INDEX", layers: ["ODS", "DWD", "DWS", "INDEX"] },
|
||
ods_dwd: { name: "ODS → DWD", layers: ["DWD"] },
|
||
dwd_dws: { name: "DWD → DWS汇总", layers: ["DWS"] },
|
||
dwd_dws_index: { name: "DWD → DWS → INDEX", layers: ["DWS", "INDEX"] },
|
||
dwd_index: { name: "DWD → INDEX", layers: ["INDEX"] },
|
||
};
|
||
|
||
interface ProcModeEntry { value: string; label: string; desc: string }
|
||
|
||
const FALLBACK_PROCESSING_MODES: ProcModeEntry[] = [
|
||
{ value: "increment_only", label: "仅增量", desc: "按游标增量抓取和装载" },
|
||
{ value: "verify_only", label: "校验并修复", desc: "对比源和目标,修复差异" },
|
||
{ value: "increment_verify", label: "增量+校验", desc: "先增量再校验" },
|
||
{ value: "full_window", label: "全窗口", desc: "用 API 返回数据的时间范围处理所有层" },
|
||
];
|
||
|
||
/** 将 API 返回的 FlowDef[] 转为 Record<id, FlowEntry> */
|
||
function apiFlowsToRecord(flows: FlowDef[]): Record<string, FlowEntry> {
|
||
const result: Record<string, FlowEntry> = {};
|
||
for (const f of flows) result[f.id] = { name: f.name, layers: f.layers };
|
||
return result;
|
||
}
|
||
|
||
/** 将 API 返回的 ProcessingModeDef[] 转为 ProcModeEntry[] */
|
||
function apiModesToEntries(modes: ProcessingModeDef[]): ProcModeEntry[] {
|
||
return modes.map((m) => ({ value: m.id, label: m.name, desc: m.description }));
|
||
}
|
||
|
||
/** 外部可用的 getFlowLayers(使用 fallback,组件内部用动态数据) */
|
||
export function getFlowLayers(flowId: string): string[] {
|
||
return FALLBACK_FLOWS[flowId]?.layers ?? [];
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 时间窗口 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
type WindowMode = "lookback" | "custom";
|
||
|
||
const WINDOW_SPLIT_OPTIONS = [
|
||
{ value: 0, label: "不切分" },
|
||
{ value: 1, label: "1天" },
|
||
{ value: 10, label: "10天" },
|
||
{ value: 30, label: "30天" },
|
||
] as const;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 连接器 → 门店 树形数据结构 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** 连接器定义:每个连接器下挂载门店列表 */
|
||
interface ConnectorDef {
|
||
id: string;
|
||
label: string;
|
||
icon: React.ReactNode;
|
||
}
|
||
|
||
const CONNECTOR_DEFS: ConnectorDef[] = [
|
||
{ id: "feiqiu", label: "飞球", icon: <ApiOutlined /> },
|
||
];
|
||
|
||
/** 构建 TreeSelect 的 treeData,连接器为父节点,门店为子节点 */
|
||
function buildConnectorStoreTree(
|
||
connectors: ConnectorDef[],
|
||
siteId: number | null,
|
||
): { treeData: { title: React.ReactNode; value: string; key: string; children?: { title: React.ReactNode; value: string; key: string }[] }[]; allValues: string[] } {
|
||
const allValues: string[] = [];
|
||
const treeData = connectors.map((c) => {
|
||
// 每个连接器下挂载当前用户的门店(未来可扩展为多门店)
|
||
const stores = siteId
|
||
? [{ title: (<Space size={4}><ShopOutlined /><span>门店 {siteId}</span></Space>), value: `${c.id}::${siteId}`, key: `${c.id}::${siteId}` }]
|
||
: [];
|
||
stores.forEach((s) => allValues.push(s.value));
|
||
return {
|
||
title: (<Space size={4}>{c.icon}<span>{c.label}</span></Space>),
|
||
value: c.id,
|
||
key: c.id,
|
||
children: stores,
|
||
};
|
||
});
|
||
return { treeData, allValues };
|
||
}
|
||
|
||
/** 从选中值中解析出 store_id 列表 */
|
||
function parseSelectedStoreIds(selected: string[]): number[] {
|
||
const ids: number[] = [];
|
||
for (const v of selected) {
|
||
// 格式: "connector::storeId"
|
||
const parts = v.split("::");
|
||
if (parts.length === 2) {
|
||
const num = Number(parts[1]);
|
||
if (!isNaN(num)) ids.push(num);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 页面组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const TaskConfig: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const user = useAuthStore((s) => s.user);
|
||
|
||
/* ---------- Flow / 处理模式 动态加载 ---------- */
|
||
const [flowDefs, setFlowDefs] = useState<Record<string, FlowEntry>>(FALLBACK_FLOWS);
|
||
const [procModes, setProcModes] = useState<ProcModeEntry[]>(FALLBACK_PROCESSING_MODES);
|
||
const [flowsLoading, setFlowsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchFlows()
|
||
.then(({ flows, processing_modes }) => {
|
||
if (cancelled) return;
|
||
if (flows.length > 0) setFlowDefs(apiFlowsToRecord(flows));
|
||
if (processing_modes.length > 0) setProcModes(apiModesToEntries(processing_modes));
|
||
})
|
||
.catch(() => { /* API 不可用,使用 fallback */ })
|
||
.finally(() => { if (!cancelled) setFlowsLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
/* ---------- 连接器 & Store 树形选择 ---------- */
|
||
const { treeData: connectorTreeData, allValues: allConnectorStoreValues } = useMemo(
|
||
() => buildConnectorStoreTree(CONNECTOR_DEFS, user?.site_id ?? null),
|
||
[user?.site_id],
|
||
);
|
||
// 默认全选
|
||
const [selectedConnectorStores, setSelectedConnectorStores] = useState<string[]>([]);
|
||
|
||
// 初始化时默认全选
|
||
useEffect(() => {
|
||
if (selectedConnectorStores.length === 0 && allConnectorStoreValues.length > 0) {
|
||
setSelectedConnectorStores(allConnectorStoreValues);
|
||
}
|
||
}, [allConnectorStoreValues]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// 从选中值解析 store_id(取第一个,当前单门店场景)
|
||
const selectedStoreIds = useMemo(() => parseSelectedStoreIds(selectedConnectorStores), [selectedConnectorStores]);
|
||
const effectiveStoreId = selectedStoreIds.length === 1 ? selectedStoreIds[0] : null;
|
||
|
||
/* ---------- Flow ---------- */
|
||
const [flow, setFlow] = useState<string>("api_ods_dwd");
|
||
|
||
/* ---------- 处理模式 ---------- */
|
||
const [processingMode, setProcessingMode] = useState<string>("increment_only");
|
||
const [fetchBeforeVerify, setFetchBeforeVerify] = useState(false);
|
||
|
||
/* ---------- 时间窗口 ---------- */
|
||
const [windowMode, setWindowMode] = useState<WindowMode>("lookback");
|
||
const [lookbackHours, setLookbackHours] = useState<number>(24);
|
||
const [overlapSeconds, setOverlapSeconds] = useState<number>(600);
|
||
const [windowStart, setWindowStart] = useState<Dayjs | null>(null);
|
||
const [windowEnd, setWindowEnd] = useState<Dayjs | null>(null);
|
||
const [windowSplitDays, setWindowSplitDays] = useState<number>(0);
|
||
|
||
/* ---------- 任务选择 ---------- */
|
||
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
|
||
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
|
||
const [taskIntervals, setTaskIntervals] = useState<Record<string, MinRunIntervalItem>>({});
|
||
|
||
/* ---------- 高级选项 ---------- */
|
||
const [dryRun, setDryRun] = useState(false);
|
||
const [forceFull, setForceFull] = useState(false);
|
||
const [useLocalJson, setUseLocalJson] = useState(false);
|
||
|
||
/* ---------- Pipeline 调优 ---------- */
|
||
const [pipelineWorkers, setPipelineWorkers] = useState<number | null>(null);
|
||
const [pipelineBatchSize, setPipelineBatchSize] = useState<number | null>(null);
|
||
const [pipelineRateMin, setPipelineRateMin] = useState<number | null>(null);
|
||
const [pipelineRateMax, setPipelineRateMax] = useState<number | null>(null);
|
||
|
||
/* ---------- CLI 预览 ---------- */
|
||
const [cliCommand, setCliCommand] = useState<string>("");
|
||
const [cliEdited, setCliEdited] = useState(false);
|
||
const [cliLoading, setCliLoading] = useState(false);
|
||
|
||
/* ---------- 提交状态 ---------- */
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
/* ---------- 调度任务弹窗 ---------- */
|
||
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
|
||
const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
|
||
const [scheduleType, setScheduleType] = useState<string>("daily");
|
||
const [scheduleForm] = Form.useForm();
|
||
|
||
/* ---------- 派生状态 ---------- */
|
||
const layers = flowDefs[flow]?.layers ?? [];
|
||
const showVerifyOption = processingMode === "verify_only";
|
||
|
||
/* ---------- 构建 TaskConfig 对象 ---------- */
|
||
const buildTaskConfig = (): TaskConfigType => {
|
||
/* layers 包含 DWD 时自动注入 DWD_LOAD_FROM_ODS(UI 上由 DWD 表过滤区块隐含) */
|
||
const tasks = layers.includes("DWD") && !selectedTasks.includes("DWD_LOAD_FROM_ODS")
|
||
? [...selectedTasks, "DWD_LOAD_FROM_ODS"]
|
||
: selectedTasks;
|
||
return {
|
||
tasks,
|
||
flow: flow,
|
||
processing_mode: processingMode,
|
||
pipeline_flow: "FULL",
|
||
dry_run: dryRun,
|
||
window_mode: windowMode,
|
||
window_start: windowMode === "custom" && windowStart ? windowStart.format("YYYY-MM-DD") : null,
|
||
window_end: windowMode === "custom" && windowEnd ? windowEnd.format("YYYY-MM-DD") : null,
|
||
window_split: windowSplitDays > 0 ? "day" : null,
|
||
window_split_days: windowSplitDays > 0 ? windowSplitDays : null,
|
||
lookback_hours: lookbackHours,
|
||
overlap_seconds: overlapSeconds,
|
||
fetch_before_verify: fetchBeforeVerify,
|
||
skip_ods_when_fetch_before_verify: false,
|
||
ods_use_local_json: useLocalJson,
|
||
store_id: effectiveStoreId,
|
||
/* CHANGE [2026-02-19] intent: DWD 表正向勾选,选中=装载 */
|
||
dwd_only_tables: layers.includes("DWD") ? (selectedDwdTables.length > 0 ? selectedDwdTables : null) : null,
|
||
force_full: forceFull,
|
||
pipeline_workers: pipelineWorkers,
|
||
pipeline_batch_size: pipelineBatchSize,
|
||
pipeline_rate_min: pipelineRateMin,
|
||
pipeline_rate_max: pipelineRateMax,
|
||
extra_args: {},
|
||
};
|
||
};
|
||
|
||
/* ---------- 自动刷新 CLI 预览 ---------- */
|
||
const refreshCli = async () => {
|
||
setCliLoading(true);
|
||
try {
|
||
const { command } = await validateTaskConfig(buildTaskConfig());
|
||
setCliCommand(command);
|
||
setCliEdited(false);
|
||
} catch {
|
||
// 静默失败,保留上次命令
|
||
} finally {
|
||
setCliLoading(false);
|
||
}
|
||
};
|
||
|
||
// 配置变化时自动刷新 CLI(防抖)
|
||
useEffect(() => {
|
||
if (cliEdited) return; // 用户手动编辑过则不自动刷新
|
||
const timer = setTimeout(refreshCli, 500);
|
||
return () => clearTimeout(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [flow, processingMode, fetchBeforeVerify, windowMode, lookbackHours, overlapSeconds,
|
||
windowStart, windowEnd, windowSplitDays, selectedTasks, selectedDwdTables,
|
||
dryRun, forceFull, useLocalJson, selectedConnectorStores,
|
||
pipelineWorkers, pipelineBatchSize, pipelineRateMin, pipelineRateMax]);
|
||
|
||
/* ---------- 事件处理 ---------- */
|
||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||
|
||
// CHANGE 2026-03-27 | 包含 ODS 层的 flow 执行前清理输出目录,每类任务只保留最近 10 个运行记录
|
||
const tryCleanupOutput = async () => {
|
||
if (!layers.includes("ODS")) return;
|
||
try {
|
||
const result = await cleanupOutput();
|
||
if (result.dirs_deleted > 0) {
|
||
message.info(`已清理 ${result.dirs_deleted} 个旧运行记录`);
|
||
}
|
||
} catch {
|
||
message.warning("输出目录清理失败,不影响任务执行");
|
||
}
|
||
};
|
||
|
||
const handleSubmitToQueue = async () => {
|
||
setSubmitting(true);
|
||
try {
|
||
await tryCleanupOutput();
|
||
await submitToQueue(buildTaskConfig());
|
||
message.success("已提交到执行队列");
|
||
navigate("/etl-tasks?tab=queue");
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : "提交失败";
|
||
message.error(`提交到队列失败:${msg}`);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// CHANGE 2026-03-27 | 直接执行后跳转历史 tab 并自动打开任务详情
|
||
const handleExecuteDirectly = async () => {
|
||
setSubmitting(true);
|
||
try {
|
||
await tryCleanupOutput();
|
||
const { execution_id } = await executeDirectly(buildTaskConfig());
|
||
message.success("任务已开始执行");
|
||
navigate(`/etl-tasks?tab=history&openExecution=${execution_id}`);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : "执行失败";
|
||
message.error(`直接执行失败:${msg}`);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
/* ---------- 调度任务弹窗 ---------- */
|
||
const handleOpenScheduleModal = () => {
|
||
scheduleForm.resetFields();
|
||
scheduleForm.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");
|
||
setScheduleModalOpen(true);
|
||
};
|
||
|
||
const handleScheduleSubmit = async () => {
|
||
try {
|
||
const values = await scheduleForm.validateFields();
|
||
setScheduleSubmitting(true);
|
||
|
||
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,
|
||
};
|
||
|
||
const taskConfig = buildTaskConfig();
|
||
await createSchedule({
|
||
name: values.name,
|
||
task_codes: taskConfig.tasks,
|
||
task_config: taskConfig,
|
||
schedule_config: scheduleConfig,
|
||
run_immediately: !!values.run_immediately,
|
||
min_run_intervals: Object.keys(taskIntervals).length > 0 ? taskIntervals : undefined,
|
||
});
|
||
message.success("调度任务已创建");
|
||
setScheduleModalOpen(false);
|
||
} catch {
|
||
// 表单验证失败
|
||
} finally {
|
||
setScheduleSubmitting(false);
|
||
}
|
||
};
|
||
|
||
/* ---------- 样式常量 ---------- */
|
||
const cardStyle = { marginBottom: 12 };
|
||
const sectionTitleStyle: React.CSSProperties = {
|
||
fontSize: 13, fontWeight: 500, color: "#666", marginBottom: 8, display: "block",
|
||
};
|
||
|
||
return (
|
||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||
{/* ---- 页面标题 ---- */}
|
||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||
<Title level={4} style={{ margin: 0 }}>
|
||
<SettingOutlined style={{ marginRight: 8 }} />
|
||
任务配置
|
||
</Title>
|
||
<Space>
|
||
<Badge count={selectedTasks.length} size="small" offset={[-4, 0]}>
|
||
<Text type="secondary">已选任务</Text>
|
||
</Badge>
|
||
</Space>
|
||
</div>
|
||
|
||
{/* ---- 第一行:连接器/门店 + Flow ---- */}
|
||
<Row gutter={12}>
|
||
<Col span={8}>
|
||
<Card size="small" title={<Space size={4}><ApiOutlined />连接器 / 门店</Space>} style={cardStyle}>
|
||
<TreeSelect
|
||
treeData={connectorTreeData}
|
||
value={selectedConnectorStores}
|
||
onChange={setSelectedConnectorStores}
|
||
treeCheckable
|
||
treeDefaultExpandAll
|
||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||
placeholder="选择连接器和门店"
|
||
style={{ width: "100%" }}
|
||
maxTagCount={3}
|
||
maxTagPlaceholder={(omitted) => `+${omitted.length} 项`}
|
||
treeCheckStrictly={false}
|
||
/>
|
||
<Text type="secondary" style={{ fontSize: 11, marginTop: 6, display: "block" }}>
|
||
{selectedStoreIds.length === 0
|
||
? "未选择门店,将使用 JWT 默认值"
|
||
: `已选 ${selectedStoreIds.length} 个门店`}
|
||
</Text>
|
||
</Card>
|
||
</Col>
|
||
<Col span={16}>
|
||
<Card size="small" title={flowsLoading ? <Space size={4}>执行流程 (Flow) <Spin size="small" /></Space> : "执行流程 (Flow)"} style={cardStyle}>
|
||
<Radio.Group value={flow} onChange={handleFlowChange} style={{ width: "100%" }}>
|
||
<Row gutter={[0, 4]}>
|
||
{Object.entries(flowDefs).map(([id, def]) => (
|
||
<Col span={12} key={id}>
|
||
<Tooltip title={def.name}>
|
||
<Radio value={id}>
|
||
<Text strong style={{ fontSize: 12 }}>{id}</Text>
|
||
</Radio>
|
||
</Tooltip>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</Radio.Group>
|
||
<div style={{ marginTop: 6, padding: "4px 8px", background: "#f6f8fa", borderRadius: 4 }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{layers.join(" → ") || "—"}
|
||
</Text>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* ---- 第二行:处理模式 + 时间窗口 ---- */}
|
||
<Row gutter={12}>
|
||
<Col span={8}>
|
||
<Card size="small" title="处理模式" style={cardStyle}>
|
||
<Radio.Group
|
||
value={processingMode}
|
||
onChange={(e) => {
|
||
setProcessingMode(e.target.value);
|
||
if (e.target.value === "increment_only") setFetchBeforeVerify(false);
|
||
}}
|
||
>
|
||
<Space direction="vertical" style={{ width: "100%" }}>
|
||
{procModes.map((m) => (
|
||
<Radio key={m.value} value={m.value}>
|
||
<Text strong>{m.label}</Text>
|
||
<br />
|
||
<Text type="secondary" style={{ fontSize: 12 }}>{m.desc}</Text>
|
||
</Radio>
|
||
))}
|
||
</Space>
|
||
</Radio.Group>
|
||
{showVerifyOption && (
|
||
<Checkbox
|
||
checked={fetchBeforeVerify}
|
||
onChange={(e) => setFetchBeforeVerify(e.target.checked)}
|
||
style={{ marginTop: 8 }}
|
||
>
|
||
校验前从 API 获取
|
||
</Checkbox>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
<Col span={16}>
|
||
<Card
|
||
size="small"
|
||
title={<><ClockCircleOutlined style={{ marginRight: 6 }} />时间窗口</>}
|
||
style={cardStyle}
|
||
>
|
||
<Row gutter={16}>
|
||
<Col span={24}>
|
||
<Segmented
|
||
value={windowMode}
|
||
onChange={(v) => setWindowMode(v as WindowMode)}
|
||
options={[
|
||
{ value: "lookback", label: "回溯模式" },
|
||
{ value: "custom", label: "自定义范围" },
|
||
]}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
|
||
{windowMode === "lookback" ? (
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Text style={sectionTitleStyle}>回溯小时数</Text>
|
||
<InputNumber
|
||
min={1} max={720} value={lookbackHours}
|
||
onChange={(v) => setLookbackHours(v ?? 24)}
|
||
style={{ width: "100%" }}
|
||
addonAfter="小时"
|
||
/>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Text style={sectionTitleStyle}>冗余秒数</Text>
|
||
<InputNumber
|
||
min={0} max={7200} value={overlapSeconds}
|
||
onChange={(v) => setOverlapSeconds(v ?? 600)}
|
||
style={{ width: "100%" }}
|
||
addonAfter="秒"
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
) : (
|
||
<>
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Text style={sectionTitleStyle}>开始日期</Text>
|
||
<DatePicker
|
||
value={windowStart} onChange={setWindowStart}
|
||
placeholder="选择开始日期" style={{ width: "100%" }}
|
||
/>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Text style={sectionTitleStyle}>结束日期</Text>
|
||
<DatePicker
|
||
value={windowEnd} onChange={setWindowEnd}
|
||
placeholder="选择结束日期" style={{ width: "100%" }}
|
||
status={windowStart && windowEnd && windowEnd.isBefore(windowStart) ? "error" : undefined}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
<div style={{ marginTop: 4 }}>
|
||
<BusinessDayHint />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ marginTop: 12 }}>
|
||
<Text style={sectionTitleStyle}>窗口切分</Text>
|
||
<Radio.Group
|
||
value={windowSplitDays}
|
||
onChange={(e) => setWindowSplitDays(e.target.value)}
|
||
>
|
||
{WINDOW_SPLIT_OPTIONS.map((opt) => (
|
||
<Radio.Button key={opt.value} value={opt.value}>{opt.label}</Radio.Button>
|
||
))}
|
||
</Radio.Group>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* ---- 高级选项(带描述) ---- */}
|
||
<Card size="small" title="高级选项" style={cardStyle}>
|
||
<Row gutter={[24, 8]}>
|
||
<Col span={12}>
|
||
<Checkbox checked={dryRun} onChange={(e) => setDryRun(e.target.checked)}>
|
||
<Text strong>dry-run</Text>
|
||
</Checkbox>
|
||
<div style={{ marginLeft: 24 }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>模拟执行,走完整流程但不写入数据库</Text>
|
||
</div>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Checkbox checked={forceFull} onChange={(e) => setForceFull(e.target.checked)}>
|
||
<Text strong>force-full</Text>
|
||
</Checkbox>
|
||
<div style={{ marginLeft: 24 }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>强制全量,跳过 hash 去重和变更对比</Text>
|
||
</div>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Checkbox checked={useLocalJson} onChange={(e) => setUseLocalJson(e.target.checked)}>
|
||
<Text strong>本地 JSON</Text>
|
||
</Checkbox>
|
||
<div style={{ marginLeft: 24 }}>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>离线模式,从本地 JSON 回放(等同 --data-source offline)</Text>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* Pipeline 调优参数 */}
|
||
<div style={{ marginTop: 12, borderTop: "1px solid #f0f0f0", paddingTop: 12 }}>
|
||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: "block" }}>
|
||
Pipeline 调优(留空使用默认值)
|
||
</Text>
|
||
<Row gutter={[24, 8]}>
|
||
<Col span={6}>
|
||
<Text style={{ fontSize: 12 }}>并发 workers</Text>
|
||
<InputNumber
|
||
size="small"
|
||
min={1}
|
||
max={32}
|
||
placeholder="默认 3"
|
||
value={pipelineWorkers}
|
||
onChange={(v) => setPipelineWorkers(v)}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Text style={{ fontSize: 12 }}>批量大小</Text>
|
||
<InputNumber
|
||
size="small"
|
||
min={1}
|
||
max={10000}
|
||
placeholder="默认 200"
|
||
value={pipelineBatchSize}
|
||
onChange={(v) => setPipelineBatchSize(v)}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Text style={{ fontSize: 12 }}>限流下限(秒)</Text>
|
||
<InputNumber
|
||
size="small"
|
||
min={0}
|
||
max={60}
|
||
step={0.1}
|
||
placeholder="默认 1.0"
|
||
value={pipelineRateMin}
|
||
onChange={(v) => setPipelineRateMin(v)}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Text style={{ fontSize: 12 }}>限流上限(秒)</Text>
|
||
<InputNumber
|
||
size="small"
|
||
min={0}
|
||
max={60}
|
||
step={0.1}
|
||
placeholder="默认 3.0"
|
||
value={pipelineRateMax}
|
||
onChange={(v) => setPipelineRateMax(v)}
|
||
style={{ width: "100%" }}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||
<Card size="small" title={<Space size={8}>任务选择<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>右侧可设置每个任务的最小执行间隔</Text></Space>} style={cardStyle}>
|
||
<TaskSelector
|
||
layers={layers}
|
||
selectedTasks={selectedTasks}
|
||
onTasksChange={setSelectedTasks}
|
||
selectedDwdTables={selectedDwdTables}
|
||
onDwdTablesChange={setSelectedDwdTables}
|
||
taskIntervals={taskIntervals}
|
||
onTaskIntervalsChange={setTaskIntervals}
|
||
/>
|
||
</Card>
|
||
|
||
{/* ---- CLI 命令预览(内嵌可编辑) ---- */}
|
||
<Card
|
||
size="small"
|
||
title={
|
||
<Space>
|
||
<CodeOutlined />
|
||
<span>CLI 命令预览</span>
|
||
{cliEdited && <Text type="warning" style={{ fontSize: 12 }}>(已手动编辑)</Text>}
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Button
|
||
size="small"
|
||
icon={<SyncOutlined spin={cliLoading} />}
|
||
onClick={() => { setCliEdited(false); refreshCli(); }}
|
||
>
|
||
重新生成
|
||
</Button>
|
||
}
|
||
style={cardStyle}
|
||
>
|
||
<TextArea
|
||
value={cliCommand}
|
||
onChange={(e) => { setCliCommand(e.target.value); setCliEdited(true); }}
|
||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||
style={{
|
||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||
fontSize: 13,
|
||
background: "#1e1e1e",
|
||
color: "#d4d4d4",
|
||
border: "none",
|
||
borderRadius: 4,
|
||
}}
|
||
placeholder="配置变更后自动生成 CLI 命令..."
|
||
/>
|
||
{cliEdited && (
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="已手动编辑命令,配置变更不会自动覆盖。点击「重新生成」恢复自动模式。"
|
||
style={{ marginTop: 8 }}
|
||
banner
|
||
/>
|
||
)}
|
||
</Card>
|
||
|
||
{/* ---- 操作按钮 ---- */}
|
||
<Card size="small" style={{ marginBottom: 24 }}>
|
||
<div style={{ display: "flex", alignItems: "center" }}>
|
||
<Space size="middle">
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
icon={<SendOutlined />}
|
||
loading={submitting}
|
||
onClick={handleSubmitToQueue}
|
||
>
|
||
提交到队列
|
||
</Button>
|
||
<Button
|
||
size="large"
|
||
icon={<ScheduleOutlined />}
|
||
onClick={handleOpenScheduleModal}
|
||
>
|
||
添加到调度任务
|
||
</Button>
|
||
</Space>
|
||
<div style={{ flex: 1 }} />
|
||
<Button
|
||
size="large"
|
||
icon={<ThunderboltOutlined />}
|
||
loading={submitting}
|
||
onClick={handleExecuteDirectly}
|
||
>
|
||
直接执行!
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* ---- 调度任务创建 Modal ---- */}
|
||
<Modal
|
||
title="添加到调度任务"
|
||
open={scheduleModalOpen}
|
||
onOk={handleScheduleSubmit}
|
||
onCancel={() => setScheduleModalOpen(false)}
|
||
confirmLoading={scheduleSubmitting}
|
||
destroyOnClose
|
||
width={560}
|
||
>
|
||
{/* 当前配置摘要(只读) */}
|
||
<Descriptions
|
||
column={1}
|
||
bordered
|
||
size="small"
|
||
style={{ marginBottom: 16 }}
|
||
title="当前任务配置(只读)"
|
||
>
|
||
<Descriptions.Item label="任务">
|
||
{selectedTasks.length > 0
|
||
? selectedTasks.map((t) => <Tag key={t}>{t}</Tag>)
|
||
: <Text type="secondary">未选择</Text>}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Flow">
|
||
<Tag>{flow}</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="处理模式">{processingMode}</Descriptions.Item>
|
||
<Descriptions.Item label="时间窗口">
|
||
{windowMode === "lookback"
|
||
? `回溯 ${lookbackHours} 小时`
|
||
: `${windowStart?.format("YYYY-MM-DD") ?? "?"} ~ ${windowEnd?.format("YYYY-MM-DD") ?? "?"}`}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
<Form form={scheduleForm} 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={[
|
||
{ label: "一次性", value: "once" },
|
||
{ label: "固定间隔", value: "interval" },
|
||
{ label: "每日", value: "daily" },
|
||
{ label: "每周", value: "weekly" },
|
||
{ label: "Cron", value: "cron" },
|
||
]}
|
||
onChange={(v: string) => setScheduleType(v)}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{scheduleType === "interval" && (
|
||
<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>
|
||
)}
|
||
{scheduleType === "daily" && (
|
||
<Form.Item name={["schedule_config", "daily_time"]} label="执行时间" rules={[{ required: true }]}>
|
||
<TimePicker format="HH:mm" />
|
||
</Form.Item>
|
||
)}
|
||
{scheduleType === "weekly" && (
|
||
<>
|
||
<Form.Item name={["schedule_config", "weekly_days"]} label="星期" rules={[{ required: true }]}>
|
||
<Checkbox.Group options={[
|
||
{ label: "周一", value: 1 }, { label: "周二", value: 2 },
|
||
{ label: "周三", value: 3 }, { label: "周四", value: 4 },
|
||
{ label: "周五", value: 5 }, { label: "周六", value: 6 },
|
||
{ label: "周日", value: 0 },
|
||
]} />
|
||
</Form.Item>
|
||
<Form.Item name={["schedule_config", "weekly_time"]} label="执行时间" rules={[{ required: true }]}>
|
||
<TimePicker format="HH:mm" />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
{scheduleType === "cron" && (
|
||
<Form.Item name={["schedule_config", "cron_expression"]} label="Cron 表达式" rules={[{ required: true }]}>
|
||
<Input placeholder="0 4 * * *" />
|
||
</Form.Item>
|
||
)}
|
||
|
||
<Form.Item name="run_immediately" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||
<Checkbox>创建后立即执行一次</Checkbox>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TaskConfig;
|