378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|