feat: 重构运行日志界面

This commit is contained in:
yu 2026-10-28 10:32:19 +08:00
parent 4fb0f64d4f
commit 9cb4eeab96
4 changed files with 488 additions and 35 deletions

View File

@ -0,0 +1,134 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, Check, Copy, Download } from "lucide-react";
import type { RunLog } from "../store/runStore";
type LogFilter = "all" | "info" | "warn" | "error";
const LEVEL_LABEL: Record<LogFilter, string> = {
all: "全部",
info: "信息",
warn: "警告",
error: "错误"
};
interface RunLogViewProps {
logs: RunLog[];
nameOf: (nodeId?: string) => string;
fileName?: string;
emptyText?: string;
className?: string;
}
export function RunLogView({ logs, nameOf, fileName = "rpa-run-logs.log", emptyText = "运行日志会显示在这里…", className }: RunLogViewProps) {
const [filter, setFilter] = useState<LogFilter>("all");
const [copied, setCopied] = useState(false);
const [pinned, setPinned] = useState(true);
const consoleRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const counts = useMemo(() => ({
all: logs.length,
info: logs.filter((log) => log.level === "info").length,
warn: logs.filter((log) => log.level === "warn").length,
error: logs.filter((log) => log.level === "error").length
}), [logs]);
const visibleLogs = useMemo(() => (
filter === "all" ? logs : logs.filter((log) => log.level === filter)
), [filter, logs]);
const logText = useMemo(() => (
logs.map((log) => `[${log.time}] [${log.level.toUpperCase()}] ${nameOf(log.nodeId)} - ${log.message}`).join("\n")
), [logs, nameOf]);
useLayoutEffect(() => {
const element = consoleRef.current;
if (element && pinnedRef.current) element.scrollTop = element.scrollHeight;
}, [visibleLogs]);
const trackPin = () => {
const element = consoleRef.current;
if (!element) return;
const nextPinned = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
if (nextPinned !== pinnedRef.current) {
pinnedRef.current = nextPinned;
setPinned(nextPinned);
}
};
const scrollToLatest = () => {
pinnedRef.current = true;
const element = consoleRef.current;
if (element) element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
};
const copyLogs = async () => {
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard access can be blocked by system policy; download remains available.
}
};
const downloadLogs = () => {
const blob = new Blob([logText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
};
return (
<div className={`run-log-view ${className || ""}`}>
<div className="run-log-toolbar">
<div className="run-log-filters" role="group" aria-label="日志级别过滤">
{(Object.keys(LEVEL_LABEL) as LogFilter[]).map((level) => (
<button
type="button"
key={level}
className={`run-log-filter ${level}${filter === level ? " active" : ""}`}
onClick={() => setFilter(level)}
aria-pressed={filter === level}
>
{LEVEL_LABEL[level]}
<small>{counts[level]}</small>
</button>
))}
</div>
<span className="spacer" />
<button type="button" className="run-log-action" onClick={() => void copyLogs()} disabled={!logs.length}>
{copied ? <Check className="icon" /> : <Copy className="icon" />}
{copied ? "已复制" : "复制"}
</button>
<button type="button" className="run-log-action" onClick={downloadLogs} disabled={!logs.length}>
<Download className="icon" />
导出
</button>
</div>
<div className="run-log-console-wrap">
<div className="run-log-console" ref={consoleRef} onScroll={trackPin} role="log" aria-live="polite">
{!visibleLogs.length && <div className="run-log-empty">{logs.length ? "当前级别暂无日志。" : emptyText}</div>}
{visibleLogs.map((log) => (
<div className={`run-log-row ${log.level}`} key={log.id}>
<span className="run-log-time">{log.time}</span>
<span className="run-log-level">{LEVEL_LABEL[log.level]}</span>
<span className="run-log-node" title={log.nodeId ? nameOf(log.nodeId) : "流程"}>{log.nodeId ? nameOf(log.nodeId) : "流程"}</span>
<span className="run-log-message">{log.message || "—"}</span>
</div>
))}
</div>
{!pinned && visibleLogs.length > 0 && (
<button type="button" className="run-log-latest" onClick={scrollToLatest}>
<ArrowDown className="icon" />
回到最新
</button>
)}
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import { useMemo } from "react";
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal, Timer } from "lucide-react";
import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal } from "lucide-react";
import { useEditorStore } from "../store/editorStore";
import { useRunStore } from "../store/runStore";
import { RunLogView } from "../components/RunLogView";
const STATUS_LABEL: Record<string, string> = {
running: "运行中", succeeded: "成功", failed: "失败", skipped: "已跳过", cancelled: "已取消", idle: "待运行"
@ -26,7 +27,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
const component = catalog?.components.find((item) => item.id === node.componentId);
map.set(node.id, node.label || component?.name || node.componentId);
}
return (id: string) => map.get(id) || id;
return (id?: string) => (id ? map.get(id) || id : "流程");
}, [catalog, flow]);
const entries = Object.entries(nodes);
@ -52,6 +53,7 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
</div>
</section>
<div className="run-main">
<section className="panel run-detail">
<div className="panel-head"><Play className="icon" /><strong>节点进度</strong><span className="spacer" /><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span></div>
<div className="run-detail-body">
@ -76,11 +78,9 @@ export function RunsPage({ onBack }: { onBack: () => void }) {
<section className="panel run-log-panel">
<div className="panel-head"><Terminal className="icon" /><strong>运行日志</strong><span className="spacer" /><span className="count-pill">{logs.length}</span></div>
<div className="run-page-logs">
{!logs.length && <div className="empty"><Timer className="icon" /><strong>暂无日志</strong><span>节点执行、重试和异常信息会实时显示。</span></div>}
{logs.map((log) => <div className={`run-page-log ${log.level}`} key={log.id}><span>{log.time}</span><strong>{log.nodeId ? nameOf(log.nodeId) : "流程"}</strong><p>{log.message}</p></div>)}
</div>
<RunLogView logs={logs} nameOf={nameOf} fileName={`${runId || "run"}.log`} emptyText="节点执行、重试和异常信息会实时显示。" />
</section>
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { CircleAlert, CircleCheck, Loader2, Square, Timer, X } from "lucide-react";
import { useEditorStore } from "../store/editorStore";
import { useRunStore } from "../store/runStore";
import { RunLogView } from "../components/RunLogView";
const STATUS_LABEL: Record<string, string> = {
running: "运行中",
@ -13,6 +14,7 @@ const STATUS_LABEL: Record<string, string> = {
};
export function RunDrawer() {
const [view, setView] = useState<"nodes" | "logs">("nodes");
const running = useRunStore((state) => state.running);
const stopping = useRunStore((state) => state.stopping);
const nodes = useRunStore((state) => state.nodes);
@ -55,6 +57,24 @@ export function RunDrawer() {
已完成 {finished.length}/{Object.keys(nodes).length}
</span>
<span className="spacer" />
<div className="run-drawer-tabs" role="tablist" aria-label="运行详情视图">
<button
type="button"
className={`run-drawer-tab${view === "nodes" ? " active" : ""}`}
onClick={() => setView("nodes")}
aria-pressed={view === "nodes"}
>
节点进度
</button>
<button
type="button"
className={`run-drawer-tab${view === "logs" ? " active" : ""}`}
onClick={() => setView("logs")}
aria-pressed={view === "logs"}
>
运行日志
</button>
</div>
{running ? (
<button type="button" className="btn" onClick={() => void stop()} disabled={stopping}>
<Square className="icon" /> 停止
@ -77,26 +97,20 @@ export function RunDrawer() {
)}
<div className="run-drawer-body">
<div className="run-node-list">
{Object.entries(nodes).length === 0 && <div className="field-hint">等待节点启动…</div>}
{Object.entries(nodes).map(([nodeId, value]) => (
<div className={`run-node-item ${value.status}`} key={nodeId}>
<span className="run-node-name">{nameOf(nodeId)}</span>
<span className="run-node-status">{STATUS_LABEL[value.status] || value.status}</span>
<span className="run-node-time">{value.durationMs == null ? "" : `${value.durationMs}ms`}</span>
</div>
))}
</div>
<div className="run-logs">
{logs.length === 0 && <div className="field-hint">运行日志会显示在这里…</div>}
{logs.map((log) => (
<div className={`run-log ${log.level}`} key={log.id}>
<span className="run-log-time">{log.time}</span>
<span className="run-log-node">{nameOf(log.nodeId)}</span>
<span className="run-log-message">{log.message}</span>
</div>
))}
</div>
{view === "nodes" ? (
<div className="run-node-list">
{Object.entries(nodes).length === 0 && <div className="field-hint">等待节点启动…</div>}
{Object.entries(nodes).map(([nodeId, value]) => (
<div className={`run-node-item ${value.status}`} key={nodeId}>
<span className="run-node-name">{nameOf(nodeId)}</span>
<span className="run-node-status">{STATUS_LABEL[value.status] || value.status}</span>
<span className="run-node-time">{value.durationMs == null ? "" : `${value.durationMs}ms`}</span>
</div>
))}
</div>
) : (
<RunLogView logs={logs} nameOf={nameOf} emptyText="运行日志会显示在这里…" />
)}
</div>
</div>
);

View File

@ -575,6 +575,7 @@ textarea:focus-visible {
/* ---------- 画布 ---------- */
.canvas-panel {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
@ -1282,11 +1283,19 @@ textarea:focus-visible {
.runs {
height: 100%;
display: grid;
grid-template-columns: 326px minmax(0, 1fr) 286px;
grid-template-columns: 324px minmax(0, 1fr);
gap: 14px;
min-height: 0;
}
.run-main {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(270px, 44%);
gap: 14px;
}
.panel-head {
height: 52px;
flex: none;
@ -1453,6 +1462,38 @@ textarea:focus-visible {
.run-page-actions .btn { justify-content: center; }
.run-error {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 10px;
border: 1px solid var(--danger-bg);
border-radius: 10px;
background: var(--danger-bg);
}
.run-error .icon {
width: 16px;
height: 16px;
flex: none;
color: var(--danger);
}
.run-error strong {
display: block;
color: var(--danger-deep);
font-size: 11.5px;
line-height: 17px;
}
.run-error span {
display: block;
margin-top: 2px;
color: var(--danger-deep);
font-size: 10.5px;
line-height: 15px;
}
.run-node-table {
margin-top: 10px;
border: 1px solid var(--line);
@ -1484,13 +1525,277 @@ textarea:focus-visible {
.run-node-main small { overflow: hidden; color: var(--danger); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.run-node-duration { color: var(--muted); font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
.run-page-logs { min-height: 0; flex: 1; overflow: auto; padding: 8px; }
.run-page-log { padding: 8px; border-bottom: 1px solid var(--line); font-size: 10px; line-height: 15px; }
.run-page-log > span { color: var(--faint); font-family: var(--mono); }
.run-page-log > strong { display: block; margin-top: 2px; color: var(--ink-2); }
.run-page-log > p { margin-top: 2px; color: var(--muted); overflow-wrap: anywhere; }
.run-page-log.warn > p { color: var(--warn-deep); }
.run-page-log.error > p { color: var(--danger); }
/* ---------- 运行日志组件 ---------- */
.run-log-view {
min-width: 0;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.run-log-toolbar {
min-height: 46px;
padding: 7px 10px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--line);
}
.run-log-filters {
padding: 2px;
display: inline-flex;
align-items: center;
gap: 2px;
border-radius: 9px;
background: var(--soft);
}
.run-log-filter {
height: 26px;
padding: 0 8px;
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 7px;
color: var(--muted);
font-size: 10.5px;
font-weight: 650;
}
.run-log-filter:hover { color: var(--ink); }
.run-log-filter.active {
color: var(--blue-dark);
background: #fff;
box-shadow: 0 1px 4px rgba(35, 55, 72, 0.1);
}
.run-log-filter small {
min-width: 17px;
padding: 0 4px;
border-radius: 8px;
background: rgba(78, 90, 107, 0.08);
color: var(--muted);
font-size: 9px;
line-height: 15px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.run-log-action {
height: 28px;
padding: 0 9px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
color: var(--ink-2);
font-size: 11px;
font-weight: 650;
}
.run-log-action .icon { width: 13px; height: 13px; }
.run-log-action:hover:not(:disabled) { color: var(--blue-dark); border-color: rgba(34, 100, 229, 0.22); background: #fff; }
.run-log-action:disabled { opacity: 0.45; cursor: not-allowed; }
.run-log-console-wrap {
position: relative;
flex: 1;
min-height: 0;
padding: 10px;
}
.run-log-console {
height: 100%;
overflow: auto;
border: 1px solid #243352;
border-radius: 10px;
background: #0f172a;
color: #dce6f7;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
}
.run-log-row {
min-width: 640px;
padding: 7px 10px;
display: grid;
grid-template-columns: 74px 48px minmax(130px, 190px) minmax(0, 1fr);
gap: 10px;
align-items: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
}
.run-log-row:hover { background: rgba(148, 163, 184, 0.09); }
.run-log-time { color: #8ea3c2; font-variant-numeric: tabular-nums; white-space: nowrap; }
.run-log-level { color: #a8b8d4; font-size: 10px; font-weight: 750; }
.run-log-node {
min-width: 0;
overflow: hidden;
color: #b9c8e2;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-log-message {
min-width: 0;
color: #dce6f7;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.run-log-row.warn .run-log-level,
.run-log-row.warn .run-log-message { color: #facc6b; }
.run-log-row.error .run-log-level,
.run-log-row.error .run-log-message { color: #ff8080; }
.run-log-row.error { background: rgba(248, 113, 113, 0.08); }
.run-log-empty {
min-height: 160px;
display: grid;
place-items: center;
color: #8ea3c2;
font-size: 11px;
}
.run-log-latest {
position: absolute;
right: 22px;
bottom: 22px;
height: 30px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #35486e;
border-radius: 15px;
background: #1e293b;
color: #dce6f7;
font-size: 10.5px;
font-weight: 700;
box-shadow: 0 8px 20px rgba(2, 6, 23, 0.32);
}
.run-log-latest .icon { width: 13px; height: 13px; }
/* ---------- 编排运行抽屉 ---------- */
.run-drawer {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
max-height: min(420px, 54%);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.82);
border-radius: 14px;
background: #fff;
box-shadow: 0 16px 44px rgba(15, 23, 42, 0.18);
z-index: 36;
}
.run-drawer-head {
min-height: 48px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
.run-drawer-meta {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 11px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.run-drawer-meta .icon { width: 13px; height: 13px; }
.run-drawer-tabs {
padding: 2px;
display: inline-flex;
align-items: center;
gap: 2px;
border-radius: 9px;
background: var(--soft);
}
.run-drawer-tab {
height: 26px;
padding: 0 9px;
border-radius: 7px;
color: var(--muted);
font-size: 10.5px;
font-weight: 700;
}
.run-drawer-tab:hover { color: var(--ink); }
.run-drawer-tab.active {
color: var(--blue-dark);
background: #fff;
box-shadow: 0 1px 4px rgba(35, 55, 72, 0.1);
}
.run-drawer-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.run-node-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 7px;
border-right: 0;
}
.run-node-item {
min-height: 38px;
padding: 6px 7px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 2px 7px;
align-items: center;
border-bottom: 1px solid var(--line);
border-left: 3px solid var(--faint);
}
.run-node-item:last-child { border-bottom: 0; }
.run-node-item.running { border-left-color: var(--blue); }
.run-node-item.succeeded { border-left-color: var(--success); }
.run-node-item.failed { border-left-color: var(--danger); }
.run-node-item.skipped,
.run-node-item.cancelled { border-left-color: var(--warn); }
.run-node-name {
min-width: 0;
overflow: hidden;
color: var(--ink);
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-node-status { color: var(--muted); font-size: 10px; }
.run-node-time { color: var(--faint); font-size: 9.5px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.run-logs {
min-width: 0;
min-height: 0;
display: flex;
}
/* ---------- 运行前检查 ---------- */
.preflight-backdrop {