1414 lines
41 KiB
HTML
1414 lines
41 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>System Simulation App</title>
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
height: 100vh;
|
|
color: #1f2933;
|
|
font-family:
|
|
system-ui,
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
sans-serif;
|
|
background: #f4f7fb;
|
|
}
|
|
|
|
.app {
|
|
display: grid;
|
|
grid-template-rows: 48px minmax(0, 1fr);
|
|
height: 100vh;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 0 14px;
|
|
border-bottom: 1px solid #d6dde8;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.title {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
font-size: 15px;
|
|
font-weight: 650;
|
|
line-height: 1.2;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status {
|
|
max-width: 260px;
|
|
overflow: hidden;
|
|
color: #5d6877;
|
|
font-size: 13px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
button {
|
|
height: 32px;
|
|
padding: 0 12px;
|
|
border: 1px solid #1d6fb8;
|
|
border-radius: 6px;
|
|
color: #ffffff;
|
|
font: inherit;
|
|
font-size: 14px;
|
|
background: #1d6fb8;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button.secondary {
|
|
border-color: #c7d0dc;
|
|
color: #273646;
|
|
background: #ffffff;
|
|
}
|
|
|
|
button:disabled {
|
|
border-color: #a7b0bd;
|
|
color: #ffffff;
|
|
background: #a7b0bd;
|
|
cursor: wait;
|
|
}
|
|
|
|
.workbench {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 340px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.canvas {
|
|
min-width: 0;
|
|
min-height: 0;
|
|
}
|
|
|
|
iframe {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
border: 0;
|
|
}
|
|
|
|
.side-panel {
|
|
display: grid;
|
|
grid-template-rows: auto minmax(140px, 32%) minmax(0, 1fr);
|
|
min-height: 0;
|
|
border-left: 1px solid #d6dde8;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.panel-header,
|
|
.panel-section {
|
|
padding: 12px;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
border-bottom: 1px solid #e3e8ef;
|
|
}
|
|
|
|
.panel-title {
|
|
font-size: 14px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.component-list {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
border-bottom: 1px solid #e3e8ef;
|
|
}
|
|
|
|
.component-row {
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
min-height: 54px;
|
|
padding: 9px 12px;
|
|
border: 0;
|
|
border-bottom: 1px solid #edf1f5;
|
|
border-radius: 0;
|
|
color: #1f2933;
|
|
text-align: left;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.component-row:hover,
|
|
.component-row.active {
|
|
background: #eef6ff;
|
|
}
|
|
|
|
.component-name {
|
|
overflow: hidden;
|
|
font-size: 14px;
|
|
font-weight: 620;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.component-meta {
|
|
margin-top: 3px;
|
|
overflow: hidden;
|
|
color: #667387;
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.empty-state {
|
|
padding: 18px 12px;
|
|
color: #667387;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.parameter-panel {
|
|
min-height: 0;
|
|
overflow: auto;
|
|
}
|
|
|
|
.selected-heading {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
font-size: 15px;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.selected-meta {
|
|
margin-top: 4px;
|
|
overflow-wrap: anywhere;
|
|
color: #667387;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.field-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
gap: 5px;
|
|
}
|
|
|
|
.name-field {
|
|
margin-top: 14px;
|
|
}
|
|
|
|
.field label {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
color: #334155;
|
|
font-size: 13px;
|
|
font-weight: 590;
|
|
}
|
|
|
|
.field-key {
|
|
color: #7b8797;
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.input-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
input {
|
|
width: 100%;
|
|
height: 34px;
|
|
padding: 0 9px;
|
|
border: 1px solid #c7d0dc;
|
|
border-radius: 6px;
|
|
color: #1f2933;
|
|
font: inherit;
|
|
font-size: 14px;
|
|
background: #ffffff;
|
|
}
|
|
|
|
input:focus {
|
|
border-color: #1d6fb8;
|
|
outline: 2px solid rgba(29, 111, 184, 0.16);
|
|
}
|
|
|
|
.unit {
|
|
min-width: 26px;
|
|
color: #667387;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.workbench {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
grid-template-rows: minmax(0, 1fr) 360px;
|
|
}
|
|
|
|
.side-panel {
|
|
grid-template-rows: auto minmax(120px, 42%) minmax(0, 1fr);
|
|
border-top: 1px solid #d6dde8;
|
|
border-left: 0;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="app">
|
|
<header class="toolbar">
|
|
<div class="title">System Simulation App</div>
|
|
<div class="actions">
|
|
<span class="status" id="status">加载中</span>
|
|
<button id="refresh-button" class="secondary" type="button" disabled>
|
|
刷新组件
|
|
</button>
|
|
<button id="export-button" type="button" disabled>生成 XML</button>
|
|
</div>
|
|
</header>
|
|
<section class="workbench">
|
|
<div class="canvas">
|
|
<iframe id="drawio-frame" title="draw.io"></iframe>
|
|
</div>
|
|
<aside class="side-panel">
|
|
<div class="panel-header">
|
|
<div class="panel-title">组件参数</div>
|
|
</div>
|
|
<div class="component-list" id="component-list">
|
|
<div class="empty-state">暂无组件</div>
|
|
</div>
|
|
<div class="parameter-panel">
|
|
<div class="panel-section">
|
|
<h2 class="selected-heading" id="selected-heading">未选择组件</h2>
|
|
<div class="selected-meta" id="selected-meta"></div>
|
|
<form id="parameter-form">
|
|
<div class="field name-field">
|
|
<label for="component-name-input">
|
|
<span>组件名称</span>
|
|
<span class="field-key">name</span>
|
|
</label>
|
|
<input
|
|
id="component-name-input"
|
|
name="component_name"
|
|
type="text"
|
|
disabled
|
|
/>
|
|
</div>
|
|
<div class="field-grid" id="field-grid"></div>
|
|
<div class="form-actions">
|
|
<button id="apply-button" type="submit" disabled>
|
|
应用参数
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const DRAWIO_BASE_URL = __DRAWIO_BASE_URL__;
|
|
const frame = document.getElementById("drawio-frame");
|
|
const exportButton = document.getElementById("export-button");
|
|
const refreshButton = document.getElementById("refresh-button");
|
|
const applyButton = document.getElementById("apply-button");
|
|
const statusNode = document.getElementById("status");
|
|
const componentListNode = document.getElementById("component-list");
|
|
const selectedHeadingNode = document.getElementById("selected-heading");
|
|
const selectedMetaNode = document.getElementById("selected-meta");
|
|
const parameterForm = document.getElementById("parameter-form");
|
|
const fieldGridNode = document.getElementById("field-grid");
|
|
const componentNameInput = document.getElementById("component-name-input");
|
|
|
|
const drawioUrl = new URL(DRAWIO_BASE_URL, window.location.href);
|
|
const componentLibraryUrl = new URL(
|
|
"/api/component-library.drawiolib",
|
|
window.location.href,
|
|
);
|
|
const componentLibraryKey = `U${componentLibraryUrl.href}`;
|
|
|
|
drawioUrl.searchParams.set("embed", "1");
|
|
drawioUrl.searchParams.set("proto", "json");
|
|
drawioUrl.searchParams.set("libraries", "1");
|
|
drawioUrl.searchParams.set("clibs", componentLibraryKey);
|
|
drawioUrl.searchParams.set(
|
|
"cors",
|
|
`^${escapeRegExp(window.location.origin)}/`,
|
|
);
|
|
drawioUrl.searchParams.set("spin", "1");
|
|
drawioUrl.searchParams.set("noSaveBtn", "1");
|
|
drawioUrl.searchParams.set("noExitBtn", "1");
|
|
drawioUrl.searchParams.set("saveAndExit", "0");
|
|
|
|
const initialXml =
|
|
'<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>';
|
|
|
|
let componentSchemas = {};
|
|
let components = [];
|
|
let selectedComponentId = null;
|
|
let currentDiagramXml = initialXml;
|
|
let drawioReady = false;
|
|
let pendingDiagramRequest = null;
|
|
let selectedCanApply = false;
|
|
let autoRefreshTimeoutId = null;
|
|
let autoRefreshNeedsExport = false;
|
|
let selectionBridgeInstalled = false;
|
|
let selectionBridgeRetryCount = 0;
|
|
let selectionSyncInFlight = false;
|
|
const focusHighlightPrefix = "__component_focus_";
|
|
|
|
loadComponentSchemas();
|
|
|
|
function setStatus(text) {
|
|
statusNode.textContent = text;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function sendToDrawio(message) {
|
|
frame.contentWindow.postMessage(JSON.stringify(message), drawioUrl.origin);
|
|
}
|
|
|
|
frame.addEventListener("load", () => {
|
|
selectionBridgeInstalled = false;
|
|
selectionBridgeRetryCount = 0;
|
|
scheduleSelectionBridgeInstall();
|
|
});
|
|
|
|
async function focusComponentInDrawio(componentId) {
|
|
if (!drawioReady || !componentId) {
|
|
return;
|
|
}
|
|
|
|
const cells = [componentId];
|
|
sendToDrawio({ action: "select", cells });
|
|
sendToDrawio({
|
|
action: "highlight",
|
|
cells,
|
|
color: "#1d6fb8",
|
|
opacity: 80,
|
|
duration: 1200,
|
|
});
|
|
|
|
try {
|
|
const diagramXml = await requestDiagramXml("定位组件");
|
|
const highlightedXml = buildFocusHighlightedXml(diagramXml, componentId);
|
|
if (!highlightedXml) {
|
|
setStatus("未找到组件图元");
|
|
return;
|
|
}
|
|
|
|
sendToDrawio({
|
|
action: "load",
|
|
xml: highlightedXml,
|
|
title: "system.drawio",
|
|
autosave: 1,
|
|
});
|
|
setStatus("已定位组件");
|
|
} catch (error) {
|
|
console.warn(error);
|
|
setStatus("定位组件失败");
|
|
}
|
|
}
|
|
|
|
function parseMessage(data) {
|
|
if (typeof data !== "string") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function scheduleSelectionBridgeInstall() {
|
|
if (selectionBridgeInstalled || selectionBridgeRetryCount > 20) {
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
selectionBridgeRetryCount += 1;
|
|
if (!installSelectionBridge()) {
|
|
scheduleSelectionBridgeInstall();
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function installSelectionBridge() {
|
|
try {
|
|
const frameDocument = frame.contentDocument || frame.contentWindow?.document;
|
|
if (!frameDocument || frameDocument.readyState === "loading") {
|
|
return false;
|
|
}
|
|
|
|
frameDocument.addEventListener(
|
|
"mouseup",
|
|
(event) => {
|
|
const componentId = componentIdFromDomTarget(event.target);
|
|
if (componentId) {
|
|
void selectComponentFromCanvas(componentId);
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
const selectedComponentIdFromGraph = componentIdFromDrawioSelection();
|
|
if (selectedComponentIdFromGraph) {
|
|
void selectComponentFromCanvas(selectedComponentIdFromGraph);
|
|
}
|
|
}, 80);
|
|
},
|
|
true,
|
|
);
|
|
frameDocument.addEventListener(
|
|
"keyup",
|
|
() => {
|
|
const componentId = componentIdFromDrawioSelection();
|
|
if (componentId) {
|
|
void selectComponentFromCanvas(componentId);
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
selectionBridgeInstalled = true;
|
|
return true;
|
|
} catch (error) {
|
|
console.warn(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function selectComponentFromCanvas(componentId) {
|
|
if (!componentId) {
|
|
return false;
|
|
}
|
|
if (
|
|
componentId === selectedComponentId &&
|
|
components.some((component) => component.id === componentId)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (!components.some((component) => component.id === componentId)) {
|
|
await loadComponentsFromXml(currentDiagramXml);
|
|
}
|
|
if (!components.some((component) => component.id === componentId)) {
|
|
return false;
|
|
}
|
|
|
|
selectedComponentId = componentId;
|
|
renderComponentList();
|
|
renderParameterForm();
|
|
componentListNode
|
|
.querySelector(".component-row.active")
|
|
?.scrollIntoView({ block: "nearest" });
|
|
setStatus("已选择组件");
|
|
return true;
|
|
}
|
|
|
|
function isSelectionMessage(message) {
|
|
const eventName = String(message.event || "").toLowerCase();
|
|
return [
|
|
"select",
|
|
"selection",
|
|
"selected",
|
|
"cellselected",
|
|
"cellsselected",
|
|
"systemsimulationselection",
|
|
].includes(eventName);
|
|
}
|
|
|
|
async function handleSelectionMessage(message) {
|
|
if (!isSelectionMessage(message)) {
|
|
return false;
|
|
}
|
|
|
|
const candidateIds = collectSelectionIds(message);
|
|
if (candidateIds.length === 0) {
|
|
setStatus("收到选择事件,但没有 cell id");
|
|
return true;
|
|
}
|
|
|
|
const componentId = componentIdFromCandidateIds(candidateIds);
|
|
if (componentId) {
|
|
const selected = await selectComponentFromCanvas(componentId);
|
|
if (selected) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
setStatus(`收到选择,正在匹配 ${candidateIds.length} 个 id`);
|
|
await syncSelectionFromDrawio(candidateIds);
|
|
return true;
|
|
}
|
|
|
|
async function syncSelectionFromDrawio(candidateIds) {
|
|
if (
|
|
!drawioReady ||
|
|
selectionSyncInFlight ||
|
|
pendingDiagramRequest
|
|
) {
|
|
setStatus(
|
|
!drawioReady
|
|
? "收到选择,但 draw.io 尚未就绪"
|
|
: "收到选择,等待当前导出完成",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (candidateIds.length === 0) {
|
|
setStatus("收到选择事件,但没有 cell id");
|
|
return;
|
|
}
|
|
|
|
selectionSyncInFlight = true;
|
|
try {
|
|
const diagramXml = await requestDiagramXml("同步选择");
|
|
await loadComponentsFromXml(diagramXml, { selectFirst: false });
|
|
|
|
const componentId = componentIdFromCandidateIds(candidateIds);
|
|
if (componentId) {
|
|
await selectComponentFromCanvas(componentId);
|
|
} else {
|
|
setStatus(`未匹配到组件:${candidateIds.slice(0, 3).join(", ")}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(error);
|
|
setStatus("选择同步失败");
|
|
} finally {
|
|
selectionSyncInFlight = false;
|
|
}
|
|
}
|
|
|
|
function collectSelectionIds(value, ids = []) {
|
|
if (value === null || value === undefined) {
|
|
return ids;
|
|
}
|
|
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
ids.push(String(value));
|
|
return ids;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
collectSelectionIds(item, ids);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
for (const key of ["id", "parentId", "ancestorId", "cell", "cellId", "value"]) {
|
|
if (value[key] !== undefined) {
|
|
collectSelectionIds(value[key], ids);
|
|
}
|
|
}
|
|
for (const key of ["cells", "ids", "ancestorIds", "selection", "selected"]) {
|
|
if (value[key] !== undefined) {
|
|
collectSelectionIds(value[key], ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
function componentIdFromDrawioSelection() {
|
|
const graph = findDrawioGraph();
|
|
if (!graph || typeof graph.getSelectionCells !== "function") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return componentIdFromCandidateIds(
|
|
collectCellIds(graph.getSelectionCells()),
|
|
);
|
|
} catch (error) {
|
|
console.warn(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collectCellIds(cells) {
|
|
const ids = [];
|
|
for (const cell of cells || []) {
|
|
if (!cell) {
|
|
continue;
|
|
}
|
|
if (cell.id !== undefined) {
|
|
ids.push(String(cell.id));
|
|
}
|
|
if (typeof cell.getId === "function") {
|
|
ids.push(String(cell.getId()));
|
|
}
|
|
|
|
const parent =
|
|
typeof cell.getParent === "function" ? cell.getParent() : cell.parent;
|
|
if (parent?.id !== undefined) {
|
|
ids.push(String(parent.id));
|
|
}
|
|
if (typeof parent?.getId === "function") {
|
|
ids.push(String(parent.getId()));
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function findDrawioGraph() {
|
|
const frameWindow = frame.contentWindow;
|
|
if (!frameWindow) {
|
|
return null;
|
|
}
|
|
|
|
const directCandidates = [
|
|
frameWindow.editorUi,
|
|
frameWindow.ui,
|
|
frameWindow.app,
|
|
frameWindow.App,
|
|
frameWindow.graph,
|
|
];
|
|
for (const candidate of directCandidates) {
|
|
const graph = graphFromCandidate(candidate);
|
|
if (graph) {
|
|
return graph;
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const key of Object.keys(frameWindow)) {
|
|
if (!/(editor|graph|draw|app|ui)/i.test(key)) {
|
|
continue;
|
|
}
|
|
let candidate = null;
|
|
try {
|
|
candidate = frameWindow[key];
|
|
} catch {
|
|
continue;
|
|
}
|
|
const graph = graphFromCandidate(candidate);
|
|
if (graph) {
|
|
return graph;
|
|
}
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function graphFromCandidate(candidate) {
|
|
if (!candidate || typeof candidate !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const possibleGraphs = [
|
|
candidate,
|
|
candidate.graph,
|
|
candidate.editor?.graph,
|
|
candidate.editorUi?.editor?.graph,
|
|
candidate.ui?.editor?.graph,
|
|
];
|
|
return (
|
|
possibleGraphs.find(
|
|
(graph) =>
|
|
graph &&
|
|
typeof graph === "object" &&
|
|
typeof graph.getSelectionCells === "function",
|
|
) || null
|
|
);
|
|
}
|
|
|
|
function componentIdFromDomTarget(target) {
|
|
const ids = [];
|
|
let element = target;
|
|
let depth = 0;
|
|
while (element && depth < 10) {
|
|
if (typeof element.getAttribute === "function") {
|
|
for (const name of ["data-cell-id", "data-id", "id"]) {
|
|
const value = element.getAttribute(name);
|
|
if (value) {
|
|
ids.push(value);
|
|
}
|
|
}
|
|
}
|
|
element = element.parentElement;
|
|
depth += 1;
|
|
}
|
|
|
|
return componentIdFromCandidateIds(ids);
|
|
}
|
|
|
|
function componentIdFromCandidateIds(ids) {
|
|
const idMap = buildCellToComponentMap(currentDiagramXml);
|
|
for (const id of ids || []) {
|
|
if (!id || id.startsWith(focusHighlightPrefix)) {
|
|
continue;
|
|
}
|
|
const componentId = idMap.get(id);
|
|
if (componentId) {
|
|
return componentId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildCellToComponentMap(xmlText) {
|
|
const map = new Map();
|
|
const documentNode = parseXmlDocument(xmlText);
|
|
const graph = documentNode ? graphRootFromDocument(documentNode) : null;
|
|
const componentIds = new Set();
|
|
|
|
for (const component of components) {
|
|
componentIds.add(component.id);
|
|
map.set(component.id, component.id);
|
|
}
|
|
|
|
if (!graph) {
|
|
return map;
|
|
}
|
|
|
|
for (const element of Array.from(graph.root.children)) {
|
|
if (element.localName !== "object") {
|
|
continue;
|
|
}
|
|
if (element.hasAttribute("portName")) {
|
|
continue;
|
|
}
|
|
const componentId = element.getAttribute("id");
|
|
if (!componentId) {
|
|
continue;
|
|
}
|
|
componentIds.add(componentId);
|
|
map.set(componentId, componentId);
|
|
}
|
|
|
|
for (const element of Array.from(graph.root.children)) {
|
|
if (element.localName === "object") {
|
|
const objectId = element.getAttribute("id");
|
|
const cell = Array.from(element.children).find(
|
|
(child) => child.localName === "mxCell",
|
|
);
|
|
const parentId = cell?.getAttribute("parent");
|
|
|
|
if (objectId && componentIds.has(objectId)) {
|
|
map.set(objectId, objectId);
|
|
} else if (objectId && parentId && componentIds.has(parentId)) {
|
|
map.set(objectId, parentId);
|
|
}
|
|
|
|
const innerCellId = cell?.getAttribute("id");
|
|
if (innerCellId && parentId && componentIds.has(parentId)) {
|
|
map.set(innerCellId, parentId);
|
|
}
|
|
}
|
|
|
|
if (element.localName === "mxCell") {
|
|
const cellId = element.getAttribute("id");
|
|
const parentId = element.getAttribute("parent");
|
|
if (cellId && parentId && componentIds.has(parentId)) {
|
|
map.set(cellId, parentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function updateControls() {
|
|
const busy = Boolean(pendingDiagramRequest);
|
|
exportButton.disabled = !drawioReady || busy;
|
|
refreshButton.disabled = !drawioReady || busy;
|
|
applyButton.disabled =
|
|
!drawioReady || busy || !selectedComponentId || !selectedCanApply;
|
|
}
|
|
|
|
function requestDiagramXml(statusText) {
|
|
if (!drawioReady) {
|
|
return Promise.reject(new Error("draw.io 尚未就绪"));
|
|
}
|
|
if (pendingDiagramRequest) {
|
|
return Promise.reject(new Error("已有导出任务正在执行"));
|
|
}
|
|
|
|
setStatus(statusText);
|
|
updateControls();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timeoutId = window.setTimeout(() => {
|
|
pendingDiagramRequest = null;
|
|
updateControls();
|
|
reject(new Error("draw.io 导出超时"));
|
|
}, 6000);
|
|
|
|
pendingDiagramRequest = { resolve, reject, timeoutId };
|
|
sendToDrawio({ action: "export", format: "xml" });
|
|
updateControls();
|
|
});
|
|
}
|
|
|
|
window.addEventListener("message", async (event) => {
|
|
if (event.origin !== drawioUrl.origin) {
|
|
return;
|
|
}
|
|
|
|
const message = parseMessage(event.data);
|
|
if (!message) {
|
|
return;
|
|
}
|
|
|
|
if (message.event === "systemSimulationPluginReady") {
|
|
selectionBridgeInstalled = true;
|
|
setStatus("选择联动就绪");
|
|
return;
|
|
}
|
|
|
|
if (message.event === "systemSimulationPluginStatus") {
|
|
if (message.status === "ready") {
|
|
setStatus("选择联动就绪");
|
|
} else if (message.status === "prototype-ready") {
|
|
setStatus("选择捕获就绪");
|
|
} else if (message.status === "registered") {
|
|
setStatus("选择插件已注册");
|
|
} else if (message.status === "graph-missing") {
|
|
setStatus("选择插件未找到画布实例");
|
|
} else if (message.status === "waiting-for-draw-loadplugin") {
|
|
setStatus("等待 draw.io 插件接口");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isSelectionMessage(message)) {
|
|
void handleSelectionMessage(message);
|
|
return;
|
|
}
|
|
|
|
if (message.event === "init") {
|
|
sendToDrawio({
|
|
action: "load",
|
|
xml: currentDiagramXml,
|
|
title: "system.drawio",
|
|
autosave: 1,
|
|
});
|
|
drawioReady = true;
|
|
setStatus("就绪");
|
|
updateControls();
|
|
scheduleSelectionBridgeInstall();
|
|
return;
|
|
}
|
|
|
|
if (message.event === "autosave") {
|
|
if (message.xml) {
|
|
currentDiagramXml = stripFocusHighlights(message.xml);
|
|
}
|
|
if (!pendingDiagramRequest) {
|
|
setStatus("已更新");
|
|
scheduleComponentAutoRefresh(message.xml || null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.event === "export" && pendingDiagramRequest) {
|
|
window.clearTimeout(pendingDiagramRequest.timeoutId);
|
|
const diagramXml = stripFocusHighlights(
|
|
message.xml || currentDiagramXml || initialXml,
|
|
);
|
|
currentDiagramXml = diagramXml;
|
|
pendingDiagramRequest.resolve(diagramXml);
|
|
pendingDiagramRequest = null;
|
|
updateControls();
|
|
}
|
|
});
|
|
|
|
refreshButton.addEventListener("click", async () => {
|
|
try {
|
|
const diagramXml = await requestDiagramXml("读取组件");
|
|
await loadComponentsFromXml(diagramXml);
|
|
setStatus("组件已刷新");
|
|
} catch (error) {
|
|
handleError(error, "刷新失败");
|
|
}
|
|
});
|
|
|
|
exportButton.addEventListener("click", async () => {
|
|
try {
|
|
const diagramXml = await requestDiagramXml("生成 XML");
|
|
await exportSystemXml(diagramXml);
|
|
setStatus("已生成");
|
|
} catch (error) {
|
|
handleError(error, "生成失败");
|
|
}
|
|
});
|
|
|
|
parameterForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await applySelectedParameters();
|
|
});
|
|
|
|
async function loadComponentSchemas() {
|
|
try {
|
|
const response = await fetch("/api/component-schemas");
|
|
if (!response.ok) {
|
|
throw new Error(await readResponseError(response));
|
|
}
|
|
componentSchemas = await response.json();
|
|
} catch (error) {
|
|
handleError(error, "参数模板加载失败");
|
|
}
|
|
}
|
|
|
|
async function loadComponentsFromXml(diagramXml, options = {}) {
|
|
const { selectFirst = true } = options;
|
|
const response = await fetch("/api/diagram-components", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ xml: diagramXml }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readResponseError(response));
|
|
}
|
|
|
|
const data = await response.json();
|
|
components = data.components || [];
|
|
if (!components.some((component) => component.id === selectedComponentId)) {
|
|
selectedComponentId = selectFirst ? components[0]?.id || null : null;
|
|
}
|
|
renderComponentList();
|
|
renderParameterForm();
|
|
}
|
|
|
|
function scheduleComponentAutoRefresh(diagramXml) {
|
|
if (arguments.length > 0) {
|
|
if (diagramXml) {
|
|
currentDiagramXml = diagramXml;
|
|
} else {
|
|
autoRefreshNeedsExport = true;
|
|
}
|
|
}
|
|
|
|
if (!drawioReady) {
|
|
return;
|
|
}
|
|
|
|
if (autoRefreshTimeoutId !== null) {
|
|
window.clearTimeout(autoRefreshTimeoutId);
|
|
}
|
|
|
|
autoRefreshTimeoutId = window.setTimeout(() => {
|
|
autoRefreshTimeoutId = null;
|
|
void autoRefreshComponents();
|
|
}, 800);
|
|
}
|
|
|
|
async function autoRefreshComponents() {
|
|
if (!drawioReady) {
|
|
return;
|
|
}
|
|
|
|
if (pendingDiagramRequest || isEditingParameters()) {
|
|
scheduleComponentAutoRefresh();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let diagramXml = currentDiagramXml;
|
|
if (autoRefreshNeedsExport || !diagramXml) {
|
|
diagramXml = await requestDiagramXml("同步组件");
|
|
autoRefreshNeedsExport = false;
|
|
}
|
|
await loadComponentsFromXml(diagramXml);
|
|
setStatus("组件已同步");
|
|
} catch (error) {
|
|
handleError(error, "同步失败");
|
|
}
|
|
}
|
|
|
|
function isEditingParameters() {
|
|
return parameterForm.contains(document.activeElement);
|
|
}
|
|
|
|
async function applySelectedParameters() {
|
|
const component = selectedComponent();
|
|
if (!component) {
|
|
return;
|
|
}
|
|
|
|
const schema = schemaForComponent(component);
|
|
const parameterNames = Object.keys(schema.parameters || {});
|
|
const formData = new FormData(parameterForm);
|
|
const parameters = {};
|
|
for (const name of parameterNames) {
|
|
parameters[name] = formData.get(name);
|
|
}
|
|
|
|
try {
|
|
const diagramXml = await requestDiagramXml("应用参数");
|
|
const response = await fetch("/api/update-component-parameters", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
xml: diagramXml,
|
|
component_id: component.id,
|
|
name: componentNameInput.value,
|
|
parameters,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readResponseError(response));
|
|
}
|
|
|
|
currentDiagramXml = await response.text();
|
|
sendToDrawio({
|
|
action: "load",
|
|
xml: currentDiagramXml,
|
|
title: "system.drawio",
|
|
autosave: 1,
|
|
});
|
|
await loadComponentsFromXml(currentDiagramXml);
|
|
setStatus("参数已应用");
|
|
} catch (error) {
|
|
handleError(error, "应用失败");
|
|
}
|
|
}
|
|
|
|
async function exportSystemXml(diagramXml) {
|
|
const response = await fetch("/api/system-xml", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ xml: diagramXml }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await readResponseError(response));
|
|
}
|
|
|
|
const systemXml = await response.text();
|
|
downloadText(systemXml, "system.xml", "application/xml");
|
|
}
|
|
|
|
function renderComponentList() {
|
|
componentListNode.textContent = "";
|
|
if (components.length === 0) {
|
|
const emptyNode = document.createElement("div");
|
|
emptyNode.className = "empty-state";
|
|
emptyNode.textContent = "暂无组件";
|
|
componentListNode.append(emptyNode);
|
|
return;
|
|
}
|
|
|
|
for (const component of components) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "component-row";
|
|
if (component.id === selectedComponentId) {
|
|
row.classList.add("active");
|
|
}
|
|
row.addEventListener("click", () => {
|
|
selectedComponentId = component.id;
|
|
renderComponentList();
|
|
renderParameterForm();
|
|
void focusComponentInDrawio(component.id);
|
|
});
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "component-name";
|
|
name.textContent = displayNameForComponent(component);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "component-meta";
|
|
meta.textContent = `${schemaKeyForComponent(component)} · ${component.id}`;
|
|
|
|
row.append(name, meta);
|
|
componentListNode.append(row);
|
|
}
|
|
}
|
|
|
|
function renderParameterForm() {
|
|
fieldGridNode.textContent = "";
|
|
const component = selectedComponent();
|
|
if (!component) {
|
|
selectedCanApply = false;
|
|
componentNameInput.value = "";
|
|
componentNameInput.disabled = true;
|
|
selectedHeadingNode.textContent = "未选择组件";
|
|
selectedMetaNode.textContent = "";
|
|
updateControls();
|
|
return;
|
|
}
|
|
|
|
const schema = schemaForComponent(component);
|
|
const parameters = schema.parameters || {};
|
|
const displayName = displayNameForComponent(component);
|
|
selectedCanApply = true;
|
|
componentNameInput.disabled = false;
|
|
componentNameInput.value = displayName;
|
|
selectedHeadingNode.textContent = displayName;
|
|
selectedMetaNode.textContent = `${schemaKeyForComponent(component)} · ${component.id}`;
|
|
|
|
const entries = Object.entries(parameters);
|
|
if (entries.length === 0) {
|
|
const emptyNode = document.createElement("div");
|
|
emptyNode.className = "empty-state";
|
|
emptyNode.textContent = "该组件暂无可调参数";
|
|
fieldGridNode.append(emptyNode);
|
|
updateControls();
|
|
return;
|
|
}
|
|
|
|
for (const [name, definition] of entries) {
|
|
fieldGridNode.append(createParameterField(component, name, definition));
|
|
}
|
|
updateControls();
|
|
}
|
|
|
|
function createParameterField(component, name, definition) {
|
|
const field = document.createElement("div");
|
|
field.className = "field";
|
|
|
|
const label = document.createElement("label");
|
|
label.htmlFor = `param-${name}`;
|
|
const labelText = document.createElement("span");
|
|
labelText.textContent = definition.label || name;
|
|
const key = document.createElement("span");
|
|
key.className = "field-key";
|
|
key.textContent = name;
|
|
label.append(labelText, key);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "input-row";
|
|
|
|
const input = document.createElement("input");
|
|
input.id = `param-${name}`;
|
|
input.name = name;
|
|
input.type =
|
|
definition.type === "float" || definition.type === "int" ? "number" : "text";
|
|
input.step = definition.type === "int" ? "1" : "any";
|
|
if (definition.min !== undefined) {
|
|
input.min = definition.min;
|
|
}
|
|
if (definition.max !== undefined) {
|
|
input.max = definition.max;
|
|
}
|
|
input.value =
|
|
component.properties?.[name] ?? definition.default ?? "";
|
|
|
|
const unit = document.createElement("span");
|
|
unit.className = "unit";
|
|
unit.textContent = definition.unit || "";
|
|
|
|
row.append(input, unit);
|
|
field.append(label, row);
|
|
return field;
|
|
}
|
|
|
|
function selectedComponent() {
|
|
return components.find((component) => component.id === selectedComponentId);
|
|
}
|
|
|
|
function schemaKeyForComponent(component) {
|
|
return component.properties?.modelType || component.type || "component";
|
|
}
|
|
|
|
function schemaForComponent(component) {
|
|
const key = schemaKeyForComponent(component);
|
|
return (
|
|
componentSchemas[key] ||
|
|
componentSchemas[component.type] || {
|
|
label: component.label || component.type || "组件",
|
|
parameters: {},
|
|
}
|
|
);
|
|
}
|
|
|
|
function buildFocusHighlightedXml(xmlText, componentId) {
|
|
const documentNode = parseXmlDocument(xmlText);
|
|
if (!documentNode) {
|
|
return null;
|
|
}
|
|
|
|
removeFocusHighlights(documentNode);
|
|
const graph = graphRootFromDocument(documentNode);
|
|
if (!graph) {
|
|
return null;
|
|
}
|
|
|
|
const component = Array.from(graph.root.children).find(
|
|
(element) =>
|
|
element.localName === "object" &&
|
|
element.getAttribute("id") === componentId,
|
|
);
|
|
if (!component) {
|
|
return null;
|
|
}
|
|
|
|
const cell = Array.from(component.children).find(
|
|
(element) => element.localName === "mxCell",
|
|
);
|
|
const geometry = cell
|
|
? Array.from(cell.children).find(
|
|
(element) => element.localName === "mxGeometry",
|
|
)
|
|
: null;
|
|
if (!cell || !geometry) {
|
|
return null;
|
|
}
|
|
|
|
const x = Number(geometry.getAttribute("x") || 0);
|
|
const y = Number(geometry.getAttribute("y") || 0);
|
|
const width = Number(geometry.getAttribute("width") || 0);
|
|
const height = Number(geometry.getAttribute("height") || 0);
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const inset = 8;
|
|
const highlight = documentNode.createElement("mxCell");
|
|
highlight.setAttribute("id", `${focusHighlightPrefix}${componentId}`);
|
|
highlight.setAttribute("value", "");
|
|
highlight.setAttribute(
|
|
"style",
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#1d6fb8;strokeWidth=3;dashed=1;dashPattern=8 4;pointerEvents=0;editable=0;movable=0;resizable=0;rotatable=0;deletable=0;connectable=0;",
|
|
);
|
|
highlight.setAttribute("vertex", "1");
|
|
highlight.setAttribute("connectable", "0");
|
|
highlight.setAttribute("parent", cell.getAttribute("parent") || "1");
|
|
|
|
const highlightGeometry = documentNode.createElement("mxGeometry");
|
|
highlightGeometry.setAttribute("x", String(x - inset));
|
|
highlightGeometry.setAttribute("y", String(y - inset));
|
|
highlightGeometry.setAttribute("width", String(width + inset * 2));
|
|
highlightGeometry.setAttribute("height", String(height + inset * 2));
|
|
highlightGeometry.setAttribute("as", "geometry");
|
|
highlight.append(highlightGeometry);
|
|
graph.root.append(highlight);
|
|
|
|
return serializeXmlDocument(documentNode);
|
|
}
|
|
|
|
function stripFocusHighlights(xmlText) {
|
|
const documentNode = parseXmlDocument(xmlText);
|
|
if (!documentNode) {
|
|
return xmlText;
|
|
}
|
|
|
|
removeFocusHighlights(documentNode);
|
|
return serializeXmlDocument(documentNode);
|
|
}
|
|
|
|
function parseXmlDocument(xmlText) {
|
|
const documentNode = new DOMParser().parseFromString(
|
|
xmlText,
|
|
"application/xml",
|
|
);
|
|
if (documentNode.getElementsByTagName("parsererror").length > 0) {
|
|
return null;
|
|
}
|
|
return documentNode;
|
|
}
|
|
|
|
function serializeXmlDocument(documentNode) {
|
|
return new XMLSerializer().serializeToString(documentNode);
|
|
}
|
|
|
|
function graphRootFromDocument(documentNode) {
|
|
const model =
|
|
documentNode.documentElement.localName === "mxGraphModel"
|
|
? documentNode.documentElement
|
|
: Array.from(documentNode.getElementsByTagName("mxGraphModel"))[0];
|
|
if (!model) {
|
|
return null;
|
|
}
|
|
|
|
const root = Array.from(model.children).find(
|
|
(element) => element.localName === "root",
|
|
);
|
|
if (!root) {
|
|
return null;
|
|
}
|
|
|
|
return { model, root };
|
|
}
|
|
|
|
function removeFocusHighlights(documentNode) {
|
|
for (const cell of Array.from(documentNode.getElementsByTagName("mxCell"))) {
|
|
if (cell.getAttribute("id")?.startsWith(focusHighlightPrefix)) {
|
|
cell.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
function displayNameForComponent(component) {
|
|
return (
|
|
component.properties?.name ||
|
|
component.label ||
|
|
schemaForComponent(component).label ||
|
|
component.type ||
|
|
"组件"
|
|
);
|
|
}
|
|
|
|
async function readResponseError(response) {
|
|
const text = await response.text();
|
|
try {
|
|
const payload = JSON.parse(text);
|
|
return payload.detail || text;
|
|
} catch {
|
|
return text || response.statusText;
|
|
}
|
|
}
|
|
|
|
function handleError(error, fallback) {
|
|
console.error(error);
|
|
setStatus(error?.message || fallback);
|
|
updateControls();
|
|
}
|
|
|
|
function downloadText(text, filename, mimeType) {
|
|
const blob = new Blob([text], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
frame.src = drawioUrl.toString();
|
|
}, 0);
|
|
</script>
|
|
</body>
|
|
</html>
|