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

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,235 @@
/**
* 数据库查看器页面。
*
* - 左侧Schema → Table 层级树,异步加载
* - 右侧上方SQL 编辑器 + 执行按钮
* - 右侧下方:列定义 / 查询结果 Table
*/
import React, { useEffect, useState, useCallback } from 'react';
import { Tree, Input, Button, Table, Space, message, Spin, Tag, Card, Typography, Tooltip } from 'antd';
import {
PlayCircleOutlined, ReloadOutlined, TableOutlined,
DatabaseOutlined, CopyOutlined,
} from '@ant-design/icons';
import type { DataNode, EventDataNode } from 'antd/es/tree';
import type { ColumnsType } from 'antd/es/table';
import {
fetchSchemas, fetchTables, fetchColumns, executeQuery,
type ColumnInfo, type QueryResult,
} from '../api/dbViewer';
const { TextArea } = Input;
const { Title, Text } = Typography;
const schemaKey = (schema: string) => `s::${schema}`;
const tableKey = (schema: string, table: string) => `t::${schema}::${table}`;
function parseTableKey(key: string): { schema: string; table: string } | null {
if (!key.startsWith('t::')) return null;
const parts = key.slice(3).split('::');
if (parts.length !== 2) return null;
return { schema: parts[0], table: parts[1] };
}
const DBViewer: React.FC = () => {
const [treeData, setTreeData] = useState<DataNode[]>([]);
const [loadingTree, setLoadingTree] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [selectedTable, setSelectedTable] = useState<{ schema: string; table: string } | null>(null);
const [columnData, setColumnData] = useState<ColumnInfo[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [sql, setSql] = useState('');
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [loadingQuery, setLoadingQuery] = useState(false);
const loadSchemas = useCallback(async () => {
setLoadingTree(true);
try {
const schemas = await fetchSchemas();
setTreeData(
schemas.map((s) => ({
title: s, key: schemaKey(s), icon: <DatabaseOutlined />, isLeaf: false,
})),
);
} catch { message.error('加载 Schema 列表失败'); }
finally { setLoadingTree(false); }
}, []);
useEffect(() => { loadSchemas(); }, [loadSchemas]);
const onLoadData = async (node: EventDataNode<DataNode>) => {
const key = node.key as string;
if (!key.startsWith('s::')) return;
if (node.children && node.children.length > 0) return;
const schema = key.slice(3);
try {
const tables = await fetchTables(schema);
const children: DataNode[] = tables.map((t) => ({
title: (
<Space size={4}>
<span>{t.name}</span>
<Text type="secondary" style={{ fontSize: 11 }}>({t.row_count.toLocaleString()})</Text>
</Space>
),
key: tableKey(schema, t.name), icon: <TableOutlined />, isLeaf: true,
}));
setTreeData((prev) => prev.map((n) => n.key === key ? { ...n, children } : n));
} catch { message.error(`加载 ${schema} 的表列表失败`); }
};
const onSelectNode = async (_: React.Key[], info: { node: DataNode }) => {
const key = info.node.key as string;
const parsed = parseTableKey(key);
if (!parsed) return;
setSelectedTable(parsed);
setLoadingColumns(true);
setQueryResult(null);
try {
const cols = await fetchColumns(parsed.schema, parsed.table);
setColumnData(cols);
setSql(`SELECT * FROM ${parsed.schema}.${parsed.table} LIMIT 100;`);
} catch { message.error('加载列定义失败'); setColumnData([]); }
finally { setLoadingColumns(false); }
};
const handleExecute = async () => {
const trimmed = sql.trim();
if (!trimmed) { message.warning('请输入 SQL 语句'); return; }
setLoadingQuery(true);
try {
const result = await executeQuery(trimmed);
setQueryResult(result);
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } } };
const msg = axiosErr.response?.data?.detail ?? (err instanceof Error ? err.message : '查询执行失败');
message.error(msg);
setQueryResult(null);
} finally { setLoadingQuery(false); }
};
const handleCopySql = () => {
navigator.clipboard.writeText(sql).then(() => message.success('已复制'));
};
const columnDefColumns: ColumnsType<ColumnInfo> = [
{ title: '列名', dataIndex: 'name', key: 'name', render: (v: string) => <code>{v}</code> },
{ title: '数据类型', dataIndex: 'data_type', key: 'data_type' },
{
title: '可空', dataIndex: 'is_nullable', key: 'is_nullable', width: 70, align: 'center',
render: (v: boolean) => v ? <Tag color="orange">YES</Tag> : <Tag color="blue">NO</Tag>,
},
{
title: '默认值', dataIndex: 'default_value', key: 'default_value',
render: (v: string | null) => v != null ? <code style={{ fontSize: 12 }}>{v}</code> : <Text type="secondary"></Text>,
},
];
const resultColumns: ColumnsType<Record<string, unknown>> = queryResult
? queryResult.columns.map((col, idx) => ({
title: col, dataIndex: String(idx), key: col, ellipsis: true,
render: (v: unknown) => {
if (v === null || v === undefined) return <Text type="secondary">NULL</Text>;
return String(v);
},
}))
: [];
const resultDataSource: Record<string, unknown>[] = queryResult
? queryResult.rows.map((row, rowIdx) => {
const obj: Record<string, unknown> = { _key: rowIdx };
row.forEach((cell, colIdx) => { obj[String(colIdx)] = cell; });
return obj;
})
: [];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<DatabaseOutlined style={{ marginRight: 8 }} />
</Title>
{selectedTable && (
<Tag color="blue">{selectedTable.schema}.{selectedTable.table}</Tag>
)}
</div>
<div style={{ display: 'flex', flex: 1, gap: 12, minHeight: 0 }}>
{/* 左侧树 */}
<Card
size="small"
title="Schema / 表"
extra={<Button size="small" icon={<ReloadOutlined />} onClick={loadSchemas} loading={loadingTree} />}
style={{ width: 260, minWidth: 260, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
>
<Spin spinning={loadingTree}>
<Tree
showIcon treeData={treeData} loadData={onLoadData}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys)}
onSelect={onSelectNode}
/>
</Spin>
</Card>
{/* 右侧 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* SQL 编辑器 */}
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 12px' } }}>
<TextArea
rows={5} value={sql} onChange={(e) => setSql(e.target.value)}
placeholder="输入 SQL 查询语句…"
style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", fontSize: 13, marginBottom: 8 }}
onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); handleExecute(); } }}
/>
<Space>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleExecute} loading={loadingQuery}>
<Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>(Ctrl+Enter)</Text>
</Button>
<Tooltip title="复制 SQL">
<Button icon={<CopyOutlined />} onClick={handleCopySql} />
</Tooltip>
</Space>
</Card>
{/* 结果区域 */}
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
>
{queryResult ? (
<>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"> {queryResult.row_count} </Text>
</div>
<Table<Record<string, unknown>>
rowKey="_key" columns={resultColumns} dataSource={resultDataSource}
pagination={{ pageSize: 50, showSizeChanger: false, showTotal: (t) => `${t}` }}
size="small" scroll={{ x: 'max-content' }} bordered
/>
</>
) : selectedTable ? (
<>
<div style={{ marginBottom: 8 }}>
<Text strong>{selectedTable.schema}.{selectedTable.table}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}></Text>
</div>
<Table<ColumnInfo>
rowKey="name" columns={columnDefColumns} dataSource={columnData}
loading={loadingColumns} pagination={false} size="small" bordered
/>
</>
) : (
<div style={{ color: '#bbb', textAlign: 'center', marginTop: 60 }}>
SQL
</div>
)}
</Card>
</div>
</div>
</div>
);
};
export default DBViewer;

