完善建模交互、组件图标与系统协议
This commit is contained in:
1 parent
456c29b3b6
commit
6572defaa4
66 files changed
+10067
-4163
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user