完善建模交互、组件图标与系统协议

This commit is contained in:
ljz committed 2026-08-15 17:40:18 +08:00
1 parent 456c29b3b6
commit 6572defaa4
66 files changed
+10067 -4163

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
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>
);
}