Files
Neo-ZQYY/apps/admin-web/src/pages/TaskConfig.tsx
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- 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>
2026-04-06 00:03:48 +08:00

904 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 / 处理模式 — 本地 fallbackAPI 不可用时兜底) */
/* ------------------------------------------------------------------ */
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_ODSUI 上由 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;