Files
SystemSimulationApp/frontend/src/DockedSimulationConsole.tsx
T

378 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import {
ChevronDown,
ChevronUp,
Square,
SquareTerminal,
Trash2,
} from "lucide-react";
type DockedConsoleMode = "minimized" | "normal" | "maximized";
type DockedConsoleLevel = "info" | "success" | "warning" | "error";
type DockedConsoleEntry = {
id: number;
timestamp: string;
level: DockedConsoleLevel;
message: string;
format: "message" | "xml";
};
type DockedConsoleProgress = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
type DockedSimulationConsoleProps = {
entries: DockedConsoleEntry[];
mode: DockedConsoleMode;
progress: DockedConsoleProgress;
onClear: () => void;
onModeChange: (mode: DockedConsoleMode) => void;
onStop: () => void;
stopPending: boolean;
};
type ResizeSession = {
pointerId: number;
startHeight: number;
startY: number;
};
const CONSOLE_DOCK_HEIGHT_KEY =
"system-simulation-flow:modeling-console-dock-height";
const DEFAULT_CONSOLE_DOCK_HEIGHT = 180;
const MIN_CONSOLE_DOCK_HEIGHT = 120;
const MAX_CONSOLE_DOCK_HEIGHT = 420;
const MIN_MODELING_CANVAS_HEIGHT = 240;
const COLLAPSED_CONSOLE_DOCK_HEIGHT = 40;
const consoleLevelLabels: Record<DockedConsoleLevel, string> = {
info: "信息",
success: "完成",
warning: "警告",
error: "错误",
};
function simulationProgressClockText(progress: DockedConsoleProgress) {
const simulatedTime = Number.isFinite(progress.simulatedTime)
? progress.simulatedTime
: progress.startTime;
const totalTime = Number.isFinite(progress.totalTime)
? progress.totalTime
: progress.startTime;
return `${simulatedTime.toFixed(2)} s / ${totalTime.toFixed(2)} s`;
}
function maximumConsoleDockHeight(hostHeight = window.innerHeight) {
return Math.max(
MIN_CONSOLE_DOCK_HEIGHT,
Math.min(
MAX_CONSOLE_DOCK_HEIGHT,
Math.round(hostHeight * 0.48),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, hostHeight - MIN_MODELING_CANVAS_HEIGHT),
),
);
}
function clampConsoleDockHeight(height: number, hostHeight = window.innerHeight) {
return Math.min(
maximumConsoleDockHeight(hostHeight),
Math.max(MIN_CONSOLE_DOCK_HEIGHT, Math.round(height)),
);
}
function loadConsoleDockHeight() {
try {
const stored = Number(localStorage.getItem(CONSOLE_DOCK_HEIGHT_KEY));
return Number.isFinite(stored) && stored > 0
? clampConsoleDockHeight(stored)
: DEFAULT_CONSOLE_DOCK_HEIGHT;
} catch {
return DEFAULT_CONSOLE_DOCK_HEIGHT;
}
}
function storeConsoleDockHeight(height: number) {
try {
localStorage.setItem(CONSOLE_DOCK_HEIGHT_KEY, String(height));
} catch {
// 本地存储不可用时仍可在当前页面调整高度。
}
}
function collapsedSummary(
entries: DockedConsoleEntry[],
progress: DockedConsoleProgress,
) {
if (progress.state === "running") {
return `${progress.message} · ${simulationProgressClockText(progress)} · ${progress.percent}%`;
}
const latestEntry = entries.at(-1);
if (!latestEntry) {
return progress.message;
}
return latestEntry.format === "xml"
? "系统 XML 已生成"
: latestEntry.message.replace(/\s+/g, " ").trim();
}
export function DockedSimulationConsole({
entries,
mode,
progress,
onClear,
onModeChange,
onStop,
stopPending,
}: DockedSimulationConsoleProps) {
const dockRef = useRef<HTMLElement>(null);
const logRef = useRef<HTMLDivElement>(null);
const resizeSessionRef = useRef<ResizeSession | null>(null);
const [height, setHeight] = useState(loadConsoleDockHeight);
const [hostHeight, setHostHeight] = useState(() => window.innerHeight);
const [resizing, setResizing] = useState(false);
const collapsed = mode === "minimized";
const canStop = progress.state === "running" && !stopPending;
const summary = useMemo(
() => collapsedSummary(entries, progress),
[entries, progress],
);
useEffect(() => {
if (!collapsed && logRef.current) {
logRef.current.scrollTop = logRef.current.scrollHeight;
}
}, [collapsed, entries]);
useEffect(() => {
if (!collapsed) {
storeConsoleDockHeight(height);
}
}, [collapsed, height]);
useEffect(() => {
const host = dockRef.current?.parentElement;
const updateHostHeight = () => {
const nextHostHeight = host?.clientHeight ?? window.innerHeight;
setHostHeight(nextHostHeight);
setHeight((current) => clampConsoleDockHeight(current, nextHostHeight));
};
updateHostHeight();
const observer =
host && typeof ResizeObserver !== "undefined"
? new ResizeObserver(updateHostHeight)
: null;
if (host) {
observer?.observe(host);
}
window.addEventListener("resize", updateHostHeight);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateHostHeight);
};
}, []);
const beginResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (collapsed || event.button !== 0) {
return;
}
event.preventDefault();
resizeSessionRef.current = {
pointerId: event.pointerId,
startHeight: height,
startY: event.clientY,
};
event.currentTarget.setPointerCapture(event.pointerId);
setResizing(true);
};
const resize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setHeight(
clampConsoleDockHeight(
session.startHeight + session.startY - event.clientY,
hostHeight,
),
);
};
const finishResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const session = resizeSessionRef.current;
if (!session || session.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizeSessionRef.current = null;
setResizing(false);
};
const consoleStyle = {
"--simulation-console-dock-height": `${
collapsed ? COLLAPSED_CONSOLE_DOCK_HEIGHT : height
}px`,
} as CSSProperties;
return (
<aside
aria-label="仿真控制台"
className={`simulation-console-dock ${collapsed ? "minimized" : "normal"} ${
resizing ? "resizing" : ""
}`}
data-editor-shortcuts-ignore="true"
ref={dockRef}
style={consoleStyle}
>
{!collapsed ? (
<div
aria-label="调整仿真控制台高度"
aria-orientation="horizontal"
aria-valuemax={maximumConsoleDockHeight(hostHeight)}
aria-valuemin={MIN_CONSOLE_DOCK_HEIGHT}
aria-valuenow={height}
className="simulation-console-dock-resizer"
onDoubleClick={() => setHeight(DEFAULT_CONSOLE_DOCK_HEIGHT)}
onKeyDown={(event) => {
const nextHeight =
event.key === "ArrowUp"
? height + 20
: event.key === "ArrowDown"
? height - 20
: event.key === "Home"
? MIN_CONSOLE_DOCK_HEIGHT
: event.key === "End"
? maximumConsoleDockHeight(hostHeight)
: null;
if (nextHeight === null) {
return;
}
event.preventDefault();
setHeight(clampConsoleDockHeight(nextHeight, hostHeight));
}}
onLostPointerCapture={finishResize}
onPointerCancel={finishResize}
onPointerDown={beginResize}
onPointerMove={resize}
onPointerUp={finishResize}
role="separator"
tabIndex={0}
title="拖动调整控制台高度,双击恢复默认高度"
/>
) : null}
<header className="simulation-console-dock-header">
<div className="simulation-console-dock-title">
<SquareTerminal aria-hidden="true" size={17} />
<strong>仿真控制台</strong>
<span className="simulation-console-dock-summary" title={summary}>
{summary}
</span>
</div>
<div className="simulation-console-dock-actions">
<button
aria-label={stopPending ? "正在停止仿真" : "停止仿真"}
className={canStop ? "active" : ""}
disabled={!canStop}
onClick={onStop}
title={canStop ? "停止当前仿真" : "当前没有可停止的仿真"}
type="button"
>
<Square aria-hidden="true" fill="currentColor" size={13} strokeWidth={1.8} />
</button>
{!collapsed ? (
<button
aria-label="清空控制台"
disabled={entries.length === 0}
onClick={onClear}
title="清空控制台"
type="button"
>
<Trash2 aria-hidden="true" size={16} />
</button>
) : null}
<button
aria-label={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
onClick={() => onModeChange(collapsed ? "normal" : "minimized")}
title={collapsed ? "展开仿真控制台" : "收起仿真控制台"}
type="button"
>
{collapsed ? (
<ChevronUp aria-hidden="true" size={17} />
) : (
<ChevronDown aria-hidden="true" size={17} />
)}
</button>
</div>
</header>
{!collapsed ? (
<>
<div className={`simulation-console-dock-progress ${progress.state}`}>
<div className="simulation-console-dock-progress-label">
<span>{progress.message}</span>
<span>{simulationProgressClockText(progress)}</span>
<strong>{progress.percent}%</strong>
</div>
<div
aria-label={`仿真进度 ${progress.percent}%,${simulationProgressClockText(progress)}`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progress.percent}
className="simulation-console-dock-progress-track"
role="progressbar"
>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
<div
aria-label="控制台日志"
className="simulation-console-dock-log"
ref={logRef}
role="region"
>
{entries.length === 0 ? (
<div className="simulation-console-dock-empty">暂无仿真消息</div>
) : (
entries.map((entry) => (
<div
className={`simulation-console-dock-entry ${entry.level} ${entry.format}`}
data-entry-id={entry.id}
key={entry.id}
>
<time>{entry.timestamp}</time>
<span className="simulation-console-dock-entry-level">
{consoleLevelLabels[entry.level]}
</span>
{entry.format === "xml" ? (
<pre
aria-label="生成的系统 XML"
className="simulation-console-dock-entry-xml"
>
{entry.message}
</pre>
) : (
<span className="simulation-console-dock-entry-message">
{entry.message}
</span>
)}
</div>
))
)}
</div>
</>
) : null}
</aside>
);
}