View File

@@ -0,0 +1,137 @@
/**
* ETL 状态监控页面。
*
* - 游标状态 Table
* - 最近执行记录 Table
*/
import React, { useEffect, useState, useCallback } from 'react';
import { Table, Tag, Button, message, Typography, Card, Row, Col, Statistic } from 'antd';
import { ReloadOutlined, DashboardOutlined, DatabaseOutlined, PlayCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
fetchCursors, fetchRecentRuns,
type CursorInfo, type RecentRun,
} from '../api/etlStatus';
const { Title, Text } = Typography;
const STATUS_COLOR: Record<string, string> = {
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
};
function formatTime(raw: string | null): string {
if (!raw) return '—';
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
}
function formatDuration(ms: number | null): string {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainSec = seconds % 60;
return `${minutes}m ${remainSec}s`;
}
const cursorColumns: ColumnsType<CursorInfo> = [
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) },
{
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
},
];
const runColumns: ColumnsType<RecentRun> = [
{ title: '任务名称', dataIndex: 'task_codes', key: 'task_codes', render: (codes: string[]) => codes.join(', ') || '—' },
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (status: string) => <Tag color={STATUS_COLOR[status] ?? 'default'}>{status}</Tag>,
},
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: (v: string) => formatTime(v) },
{ title: '执行时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 100, render: (v: number | null) => formatDuration(v) },
];
const ETLStatus: React.FC = () => {
const [cursors, setCursors] = useState<CursorInfo[]>([]);
const [runs, setRuns] = useState<RecentRun[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [c, r] = await Promise.all([fetchCursors(), fetchRecentRuns()]);
setCursors(c);
setRuns(r);
} catch { message.error('加载 ETL 状态失败'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
// 统计
const successCount = runs.filter((r) => r.status === 'success').length;
const failedCount = runs.filter((r) => r.status === 'failed').length;
const runningCount = runs.filter((r) => r.status === 'running').length;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<DashboardOutlined style={{ marginRight: 8 }} />
ETL
</Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
{/* 统计卡片 */}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small">
<Statistic title="游标数" value={cursors.length} prefix={<DatabaseOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="最近执行" value={runs.length} prefix={<PlayCircleOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="成功" value={successCount} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic
title="失败 / 运行中"
value={failedCount}
suffix={runningCount > 0 ? ` / ${runningCount}` : ''}
valueStyle={{ color: failedCount > 0 ? '#ff4d4f' : undefined }}
/>
</Card>
</Col>
</Row>
<Card size="small" title="游标状态" style={{ marginBottom: 12 }}>
<Table<CursorInfo>
rowKey="task_code" columns={cursorColumns} dataSource={cursors}
loading={loading} pagination={false} size="small"
/>
</Card>
<Card size="small" title="最近执行记录">
<Table<RecentRun>
rowKey="id" columns={runColumns} dataSource={runs}
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
/>
</Card>
</div>
);
};
export default ETLStatus;

View File

@@ -0,0 +1,164 @@
/**
* 环境配置页面。
*
* - Ant Design Table 展示键值对,支持 inline 编辑
* - 敏感值显示为 ****,编辑时可输入新值
* - 顶部按钮栏:刷新、保存、导出
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Table, Button, Input, Tag, Space, message, Card, Typography, Badge } from 'antd';
import type { InputRef } from 'antd';
import {
ReloadOutlined, SaveOutlined, DownloadOutlined, ToolOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { EnvConfigItem } from '../types';
import { fetchEnvConfig, updateEnvConfig, exportEnvConfig } from '../api/envConfig';
const { Title, Text } = Typography;
const MASK = '****';
const EnvConfig: React.FC = () => {
const [items, setItems] = useState<EnvConfigItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [dirtyMap, setDirtyMap] = useState<Record<string, string>>({});
const inputRef = useRef<InputRef>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchEnvConfig();
setItems(data);
setDirtyMap({});
setEditingKey(null);
} catch { message.error('加载环境配置失败'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const startEdit = (key: string, currentValue: string, isSensitive: boolean) => {
setEditingKey(key);
setEditValue(isSensitive ? '' : (dirtyMap[key] ?? currentValue));
setTimeout(() => { inputRef.current?.focus(); }, 0);
};
const confirmEdit = (key: string, originalValue: string, isSensitive: boolean) => {
const trimmed = editValue.trim();
if (isSensitive && trimmed === '') {
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
} else if (!isSensitive && trimmed === originalValue) {
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
} else {
setDirtyMap((prev) => ({ ...prev, [key]: trimmed }));
}
setEditingKey(null);
};
const cancelEdit = () => { setEditingKey(null); };
const handleSave = async () => {
if (Object.keys(dirtyMap).length === 0) { message.info('没有需要保存的修改'); return; }
setSaving(true);
try {
const payload = items.map((item) => ({
key: item.key,
value: dirtyMap[item.key] ?? item.value,
is_sensitive: item.is_sensitive,
}));
await updateEnvConfig(payload);
message.success('保存成功');
await load();
} catch { message.error('保存失败'); }
finally { setSaving(false); }
};
const handleExport = async () => {
setExporting(true);
try { await exportEnvConfig(); message.success('导出成功'); }
catch { message.error('导出失败'); }
finally { setExporting(false); }
};
const columns: ColumnsType<EnvConfigItem> = [
{
title: '键名', dataIndex: 'key', key: 'key', width: '35%',
render: (text: string) => <code style={{ fontSize: 12 }}>{text}</code>,
},
{
title: '值', dataIndex: 'value', key: 'value', width: '50%',
render: (_: string, record: EnvConfigItem) => {
if (editingKey === record.key) {
return (
<Input
ref={inputRef} value={editValue} size="small"
placeholder={record.is_sensitive ? '输入新值(留空则不修改)' : undefined}
onChange={(e) => setEditValue(e.target.value)}
onPressEnter={() => confirmEdit(record.key, record.value, record.is_sensitive)}
onBlur={() => confirmEdit(record.key, record.value, record.is_sensitive)}
onKeyDown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
style={{ fontFamily: 'monospace' }}
/>
);
}
const isDirty = record.key in dirtyMap;
const displayValue = record.is_sensitive
? (isDirty ? MASK + ' (已修改)' : MASK)
: (isDirty ? dirtyMap[record.key] : record.value);
return (
<span
style={{ cursor: 'pointer', color: isDirty ? '#1677ff' : undefined, fontFamily: 'monospace', fontSize: 12 }}
onClick={() => startEdit(record.key, record.value, record.is_sensitive)}
title="点击编辑"
>
{displayValue || <Text type="secondary"></Text>}
</span>
);
},
},
{
title: '类型', dataIndex: 'is_sensitive', key: 'is_sensitive', width: '15%', align: 'center',
render: (v: boolean) => v ? <Tag color="red"></Tag> : <Tag color="green"></Tag>,
},
];
const hasDirty = Object.keys(dirtyMap).length > 0;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<ToolOutlined style={{ marginRight: 8 }} />
</Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
<Badge count={hasDirty ? Object.keys(dirtyMap).length : 0} size="small">
<Button
type="primary" icon={<SaveOutlined />}
onClick={handleSave} loading={saving} disabled={!hasDirty}
>
</Button>
</Badge>
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}></Button>
</Space>
</div>
<Card size="small">
<Table<EnvConfigItem>
rowKey="key" columns={columns} dataSource={items}
loading={loading} pagination={false} size="small"
/>
</Card>
</div>
);
};
export default EnvConfig;

View File

@@ -0,0 +1,138 @@
/**
* 日志查看器页面。
*
* - 输入执行 ID通过 WebSocket 实时接收日志
* - 支持加载历史日志
* - 关键词过滤(大小写不敏感)
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Input, Button, Space, message, Card, Typography, Tag, Badge } from "antd";
import {
LinkOutlined, DisconnectOutlined, HistoryOutlined,
FileTextOutlined, SearchOutlined, ClearOutlined,
} from "@ant-design/icons";
import { apiClient } from "../api/client";
import LogStream from "../components/LogStream";
const { Title, Text } = Typography;
/* ------------------------------------------------------------------ */
/* 纯函数:日志过滤 */
/* ------------------------------------------------------------------ */
export function filterLogLines(lines: string[], keyword: string): string[] {
if (!keyword.trim()) return lines;
const lower = keyword.toLowerCase();
return lines.filter((line) => line.toLowerCase().includes(lower));
}
/* ------------------------------------------------------------------ */
/* 页面组件 */
/* ------------------------------------------------------------------ */
const LogViewer: React.FC = () => {
const [executionId, setExecutionId] = useState("");
const [lines, setLines] = useState<string[]>([]);
const [filterKeyword, setFilterKeyword] = useState("");
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
return () => { wsRef.current?.close(); };
}, []);
const handleConnect = useCallback(() => {
const id = executionId.trim();
if (!id) { message.warning("请输入执行 ID"); return; }
wsRef.current?.close();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${id}`);
wsRef.current = ws;
ws.onopen = () => { setConnected(true); message.success("WebSocket 已连接"); };
ws.onmessage = (event) => { setLines((prev) => [...prev, event.data]); };
ws.onclose = () => { setConnected(false); };
ws.onerror = () => { message.error("WebSocket 连接失败"); setConnected(false); };
}, [executionId]);
const handleDisconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
setConnected(false);
}, []);
const handleLoadHistory = useCallback(async () => {
const id = executionId.trim();
if (!id) { message.warning("请输入执行 ID"); return; }
try {
const { data } = await apiClient.get<{ execution_id: string; output_log: string | null; error_log: string | null }>(
`/execution/${id}/logs`
);
const parts: string[] = [];
if (data.output_log) parts.push(data.output_log);
if (data.error_log) parts.push(data.error_log);
const historyLines = parts.join("\n").split("\n");
setLines(historyLines);
message.success("历史日志加载完成");
} catch { message.error("加载历史日志失败"); }
}, [executionId]);
const handleClear = useCallback(() => { setLines([]); }, []);
const filteredLines = filterLogLines(lines, filterKeyword);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ marginBottom: 12, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={4} style={{ margin: 0 }}>
<FileTextOutlined style={{ marginRight: 8 }} />
</Title>
<Space>
{connected && <Badge status="processing" text={<Text type="success"></Text>} />}
<Tag>{lines.length} </Tag>
{filterKeyword && <Tag color="blue">{filteredLines.length} </Tag>}
</Space>
</div>
{/* 操作栏 */}
<Card size="small" style={{ marginBottom: 12 }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<Space>
<Input
placeholder="执行 ID"
value={executionId}
onChange={(e) => setExecutionId(e.target.value)}
style={{ width: 280, fontFamily: "monospace" }}
onPressEnter={handleConnect}
allowClear
/>
{connected ? (
<Button icon={<DisconnectOutlined />} danger onClick={handleDisconnect}></Button>
) : (
<Button type="primary" icon={<LinkOutlined />} onClick={handleConnect}></Button>
)}
<Button icon={<HistoryOutlined />} onClick={handleLoadHistory}></Button>
<Button icon={<ClearOutlined />} onClick={handleClear} disabled={lines.length === 0}></Button>
</Space>
<Input
prefix={<SearchOutlined />}
placeholder="过滤关键词..."
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
allowClear
style={{ width: 220 }}
/>
</Space>
</Card>
{/* 日志流 */}
<div style={{ flex: 1, minHeight: 0 }}>
<LogStream executionId={executionId} lines={filteredLines} />
</div>
</div>
);
};
export default LogViewer;

View File

@@ -0,0 +1,92 @@
/**
* 登录页面 — Ant Design Form + Zustand authStore。
*/
import React, { useState } from "react";
import { Button, Card, Form, Input, message, Typography, Space } from "antd";
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../store/authStore";
const { Title, Text } = Typography;
interface LoginFormValues {
username: string;
password: string;
}
const Login: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const [loading, setLoading] = useState(false);
const onFinish = async (values: LoginFormValues) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success("登录成功");
navigate("/", { replace: true });
} catch (err: unknown) {
const detail =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail ?? "登录失败,请检查用户名和密码";
message.error(detail);
} finally {
setLoading(false);
}
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
}}
>
<Card
style={{
width: 400,
borderRadius: 12,
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
}}
>
<Space direction="vertical" style={{ width: "100%", textAlign: "center", marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}>NeoZQYY</Title>
<Text type="secondary"></Text>
</Space>
<Form<LoginFormValues>
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,573 @@
/**
* 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,
} from "antd";
import {
SendOutlined,
ThunderboltOutlined,
CodeOutlined,
SettingOutlined,
ClockCircleOutlined,
SyncOutlined,
ShopOutlined,
ApiOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import TaskSelector from "../components/TaskSelector";
import { validateTaskConfig } from "../api/tasks";
import { submitToQueue, executeDirectly } from "../api/execution";
import { useAuthStore } from "../store/authStore";
import type { RadioChangeEvent } from "antd";
import type { Dayjs } from "dayjs";
import type { TaskConfig as TaskConfigType } from "../types";
const { Title, Text } = Typography;
const { TextArea } = Input;
/* ------------------------------------------------------------------ */
/* Flow 定义 */
/* ------------------------------------------------------------------ */
const FLOW_DEFINITIONS: Record<string, { name: string; layers: string[]; desc: string }> = {
api_ods: { name: "API → ODS", layers: ["ODS"], desc: "仅抓取原始数据" },
api_ods_dwd: { name: "API → ODS → DWD", layers: ["ODS", "DWD"], desc: "抓取并清洗装载" },
api_full: { name: "API → ODS → DWD → DWS → INDEX", layers: ["ODS", "DWD", "DWS", "INDEX"], desc: "全链路执行" },
ods_dwd: { name: "ODS → DWD", layers: ["DWD"], desc: "仅清洗装载" },
dwd_dws: { name: "DWD → DWS汇总", layers: ["DWS"], desc: "仅汇总计算" },
dwd_dws_index: { name: "DWD → DWS → INDEX", layers: ["DWS", "INDEX"], desc: "汇总+指数" },
dwd_index: { name: "DWD → INDEX", layers: ["INDEX"], desc: "仅指数计算" },
};
export function getFlowLayers(flowId: string): string[] {
return FLOW_DEFINITIONS[flowId]?.layers ?? [];
}
/* ------------------------------------------------------------------ */
/* 处理模式 */
/* ------------------------------------------------------------------ */
const PROCESSING_MODES = [
{ value: "increment_only", label: "仅增量", desc: "按游标增量抓取和装载" },
{ value: "verify_only", label: "校验并修复", desc: "对比源和目标,修复差异" },
{ value: "increment_verify", label: "增量+校验", desc: "先增量再校验" },
] as const;
/* ------------------------------------------------------------------ */
/* 时间窗口 */
/* ------------------------------------------------------------------ */
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);
/* ---------- 连接器 & 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 [dryRun, setDryRun] = useState(false);
const [forceFull, setForceFull] = useState(false);
const [useLocalJson, setUseLocalJson] = useState(false);
/* ---------- CLI 预览 ---------- */
const [cliCommand, setCliCommand] = useState<string>("");
const [cliEdited, setCliEdited] = useState(false);
const [cliLoading, setCliLoading] = useState(false);
/* ---------- 提交状态 ---------- */
const [submitting, setSubmitting] = useState(false);
/* ---------- 派生状态 ---------- */
const layers = getFlowLayers(flow);
const showVerifyOption = processingMode === "verify_only";
/* ---------- 构建 TaskConfig 对象 ---------- */
const buildTaskConfig = (): TaskConfigType => ({
tasks: selectedTasks,
pipeline: 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,
dwd_only_tables: selectedDwdTables.length > 0 ? selectedDwdTables : null,
force_full: forceFull,
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]);
/* ---------- 事件处理 ---------- */
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
const handleSubmitToQueue = async () => {
setSubmitting(true);
try {
await submitToQueue(buildTaskConfig());
message.success("已提交到执行队列");
navigate("/task-manager");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "提交失败";
message.error(`提交到队列失败:${msg}`);
} finally {
setSubmitting(false);
}
};
const handleExecuteDirectly = async () => {
setSubmitting(true);
try {
await executeDirectly(buildTaskConfig());
message.success("任务已开始执行");
navigate("/task-manager");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "执行失败";
message.error(`直接执行失败:${msg}`);
} finally {
setSubmitting(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="执行流程 (Flow)" style={cardStyle}>
<Radio.Group value={flow} onChange={handleFlowChange} style={{ width: "100%" }}>
<Row gutter={[0, 4]}>
{Object.entries(FLOW_DEFINITIONS).map(([id, def]) => (
<Col span={12} key={id}>
<Tooltip title={def.desc}>
<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%" }}>
{PROCESSING_MODES.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: 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>
</Card>
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
<Card size="small" title="任务选择" style={cardStyle}>
<TaskSelector
layers={layers}
selectedTasks={selectedTasks}
onTasksChange={setSelectedTasks}
selectedDwdTables={selectedDwdTables}
onDwdTablesChange={setSelectedDwdTables}
/>
</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 }}>
<Space size="middle">
<Button
type="primary"
size="large"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmitToQueue}
>
</Button>
<Button
size="large"
icon={<ThunderboltOutlined />}
loading={submitting}
onClick={handleExecuteDirectly}
>
</Button>
</Space>
</Card>
</div>
);
};
export default TaskConfig;

View File

@@ -0,0 +1,255 @@
/**
* 任务管理页面。
*
* 三个 Tab队列、调度、历史
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
Typography, Descriptions, Empty,
} from 'antd';
import {
ReloadOutlined, DeleteOutlined, StopOutlined,
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { QueuedTask, ExecutionLog } from '../types';
import {
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
} from '../api/execution';
import ScheduleTab from '../components/ScheduleTab';
const { Title, Text } = Typography;
/* ------------------------------------------------------------------ */
/* 状态颜色映射 */
/* ------------------------------------------------------------------ */
const STATUS_COLOR: Record<string, string> = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
cancelled: 'warning',
};
/* ------------------------------------------------------------------ */
/* 工具函数 */
/* ------------------------------------------------------------------ */
function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('zh-CN');
}
function fmtDuration(ms: number | null | undefined): string {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
const sec = ms / 1000;
if (sec < 60) return `${sec.toFixed(1)}s`;
const min = Math.floor(sec / 60);
const remainSec = Math.round(sec % 60);
return `${min}m${remainSec}s`;
}
/* ------------------------------------------------------------------ */
/* 队列 Tab */
/* ------------------------------------------------------------------ */
const QueueTab: React.FC = () => {
const [data, setData] = useState<QueuedTask[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try { setData(await fetchQueue()); }
catch { message.error('加载队列失败'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleDelete = async (id: string) => {
try { await deleteFromQueue(id); message.success('已删除'); load(); }
catch { message.error('删除失败'); }
};
const handleCancel = async (id: string) => {
try { await cancelExecution(id); message.success('已取消'); load(); }
catch { message.error('取消失败'); }
};
const columns: ColumnsType<QueuedTask> = [
{
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
render: (tasks: string[]) => (
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: tasks?.join(', ') }}>
{tasks?.join(', ') ?? '—'}
</Text>
),
},
{
title: 'Flow', dataIndex: ['config', 'pipeline'], key: 'pipeline', width: 120,
render: (v: string) => <Tag>{v}</Tag>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
},
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
{
title: '操作', key: 'action', width: 100, align: 'center',
render: (_: unknown, record: QueuedTask) => {
if (record.status === 'pending') {
return (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger icon={<DeleteOutlined />} size="small"></Button>
</Popconfirm>
);
}
if (record.status === 'running') {
return (
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
<Button type="link" danger icon={<StopOutlined />} size="small"></Button>
</Popconfirm>
);
}
return null;
},
},
];
return (
<>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {data.length} </Text>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Table<QueuedTask>
rowKey="id" columns={columns} dataSource={data}
loading={loading} pagination={false} size="small"
locale={{ emptyText: <Empty description="队列为空" /> }}
/>
</>
);
};
/* ------------------------------------------------------------------ */
/* 历史 Tab */
/* ------------------------------------------------------------------ */
const HistoryTab: React.FC = () => {
const [data, setData] = useState<ExecutionLog[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<ExecutionLog | null>(null);
const load = useCallback(async () => {
setLoading(true);
try { setData(await fetchHistory()); }
catch { message.error('加载历史记录失败'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const columns: ColumnsType<ExecutionLog> = [
{
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
render: (codes: string[]) => (
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: codes?.join(', ') }}>
{codes?.join(', ') ?? '—'}
</Text>
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
},
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: fmtTime },
{ title: '时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: fmtDuration },
{
title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 70, align: 'center',
render: (v: number | null) => v != null ? (
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
) : '—',
},
];
return (
<>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {data.length} </Text>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Table<ExecutionLog>
rowKey="id" columns={columns} dataSource={data}
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
/>
<Drawer
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
width={520}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
<Descriptions.Item label="退出码">
{detail.exit_code != null ? (
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="命令">
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</>
);
};
/* ------------------------------------------------------------------ */
/* 主组件 */
/* ------------------------------------------------------------------ */
const TaskManager: React.FC = () => {
const items = [
{
key: 'queue',
label: <Space><UnorderedListOutlined /></Space>,
children: <QueueTab />,
},
{
key: 'schedule',
label: <Space><ClockCircleOutlined /></Space>,
children: <ScheduleTab />,
},
{
key: 'history',
label: <Space><HistoryOutlined /></Space>,
children: <HistoryTab />,
},
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<UnorderedListOutlined style={{ marginRight: 8 }} />
</Title>
<Tabs defaultActiveKey="queue" items={items} />
</div>
);
};
export default TaskManager;