微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View 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;

View File

@@ -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>

View 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;

View File

@@ -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)}
/>
</>
);
};

View 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;