微信小程序页面迁移校验之前 P5任务处理之前
This commit is contained in:
29
apps/admin-web/src/components/BusinessDayHint.tsx
Normal file
29
apps/admin-web/src/components/BusinessDayHint.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 营业日口径提示组件。
|
||||
*
|
||||
* 在日期选择器旁显示 Tooltip + 文字标注,说明当前营业日分割点。
|
||||
* 例如:「营业日:08:00 起」
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tooltip, Typography } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useBusinessDayStore } from "../store/businessDayStore";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BusinessDayHint: React.FC = () => {
|
||||
const startHour = useBusinessDayStore((s) => s.startHour);
|
||||
const hh = String(startHour).padStart(2, "0");
|
||||
|
||||
return (
|
||||
<Tooltip title={`统计日期按营业日口径划分:每天 ${hh}:00 至次日 ${hh}:00`}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginLeft: 4 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 2 }} />
|
||||
营业日:{hh}:00 起
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default BusinessDayHint;
|
||||
@@ -64,11 +64,16 @@ const LogStream: React.FC<LogStreamProps> = ({ lines }) => {
|
||||
{lines.length === 0 ? (
|
||||
<div style={{ color: "#888" }}>暂无日志</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
lines.map((line, i) => {
|
||||
let color = "#d4d4d4";
|
||||
if (/\bERROR\b/i.test(line)) color = "#f56c6c";
|
||||
else if (/\bWARN(?:ING)?\b/i.test(line)) color = "#e6a23c";
|
||||
return (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", color }}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
226
apps/admin-web/src/components/ScheduleHistoryDrawer.tsx
Normal file
226
apps/admin-web/src/components/ScheduleHistoryDrawer.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 调度任务执行历史抽屉组件。
|
||||
*
|
||||
* - 表格展示执行记录(ID、状态、开始时间、耗时、退出码),50 条/页分页
|
||||
* - 点击行打开详情(复用 LogStream 展示日志,running 任务走 WebSocket)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Drawer, Table, Tag, Typography, Descriptions, Spin, Space, message,
|
||||
} from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { ExecutionLog } from '../types';
|
||||
import { fetchScheduleHistory } from '../api/schedules';
|
||||
import { apiClient } from '../api/client';
|
||||
import LogStream from './LogStream';
|
||||
|
||||
const { 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`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
scheduleId: string | null;
|
||||
scheduleName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ScheduleHistoryDrawer: React.FC<Props> = ({ open, scheduleId, scheduleName, onClose }) => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const closeWs = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
setWsConnected(false);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
if (!scheduleId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchScheduleHistory(scheduleId, p, 50));
|
||||
} catch {
|
||||
message.error('加载执行历史失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scheduleId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && scheduleId) {
|
||||
setPage(1);
|
||||
setDetail(null);
|
||||
load(1);
|
||||
}
|
||||
return () => { closeWs(); };
|
||||
}, [open, scheduleId, load, closeWs]);
|
||||
|
||||
const handleRowClick = useCallback(async (record: ExecutionLog) => {
|
||||
setDetail(record);
|
||||
setLogLines([]);
|
||||
setLogLoading(true);
|
||||
closeWs();
|
||||
|
||||
if (record.status === 'running') {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${record.id}`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => { setWsConnected(true); setLogLoading(false); };
|
||||
ws.onmessage = (event) => { setLogLines((prev) => [...prev, event.data]); };
|
||||
ws.onclose = () => { setWsConnected(false); };
|
||||
ws.onerror = () => {
|
||||
setWsConnected(false);
|
||||
apiClient.get<{ output_log: string | null; error_log: string | null }>(
|
||||
`/execution/${record.id}/logs`,
|
||||
).then(({ data: logData }) => {
|
||||
const parts: string[] = [];
|
||||
if (logData.output_log) parts.push(logData.output_log);
|
||||
if (logData.error_log) parts.push(logData.error_log);
|
||||
setLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||||
}).catch(() => {}).finally(() => setLogLoading(false));
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const { data: logData } = await apiClient.get<{
|
||||
output_log: string | null; error_log: string | null;
|
||||
}>(`/execution/${record.id}/logs`);
|
||||
const parts: string[] = [];
|
||||
if (logData.output_log) parts.push(logData.output_log);
|
||||
if (logData.error_log) parts.push(logData.error_log);
|
||||
setLogLines(parts.join('\n').split('\n').filter(Boolean));
|
||||
} catch { /* 静默 */ }
|
||||
finally { setLogLoading(false); }
|
||||
}
|
||||
}, [closeWs]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</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 (
|
||||
<Drawer
|
||||
title={`执行历史 — ${scheduleName}`}
|
||||
open={open}
|
||||
onClose={() => { closeWs(); setDetail(null); onClose(); }}
|
||||
width={800}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Table<ExecutionLog>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 50,
|
||||
onChange: (p) => { setPage(p); load(p); },
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 执行详情 */}
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<span>执行详情</span>
|
||||
{detail?.status === 'running' && (
|
||||
wsConnected ? <Tag color="processing">实时连接中</Tag> : <Tag>未连接</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={!!detail}
|
||||
onClose={() => { closeWs(); setDetail(null); }}
|
||||
width={700}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="执行 ID">{detail.id}</Descriptions.Item>
|
||||
<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>
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined />
|
||||
<Text strong>执行日志</Text>
|
||||
{logLoading && <Spin size="small" />}
|
||||
</div>
|
||||
<div style={{ height: 400 }}>
|
||||
<LogStream executionId={detail.id} lines={logLines} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleHistoryDrawer;
|
||||
@@ -10,19 +10,22 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message, Typography,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||||
import {
|
||||
fetchSchedules,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
toggleSchedule,
|
||||
runScheduleNow,
|
||||
} from '../api/schedules';
|
||||
import ScheduleHistoryDrawer from './ScheduleHistoryDrawer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 & 工具 */
|
||||
@@ -150,6 +153,11 @@ const ScheduleTab: React.FC = () => {
|
||||
const [scheduleType, setScheduleType] = useState<string>('daily');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
/* 执行历史抽屉状态 */
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyScheduleId, setHistoryScheduleId] = useState<string | null>(null);
|
||||
const [historyScheduleName, setHistoryScheduleName] = useState('');
|
||||
|
||||
/* 加载列表 */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -164,25 +172,6 @@ const ScheduleTab: React.FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* 打开创建 Modal */
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
schedule_config: {
|
||||
schedule_type: 'daily',
|
||||
interval_value: 1,
|
||||
interval_unit: 'hours',
|
||||
daily_time: dayjs('04:00', 'HH:mm'),
|
||||
weekly_days: [1],
|
||||
weekly_time: dayjs('04:00', 'HH:mm'),
|
||||
cron_expression: '0 4 * * *',
|
||||
},
|
||||
});
|
||||
setScheduleType('daily');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 打开编辑 Modal */
|
||||
const openEdit = (record: ScheduledTask) => {
|
||||
setEditing(record);
|
||||
@@ -199,13 +188,20 @@ const ScheduleTab: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 提交创建/编辑 */
|
||||
/* 打开执行历史 */
|
||||
const openHistory = (record: ScheduledTask) => {
|
||||
setHistoryScheduleId(record.id);
|
||||
setHistoryScheduleName(record.name);
|
||||
setHistoryOpen(true);
|
||||
};
|
||||
|
||||
/* 提交编辑 */
|
||||
const handleSubmit = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// 将 dayjs 对象转为字符串
|
||||
const cfg = { ...values.schedule_config };
|
||||
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
|
||||
cfg.daily_time = cfg.daily_time.format('HH:mm');
|
||||
@@ -227,47 +223,16 @@ const ScheduleTab: React.FC = () => {
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
} else {
|
||||
// 创建时使用默认 task_config(简化实现)
|
||||
await createSchedule({
|
||||
name: values.name,
|
||||
task_codes: [],
|
||||
task_config: {
|
||||
tasks: [],
|
||||
flow: 'api_full',
|
||||
processing_mode: 'increment_only',
|
||||
pipeline_flow: 'FULL',
|
||||
dry_run: false,
|
||||
window_mode: 'lookback',
|
||||
window_start: null,
|
||||
window_end: null,
|
||||
window_split: null,
|
||||
window_split_days: null,
|
||||
lookback_hours: 24,
|
||||
overlap_seconds: 600,
|
||||
fetch_before_verify: false,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: false,
|
||||
store_id: null,
|
||||
dwd_only_tables: null,
|
||||
force_full: false,
|
||||
extra_args: {},
|
||||
},
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已创建');
|
||||
}
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
// 表单验证失败,不做额外处理
|
||||
// 表单验证失败
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -294,8 +259,29 @@ const ScheduleTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* 手动执行一次(不更新调度间隔) */
|
||||
const handleRunNow = async (id: string) => {
|
||||
try {
|
||||
await runScheduleNow(id);
|
||||
message.success('已提交到执行队列');
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 表格列定义 */
|
||||
const columns: ColumnsType<ScheduledTask> = [
|
||||
{
|
||||
title: '调度 ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 11 }}>
|
||||
{id.slice(0, 8)}…
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
@@ -338,9 +324,17 @@ const ScheduleTab: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
width: 300,
|
||||
render: (_: unknown, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Popconfirm title="确认立即执行一次?(不影响调度间隔)" onConfirm={() => handleRunNow(record.id)}>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small">
|
||||
立即执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}>
|
||||
执行历史
|
||||
</Button>
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -356,15 +350,11 @@ const ScheduleTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建调度
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<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<ScheduledTask>
|
||||
@@ -376,9 +366,9 @@ const ScheduleTab: React.FC = () => {
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 创建/编辑 Modal */}
|
||||
{/* 编辑 Modal */}
|
||||
<Modal
|
||||
title={editing ? '编辑调度任务' : '新建调度任务'}
|
||||
title="编辑调度任务"
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
@@ -400,6 +390,14 @@ const ScheduleTab: React.FC = () => {
|
||||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 执行历史抽屉 */}
|
||||
<ScheduleHistoryDrawer
|
||||
open={historyOpen}
|
||||
scheduleId={historyScheduleId}
|
||||
scheduleName={historyScheduleName}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
313
apps/admin-web/src/components/TaskLogViewer.tsx
Normal file
313
apps/admin-web/src/components/TaskLogViewer.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* 按任务分组的日志展示组件。
|
||||
*
|
||||
* - 顶部:任务执行时间线概览(可点击跳转)
|
||||
* - 中部:按任务代码过滤搜索框
|
||||
* - 主体:Collapse 折叠面板,每个任务一个区块
|
||||
* - 展开后显示该任务的完整日志(时间戳、级别、消息)
|
||||
*
|
||||
* 需求: 10.2, 10.5, 10.6
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Timeline,
|
||||
Tag,
|
||||
Input,
|
||||
Empty,
|
||||
Badge,
|
||||
Space,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
groupLogsByTask,
|
||||
filterTaskGroups,
|
||||
type TaskLogGroup,
|
||||
type ParsedLogEntry,
|
||||
} from "../utils/taskLogParser";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态 → 颜色/图标映射 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
TaskLogGroup["status"],
|
||||
{ color: string; icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
success: { color: "green", icon: <CheckCircleOutlined />, label: "成功" },
|
||||
failed: { color: "red", icon: <CloseCircleOutlined />, label: "失败" },
|
||||
running: { color: "blue", icon: <ClockCircleOutlined />, label: "运行中" },
|
||||
unknown: { color: "default", icon: <QuestionCircleOutlined />, label: "未知" },
|
||||
};
|
||||
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
ERROR: "#ff4d4f",
|
||||
CRITICAL: "#ff4d4f",
|
||||
WARNING: "#faad14",
|
||||
INFO: "#52c41a",
|
||||
DEBUG: "#8c8c8c",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 日志级别标签 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LevelTag: React.FC<{ level: string }> = ({ level }) => (
|
||||
<span
|
||||
style={{
|
||||
color: LEVEL_COLOR[level] ?? "#8c8c8c",
|
||||
fontWeight: level === "ERROR" || level === "CRITICAL" ? 600 : 400,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 12,
|
||||
minWidth: 56,
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 单条日志行 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LogLine: React.FC<{ entry: ParsedLogEntry }> = ({ entry }) => (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
padding: "1px 0",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{entry.timestamp && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginRight: 8, fontFamily: "monospace" }}>
|
||||
{entry.timestamp}
|
||||
</Text>
|
||||
)}
|
||||
<LevelTag level={entry.level} />
|
||||
<span style={{ marginLeft: 8 }}>{entry.message}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 任务面板头部 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskPanelHeader: React.FC<{ group: TaskLogGroup }> = ({ group }) => {
|
||||
const cfg = STATUS_CONFIG[group.status];
|
||||
return (
|
||||
<Space size={12}>
|
||||
<Text strong style={{ fontFamily: "monospace" }}>{group.taskCode}</Text>
|
||||
<Tag color={cfg.color} icon={cfg.icon}>{cfg.label}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{group.entries.length} 条日志
|
||||
</Text>
|
||||
{group.counts.error > 0 && (
|
||||
<Badge count={group.counts.error} size="small" title="错误数" />
|
||||
)}
|
||||
{group.counts.warning > 0 && (
|
||||
<Badge
|
||||
count={group.counts.warning}
|
||||
size="small"
|
||||
color="#faad14"
|
||||
title="警告数"
|
||||
/>
|
||||
)}
|
||||
{group.startTime && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{group.startTime}
|
||||
{group.endTime && group.endTime !== group.startTime
|
||||
? ` → ${group.endTime}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 时间线概览 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskTimeline: React.FC<{
|
||||
groups: TaskLogGroup[];
|
||||
onClickTask: (taskCode: string) => void;
|
||||
}> = ({ groups, onClickTask }) => {
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const cfg = STATUS_CONFIG[g.status];
|
||||
return {
|
||||
color: cfg.color === "default" ? "gray" : cfg.color,
|
||||
children: (
|
||||
<div
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onClickTask(g.taskCode)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onClickTask(g.taskCode);
|
||||
}}
|
||||
>
|
||||
<Space size={8}>
|
||||
<Text strong style={{ fontFamily: "monospace", fontSize: 13 }}>
|
||||
{g.taskCode}
|
||||
</Text>
|
||||
<Tag color={cfg.color} style={{ fontSize: 11 }}>
|
||||
{cfg.label}
|
||||
</Tag>
|
||||
{g.startTime && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{g.startTime}
|
||||
{g.endTime && g.endTime !== g.startTime
|
||||
? ` → ${g.endTime}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
({g.entries.length} 条)
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 12, padding: "8px 12px", background: "#fafafa", borderRadius: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: "block" }}>
|
||||
任务执行时间线(点击跳转)
|
||||
</Text>
|
||||
<Timeline items={items} style={{ marginBottom: 0, paddingTop: 8 }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TaskLogViewerProps {
|
||||
/** 原始日志行数组 */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const TaskLogViewer: React.FC<TaskLogViewerProps> = ({ lines }) => {
|
||||
const [taskFilter, setTaskFilter] = useState("");
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
||||
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// 解析并分组
|
||||
const allGroups = useMemo(() => groupLogsByTask(lines), [lines]);
|
||||
const filteredGroups = useMemo(
|
||||
() => filterTaskGroups(allGroups, taskFilter),
|
||||
[allGroups, taskFilter],
|
||||
);
|
||||
|
||||
// 点击时间线跳转到对应任务面板
|
||||
const handleTimelineClick = useCallback((taskCode: string) => {
|
||||
// 展开目标面板
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(taskCode) ? prev : [...prev, taskCode],
|
||||
);
|
||||
// 滚动到面板位置
|
||||
requestAnimationFrame(() => {
|
||||
const el = panelRefs.current[taskCode];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPanelRef = useCallback(
|
||||
(taskCode: string) => (el: HTMLDivElement | null) => {
|
||||
panelRefs.current[taskCode] = el;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return <Empty description="暂无日志数据" />;
|
||||
}
|
||||
|
||||
// 如果没有可解析的任务分组(全是无法解析的行),回退到普通展示
|
||||
if (allGroups.length === 0) {
|
||||
return <Empty description="无法解析任务分组" />;
|
||||
}
|
||||
|
||||
const collapseItems = filteredGroups.map((group) => ({
|
||||
key: group.taskCode,
|
||||
label: <TaskPanelHeader group={group} />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{group.entries.map((entry, i) => (
|
||||
<LogLine key={i} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 时间线概览 */}
|
||||
<TaskTimeline groups={allGroups} onClickTask={handleTimelineClick} />
|
||||
|
||||
{/* 任务代码过滤 */}
|
||||
{allGroups.length > 1 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="按任务代码过滤..."
|
||||
value={taskFilter}
|
||||
onChange={(e) => setTaskFilter(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
|
||||
{filteredGroups.length} / {allGroups.length} 个任务
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 折叠面板 */}
|
||||
{filteredGroups.length === 0 ? (
|
||||
<Empty description="无匹配的任务" />
|
||||
) : (
|
||||
<>
|
||||
{filteredGroups.map((g) => (
|
||||
<div key={g.taskCode} ref={setPanelRef(g.taskCode)} />
|
||||
))}
|
||||
<Collapse
|
||||
activeKey={activeKeys}
|
||||
onChange={(keys) => setActiveKeys(keys as string[])}
|
||||
items={collapseItems}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskLogViewer;
|
||||
Reference in New Issue
Block a user