前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
1 parent 0dcb465d84
commit 91bd9fb252
77 files changed
+11223 -551

No files matched your search

@@ -0,0 +1,45 @@
# Amesim 风格的连接线子模型演示
这是一个独立的交互 demo,用于评估“绘图 → 子模型 → 参数 → 检查”的操作方式。
## 打开与操作
在项目已有 Vite 开发服务运行时,打开:
`http://127.0.0.1:5173/demos/amesim-submodels/index.html`
1. 示例预置四条尚未选型的连接。进入“子模型”,点击连线或画布下方的连线按钮。
2. 弹窗优先展示兼容型号及物理作用;“端口需求与其他型号”默认折叠。
3. “应用并下一条”依次处理未选型连线,完成后进入参数阶段。管长、内径需要用户填写。
4. 进入“检查”,查看示例端口匹配和必填参数;问题项可以直接定位处理。
5. 绘图模式下可删除一条连线,再依次点击两个空闲端口重连。Esc 取消尚未完成的连线。
示例中的 P4NODE2 始终以端口 2 为参考,不提供节点子模型切换。
## 范围
- 仅包含固定的局部端口示例和 DIRECT、PNL00R、PNL0001/2/3 的接口匹配规则。
- 匹配检查基于温度/压力的供需关系,质量/能量流率采用互补方向;不是正式全网依赖分析。
- 优先项按接口兼容和模型复杂度排序,不代表对具体工程的物理模型推荐。各型号显示是否包含压降、储气及状态数量。
- 管路方向由端口关系决定,与画线顺序和实际气流方向无关。
- 管路配置只是交互数据,不生成正式 XML,不调用编译器、不执行仿真。
- 参数使用界面显示单位;尚未接入 SI 转换、介质、换热、完整初始条件检查等生产能力。
- 不复制 Siemens 数值代码。示例接口规则根据本机已有 Amesim 2404 管路源文件的公开接口说明整理。
- 不修改主应用、正式组件、pnode 方程、求解器或已有工程数据。没有引入 npm 依赖。
## 保存与恢复
浏览器只使用独立的 localStorage 键:
`system-simulation:amesim-submodel-demo:v1`
- 点击页面右上角“恢复初始示例”,可以清空本演示的选型与参数,重新体验。
- 返回原系统:点击“返回主应用”或打开 `http://127.0.0.1:5173/`。
- 撤回本次 demo:删除 `frontend/public/demos/amesim-submodels/` 这个目录即可。无需回退任何现有源码。
- 若只清理浏览器数据,可在该页面的开发者控制台执行 `localStorage.removeItem('system-simulation:amesim-submodel-demo:v1')`,然后刷新。
注意:这个目录位于 Vite 的 public 下,正常打包会把它作为静态文件带入构建产物。正式发布前,如尚未决定保留,应先移除这个 demo 目录。
## 本次验证
2026-09-10:JavaScript 语法及三个静态入口 HTTP 检查通过。Edge 浏览器完成兼容型号筛选、连续选型、反向端口匹配、参数校验、刷新恢复、更换型号保留共同参数、删除重连、独立重置和窄屏检查。未发起 `/api/` 请求。诊断截图保存在 Git 忽略的 `test/amesim-submodel-demo/`。
@@ -0,0 +1,256 @@
(() => {
'use strict';
const STORAGE_KEY = 'system-simulation:amesim-submodel-demo:v1';
const $ = id => document.getElementById(id);
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// Local, deliberately bounded demonstration of variable-level interface matching.
// These declarations are not the production component catalogue or a numerical solver.
const ports = {
'A.1': { label: '储气罐 A · 端口 1', component: 'A', state: 'out' },
'N1.1': { label: '节点 N1 · 端口 1', component: 'N1', state: 'out', number: '1' },
'N1.2': { label: '节点 N1 · 端口 2(参考)', component: 'N1', state: 'in', number: '2 参考' },
'N1.3': { label: '节点 N1 · 端口 3', component: 'N1', state: 'out', number: '3' },
'N1.4': { label: '节点 N1 · 端口 4', component: 'N1', state: 'out', number: '4' },
'B.2': { label: '阀门 B · 端口 2', component: 'B', state: 'in' },
'C.1': { label: '封堵 C · 端口 1', component: 'C', state: 'in' },
'D.1': { label: '封堵 D · 端口 1', component: 'D', state: 'in' },
};
const fields = {
length: { label: '管长(m)', min: 0, exclusive: true, step: 'any', value: '' },
diameter: { label: '内径(mm)', min: 0, exclusive: true, step: 'any', value: '' },
roughness: { label: '相对粗糙度', min: 0, max: 0.1, step: 'any', value: '0.00001' },
pressure: { label: '初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature: { label: '初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
pressure2: { label: '管路端口 2 初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature2: { label: '管路端口 2 初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
};
const models = [
{ id: 'DIRECT', label: '理想连接', ends: null, effect: '不计管路压降,不增加储气容积。', fields: [] },
{ id: 'PNL00R', label: '阻性管路', ends: ['in', 'in'], effect: '考虑摩擦压降;不增加储气状态。', fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', label: '单端储气管路', ends: ['in', 'out'], effect: '考虑摩擦压降;一处储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', label: '中间储气管路', ends: ['in', 'in'], effect: '考虑摩擦压降;中间储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', label: '双端储气管路', ends: ['out', 'out'], effect: '考虑摩擦压降;两端储气容积,4 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const modelById = id => models.find(model => model.id === id);
const initial = () => ({ version: 1, mode: 'sketch', selected: 'L1', edges: [
{ id: 'L1', a: 'A.1', b: 'N1.1', model: null, params: {} },
{ id: 'L2', a: 'N1.2', b: 'B.2', model: null, params: {} },
{ id: 'L3', a: 'C.1', b: 'N1.3', model: null, params: {} },
{ id: 'L4', a: 'N1.4', b: 'D.1', model: null, params: {} },
] });
let state = initial(), pendingPort = null, dialogEdge = null, positions = {};
let storageAvailable = true;
function matches(edge, model) {
const a = ports[edge.a].state, b = ports[edge.b].state;
if (!model.ends) return a !== b;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(edge) { return models.filter(model => matches(edge, model)); }
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (!saved || saved.version !== 1 || !Array.isArray(saved.edges) || saved.edges.length > 4) return;
const occupied = new Set(), ids = new Set();
const valid = saved.edges.every(edge => {
if (!edge || !/^L[1-9]\d*$/.test(edge.id) || ids.has(edge.id) || !ports[edge.a] || !ports[edge.b] ||
ports[edge.a].component === ports[edge.b].component || occupied.has(edge.a) || occupied.has(edge.b)) return false;
if (edge.model !== null && (!modelById(edge.model) || !matches(edge, modelById(edge.model)))) return false;
ids.add(edge.id); occupied.add(edge.a); occupied.add(edge.b);
return true;
});
if (!valid) return;
state = {
version: 1,
mode: ['sketch', 'submodel', 'parameters', 'check'].includes(saved.mode) ? saved.mode : 'sketch',
selected: ids.has(saved.selected) ? saved.selected : saved.edges[0]?.id || null,
edges: saved.edges.map(edge => ({ id: edge.id, a: edge.a, b: edge.b, model: edge.model,
params: Object.fromEntries(Object.keys(fields).filter(key => ['string', 'number'].includes(typeof edge.params?.[key])).map(key => [key, String(edge.params[key]).slice(0, 64)])) })),
};
} catch { storageAvailable = false; }
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); storageAvailable = true; }
catch { storageAvailable = false; }
$('save-state').textContent = storageAvailable ? '仅保存此演示 · 与正式工程分离' : '当前浏览器未保存 · 刷新将丢失演示修改';
}
function selected() { return state.edges.find(edge => edge.id === state.selected); }
function message(text) { $('notice').textContent = text; }
function validField(key, value) {
const field = fields[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (field.exclusive ? number > field.min : number >= field.min) && (field.max === undefined || number <= field.max);
}
function missingFields(edge) {
return (modelById(edge.model)?.fields || []).filter(key => !validField(key, edge.params[key]));
}
function edgeStatus(edge) {
if (!edge.model) return '未选子模型';
return missingFields(edge).length ? '待填参数' : '已配置';
}
function orientation(edge, model) {
if (!model.ends || model.ends[0] === model.ends[1]) return '两端接口对称';
const forward = ports[edge.a].state !== model.ends[0];
return `管路端口 1 → ${ports[forward ? edge.a : edge.b].label};端口 2 → ${ports[forward ? edge.b : edge.a].label}`;
}
function updateProgress() {
const chosen = state.edges.filter(edge => edge.model).length;
const configured = state.edges.filter(edge => edge.model && missingFields(edge).length === 0).length;
$('progress').textContent = `${state.edges.length} 条连接 · ${chosen} 条已选子模型 · ${configured} 条参数完整`;
}
function layout() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
$('wires').setAttribute('viewBox', `0 0 ${width} ${height}`);
positions = {
'A.1': [width * .16 + 29, height * .54],
'N1.1': [width * .48 - 19, height * .54], 'N1.2': [width * .48 + 19, height * .54],
'N1.3': [width * .48, height * .54 - 19], 'N1.4': [width * .48, height * .54 + 19],
'B.2': [width * .84 - 29, height * .54],
'C.1': [width * .48, height * .14 + 4], 'D.1': [width * .48, height * .87 - 4],
};
$('ports').innerHTML = Object.entries(ports).map(([id, port]) => `<button type="button" class="port ${id === pendingPort ? 'pending' : ''} ${id === 'N1.2' ? 'reference' : ''}" style="--x:${positions[id][0]}px;--y:${positions[id][1]}px" data-port="${id}" aria-label="${escape(port.label)}">${port.number ? `<span>${escape(port.number)}</span>` : ''}</button>`).join('');
renderWires();
}
function renderWires() {
if (!positions['A.1']) return;
$('wires').innerHTML = state.edges.map(edge => {
const [ax, ay] = positions[edge.a], [bx, by] = positions[edge.b];
const horizontal = Math.abs(bx - ax) >= Math.abs(by - ay);
const path = horizontal ? `M${ax},${ay} H${(ax + bx) / 2} V${by} H${bx}` : `M${ax},${ay} V${(ay + by) / 2} H${bx} V${by}`;
const x = horizontal ? (ax + bx) / 2 : (ax + bx) / 2 + 13;
const y = horizontal ? (ay + by) / 2 - 12 : (ay + by) / 2;
const label = state.mode === 'sketch' ? edge.id : `${edge.id} · ${edge.model || '未选'}`;
return `<g><path class="wire-line ${edge.model ? 'configured' : ''} ${edge.id === state.selected ? 'selected' : ''}" d="${path}"/><path data-edge="${edge.id}" class="wire-hit" d="${path}"/><text class="wire-label" x="${x}" y="${y}" text-anchor="${horizontal ? 'middle' : 'start'}">${escape(label)}</text></g>`;
}).join('');
$('connection-strip').innerHTML = state.edges.map(edge => `<button type="button" data-edge="${edge.id}" aria-pressed="${edge.id === state.selected}"><span class="dot ${edge.model ? 'ready' : ''}"></span>${edge.id}<span>${escape(state.mode === 'sketch' ? '连接线' : edge.model || '未选')}</span></button>`).join('') || '<span class="secondary">依次点击两个空闲端口创建连接。</span>';
}
function setMode(mode) { state.mode = mode; pendingPort = null; render(); save(); }
function selectEdge(id, open = false) {
state.selected = id; pendingPort = null; render(); save();
if (open && state.mode === 'submodel') openChooser(id);
}
function renderInspector() {
const edge = selected();
$('inspector-title').textContent = state.mode === 'check' ? '配置检查' : state.mode === 'parameters' ? '连线参数' : '连线';
$('selected-id').textContent = state.mode === 'check' ? '局部演示' : edge?.id || '';
if (state.mode === 'check') { renderChecks(); return; }
if (!edge) { $('inspector-body').innerHTML = '<p class="secondary">选择连接线,或在绘图模式连接两个空闲端口。</p>'; return; }
const model = modelById(edge.model);
let html = `<div class="endpoint"><div>${escape(ports[edge.a].label)}</div><div>↔ ${escape(ports[edge.b].label)}</div></div>`;
if (state.mode === 'sketch') {
html += '<p class="secondary">先完成连接。进入子模型阶段后,为连接线选择计算模型。</p><div class="inspector-actions"><button type="button" class="primary" data-action="submodel">进入子模型</button><button type="button" data-action="delete">删除此连线</button></div>';
} else {
html += `<div class="section-label">当前子模型</div><div class="model-name">${escape(model?.id || '尚未选择')}</div><div class="secondary">${escape(model?.effect || '根据两端接口筛选,保留用户选择。')}</div>`;
if (model) html += `<p class="secondary">${escape(orientation(edge, model))}</p>`;
html += `<div class="inspector-actions"><button type="button" class="${state.mode === 'submodel' ? 'primary' : ''}" data-action="choose">${model ? '更换子模型…' : '选择子模型…'}</button>${state.mode === 'submodel' && model ? '<button type="button" data-action="parameters">填写参数</button>' : ''}</div>`;
if (state.mode === 'parameters' && model) {
html += model.fields.length ? `<form class="parameter-form" novalidate>${model.fields.map(key => {
const field = fields[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '管路端口 1 ' + field.label : field.label;
return `<label for="param-${key}">${escape(label)}<input id="param-${key}" data-parameter="${key}" type="number" min="${field.min}" ${field.max !== undefined ? `max="${field.max}"` : ''} step="${field.step}" value="${escape(edge.params[key] ?? '')}" placeholder="填写实际值" aria-invalid="${!validField(key, edge.params[key])}"></label>`;
}).join('')}</form><div class="parameter-state" id="parameter-state"></div>` : '<p class="secondary">理想连接无需管路参数。</p>';
}
}
$('inspector-body').innerHTML = html;
updateParameterState();
}
function updateParameterState() {
const target = $('parameter-state'), edge = selected();
if (!target || !edge) return;
const missing = missingFields(edge);
target.classList.toggle('valid', missing.length === 0);
target.textContent = missing.length ? `待填写或修正 ${missing.length} 项` : '参数完整 · 已保存到此演示';
}
function renderChecks() {
const occupied = new Set(state.edges.flatMap(edge => [edge.a, edge.b]));
const free = Object.keys(ports).filter(id => !occupied.has(id));
const complete = !free.length && state.edges.every(edge => edge.model && matches(edge, modelById(edge.model)) && !missingFields(edge).length);
$('inspector-body').innerHTML = `<div class="check-summary ${complete ? 'complete' : ''}">${complete ? '局部连接配置检查通过' : '还有配置需要完成'}</div><p class="secondary">此检查覆盖示例端口匹配和必填参数;未调用系统编译器或求解器。</p><div class="check-list">${state.edges.map(edge => `<div class="check-item ${edgeStatus(edge) === '已配置' ? 'complete' : ''}"><strong>${edge.id} · ${escape(edge.model || '未选')}</strong><div class="secondary">${escape(edgeStatus(edge))}</div>${edgeStatus(edge) !== '已配置' ? `<button type="button" data-fix="${edge.id}">${edge.model ? '填写参数' : '选择子模型'}</button>` : ''}</div>`).join('')}${free.length ? `<div class="check-item"><strong>${free.length} 个示例端口未连接</strong><div class="secondary">${free.map(id => escape(ports[id].label)).join('、')}</div><button type="button" data-action="sketch">返回绘图</button></div>` : ''}</div>`;
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
const hints = {
sketch: '示例已连线,可进入“子模型”;也可删除连线后重新连接端口。',
submodel: '点击连接线选择兼容子模型。pnode 保持不变,管路方向自动匹配。',
parameters: '点击管路填写实际尺寸。理想连接无需参数。',
check: '检查本示例的端口匹配和必填参数。',
};
$('mode-hint').textContent = hints[state.mode];
const next = { sketch: '选择子模型 →', submodel: '填写参数 →', parameters: '检查配置 →', check: '返回绘图' };
$('next-mode').textContent = next[state.mode];
layout(); renderInspector(); updateProgress();
}
function openChooser(id) {
const edge = state.edges.find(item => item.id === id);
if (!edge) return;
dialogEdge = id;
const available = candidates(edge), selectedModel = edge.model || available[0]?.id;
$('dialog-title').textContent = `选择 ${id} 的子模型`;
$('dialog-endpoints').textContent = `${ports[edge.a].label} ↔ ${ports[edge.b].label}`;
$('model-options').innerHTML = available.map((model, index) => `<label class="model-option"><input type="radio" name="model" value="${model.id}" ${model.id === selectedModel ? 'checked' : ''}><span><strong>${model.id}</strong>${escape(model.label)} ${index === 0 ? '<span class="recommendation">接口匹配优先项</span>' : ''}<small>${escape(model.effect)}</small></span></label>`).join('');
const describe = id => `${ports[id].label}:${ports[id].state === 'out' ? '提供温度、压力,接收质量与能量流率' : '接收温度、压力,提供质量与能量流率'}`;
$('port-requirements').innerHTML = `<div>${escape(describe(edge.a))}</div><div>${escape(describe(edge.b))}</div>`;
$('excluded-models').innerHTML = models.filter(model => !matches(edge, model)).map(model => `<div class="excluded-model"><span>${model.id}</span><span>端口变量供需不匹配</span></div>`).join('');
$('apply-model').disabled = !available.length;
$('apply-next').disabled = !available.length;
document.querySelector('.compatibility-details').open = false;
if (!$('model-dialog').open) $('model-dialog').showModal();
}
function applyModel(next) {
const edge = state.edges.find(item => item.id === dialogEdge);
const id = document.querySelector('input[name="model"]:checked')?.value;
const model = modelById(id);
if (!edge || !model || !matches(edge, model)) return;
edge.model = id;
// Keep shared inputs when changing a model. Inactive inputs remain private to this draft.
model.fields.forEach(key => { if (edge.params[key] === undefined) edge.params[key] = fields[key].value; });
state.selected = edge.id;
$('model-dialog').close(); render(); save();
message(`${edge.id} 已选择 ${id}。${model.effect}`);
if (next) {
const unassigned = state.edges.find(item => !item.model);
if (unassigned) { selectEdge(unassigned.id); openChooser(unassigned.id); }
else { setMode('parameters'); state.selected = state.edges.find(item => missingFields(item).length)?.id || edge.id; render(); save(); message('所有连接线已选型。填写实际管长、内径后检查配置。'); }
}
}
function portClick(id) {
if (state.mode !== 'sketch') { message('如需修改连接,请切换到“绘图”。'); return; }
if (pendingPort === id) { pendingPort = null; layout(); message('已取消连线。'); return; }
if (state.edges.some(edge => edge.a === id || edge.b === id)) { message('该端口已有连接。选中原连线可删除后重连。'); return; }
if (!pendingPort) { pendingPort = id; layout(); message(`起点:${ports[id].label}。请选择另一个部件的空闲端口。`); return; }
if (ports[pendingPort].component === ports[id].component) { message('请选择另一个部件的端口。'); return; }
let index = 1;
while (state.edges.some(edge => edge.id === `L${index}`)) index++;
const edge = { id: `L${index}`, a: pendingPort, b: id, model: null, params: {} };
state.edges.push(edge); state.selected = edge.id; pendingPort = null; render(); save();
message(`${edge.id} 已连接。子模型稍后选择。`);
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('next-mode').addEventListener('click', () => setMode({ sketch: 'submodel', submodel: 'parameters', parameters: 'check', check: 'sketch' }[state.mode]));
$('wires').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('connection-strip').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('ports').addEventListener('click', event => { const target = event.target.closest('[data-port]'); if (target) portClick(target.dataset.port); });
$('inspector-body').addEventListener('click', event => {
const fix = event.target.closest('[data-fix]');
if (fix) { state.selected = fix.dataset.fix; const edge = selected(); setMode(edge.model ? 'parameters' : 'submodel'); if (!edge.model) openChooser(edge.id); return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'choose') openChooser(state.selected);
else if (action === 'delete') { state.edges = state.edges.filter(edge => edge.id !== state.selected); state.selected = state.edges[0]?.id || null; pendingPort = null; render(); save(); message('连线已删除。依次点击两个空闲端口即可重连。'); }
else if (['sketch', 'submodel', 'parameters'].includes(action)) setMode(action);
});
$('inspector-body').addEventListener('input', event => {
const key = event.target.dataset.parameter, edge = selected();
if (!fields[key] || !edge) return;
edge.params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!validField(key, event.target.value)));
updateParameterState(); updateProgress(); save();
});
$('inspector-body').addEventListener('submit', event => event.preventDefault());
$('apply-model').addEventListener('click', () => applyModel(false));
$('apply-next').addEventListener('click', () => applyModel(true));
$('reset').addEventListener('click', () => {
$('model-dialog').close(); state = initial(); pendingPort = null; dialogEdge = null;
render(); save(); message('已恢复最初的四条连接;此演示的选型和参数已清空。');
});
document.addEventListener('keydown', event => { if (event.key === 'Escape' && pendingPort) { pendingPort = null; layout(); message('已取消连线。'); } });
load(); render(); save();
new ResizeObserver(layout).observe($('canvas'));
})();
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>连接线子模型 · 操作演示</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<header class="topbar">
<div><strong>System Simulation</strong><span class="demo-tag">子模型操作演示</span></div>
<div class="top-actions"><a href="/">返回主应用</a><button id="reset" type="button">恢复初始示例</button></div>
</header>
<nav class="workflow" aria-label="建模步骤">
<button type="button" data-mode="sketch" aria-pressed="true"><span>1</span>绘图</button>
<button type="button" data-mode="submodel" aria-pressed="false"><span>2</span>子模型</button>
<button type="button" data-mode="parameters" aria-pressed="false"><span>3</span>参数</button>
<button type="button" data-mode="check" aria-pressed="false"><span>4</span>检查</button>
<span class="workflow-note">独立配置演示 · 不执行仿真</span>
</nav>
<main class="workspace">
<section class="canvas-pane" aria-label="局部气动网络">
<div class="canvas-toolbar"><span id="mode-hint">示例已连线,可进入“子模型”;也可删除连线后重新连接端口。</span><button type="button" id="next-mode">选择子模型 →</button></div>
<div id="canvas" class="canvas">
<svg id="wires" viewBox="0 0 900 500" aria-label="连接线" role="group"></svg>
<div class="component tank" style="--x:16%;--y:54%"><div class="tank-symbol"></div><strong>储气罐 A</strong><small>提供温度、压力</small></div>
<div class="component junction" style="--x:48%;--y:54%"><div class="junction-symbol"></div><strong>节点 N1</strong><small>P4NODE2 · 固定</small></div>
<div class="component valve" style="--x:84%;--y:54%"><div class="valve-symbol">↗</div><strong>阀门 B</strong><small>需要温度、压力</small></div>
<div class="component plug" style="--x:48%;--y:14%"><div class="plug-symbol"></div><strong>封堵 C</strong></div>
<div class="component plug" style="--x:48%;--y:87%"><div class="plug-symbol"></div><strong>封堵 D</strong></div>
<div id="ports"></div>
<div class="canvas-caption">只展示本次选型涉及的端口;部件位置及 pnode 子模型固定。</div>
</div>
<div class="connection-strip" id="connection-strip" aria-label="选择连接线"></div>
<div id="notice" role="status" aria-live="polite" class="notice">先保留连接,再确定每条线的计算模型。</div>
</section>
<aside class="inspector" aria-label="连线设置">
<div class="inspector-title"><strong id="inspector-title">连线</strong><span id="selected-id"></span></div>
<div id="inspector-body" class="inspector-body"></div>
</aside>
</main>
<footer class="statusbar"><span id="progress"></span><span id="save-state">演示独立保存</span></footer>
<dialog id="model-dialog" aria-labelledby="dialog-title">
<form method="dialog" class="dialog-shell">
<div class="dialog-header"><div><h1 id="dialog-title">选择连接线子模型</h1><div id="dialog-endpoints" class="secondary"></div></div><button type="submit" class="icon-button" aria-label="关闭子模型窗口">×</button></div>
<div class="dialog-content">
<div class="selection-context"><strong>兼容的子模型</strong><span>方向自动匹配</span></div>
<div id="model-options" role="radiogroup" aria-label="兼容子模型"></div>
<div class="choice-note">优先项按接口匹配与模型复杂度排序。是否计入压降、储气,请按实际管路选择。</div>
<details class="compatibility-details"><summary>端口需求与其他型号</summary><div id="port-requirements"></div><div id="excluded-models"></div></details>
</div>
<div class="dialog-footer"><button type="submit">取消</button><button id="apply-model" type="button">应用</button><button id="apply-next" type="button" class="primary">应用并下一条</button></div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,108 @@
:root { font: 14px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #edf1f5; --blue: #1764a0; --border: #cfd8e3; --muted: #647487; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; color: #263442; border: 1px solid var(--border); background: #fff; border-radius: 4px; padding: 6px 12px; }
button:hover { background: #f0f5fa; }
button:disabled { cursor: default; opacity: .5; }
button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid #247ec5; outline-offset: 3px; }
a { color: var(--blue); text-decoration: none; }
strong { font-weight: 600; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: #fff; border-bottom: 1px solid var(--border); }
.topbar strong { font-size: 16px; }
.demo-tag { display: inline-block; margin-left: 14px; color: var(--muted); font-size: 12px; }
.top-actions { display: flex; align-items: center; gap: 18px; }
.workflow { display: flex; align-items: center; gap: 4px; padding: 7px 18px; background: #f8fafc; border-bottom: 1px solid var(--border); }
.workflow button { display: flex; align-items: center; gap: 8px; background: transparent; border-color: transparent; padding: 8px 18px; }
.workflow button span { display: inline-grid; place-items: center; width: 21px; height: 21px; border-radius: 50%; background: #e3e9ef; font-size: 12px; }
.workflow button[aria-pressed="true"] { background: #e7f1fa; border-color: #b4cfe4; color: #155a94; }
.workflow button[aria-pressed="true"] span { background: var(--blue); color: #fff; }
.workflow-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; min-height: calc(100vh - 143px); }
.canvas-pane { min-width: 0; display: flex; flex-direction: column; }
.canvas-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 16px; min-height: 55px; border-bottom: 1px solid var(--border); font-size: 12px; }
.canvas-toolbar button { white-space: nowrap; font-size: 13px; }
.canvas { position: relative; width: 100%; aspect-ratio: 1.8; min-height: 420px; max-height: 620px; background-color: #fff; background-image: radial-gradient(#d3dce5 .7px, transparent .7px); background-size: 20px 20px; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wire-line { fill: none; stroke: #8b9aaa; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire-line.configured { stroke: #526c7c; stroke-dasharray: none; }
.wire-line.selected { stroke: #1472bd; stroke-width: 3; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; pointer-events: stroke; cursor: pointer; }
.wire-label { fill: #596f84; font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; paint-order: stroke; stroke: #fff; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.component { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; pointer-events: none; }
.component strong { position: absolute; top: calc(100% + 12px); white-space: nowrap; font-size: 13px; }
.component small { position: absolute; top: calc(100% + 33px); color: var(--muted); white-space: nowrap; font-size: 11px; }
.junction strong, .junction small { left: calc(100% + 18px); }
.tank-symbol { width: 58px; height: 76px; border: 2px solid #506579; border-radius: 16px; background: #f8fafc; }
.junction-symbol { width: 38px; height: 38px; position: relative; }
.junction-symbol::before, .junction-symbol::after { content: ""; position: absolute; background: #344f68; }
.junction-symbol::before { width: 38px; height: 3px; top: 17px; }
.junction-symbol::after { width: 3px; height: 38px; left: 17px; }
.valve-symbol { width: 58px; height: 43px; display: grid; place-items: center; font: 33px/1 sans-serif; border: 2px solid #506579; background: #f8fafc; }
.plug-symbol { width: 24px; height: 8px; background: repeating-linear-gradient(130deg, #506579 0 2px, #fff 2px 5px); border-bottom: 2px solid #506579; }
.plug strong { top: -5px; left: calc(100% + 12px); }
.port { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: 27px; height: 27px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; border-radius: 50%; }
.port::before { content: ""; width: 9px; height: 9px; background: #fff; border: 2px solid #4c6d87; border-radius: 50%; }
.port.pending { background: #e3effa; }
.port.pending::before { background: #1472bd; border-color: #1472bd; }
.port span { position: absolute; left: 23px; top: -4px; color: #516678; font-size: 10px; background: #fff; white-space: nowrap; pointer-events: none; }
.port.reference::before { border-color: #b37621; }
.port.reference span { color: #8d5d1b; }
.canvas-caption { position: absolute; bottom: 10px; left: 14px; color: #738292; font-size: 11px; }
.connection-strip { display: flex; flex-wrap: wrap; gap: 8px; padding: 13px 16px; border-top: 1px solid var(--border); background: #f8fafc; }
.connection-strip button { font-size: 12px; display: flex; gap: 7px; align-items: center; }
.connection-strip button[aria-pressed="true"] { color: var(--blue); border-color: #6299c5; background: #eaf3fb; }
.dot { width: 6px; height: 6px; background: #adbac7; border-radius: 50%; }
.dot.ready { background: #427e63; }
.notice { margin-top: auto; padding: 13px 16px; color: #526679; font-size: 12px; border-top: 1px solid #dbe2e9; }
.inspector { border-left: 1px solid var(--border); background: #fff; min-width: 0; }
.inspector-title { display: flex; justify-content: space-between; padding: 14px 16px; background: #f5f7fa; border-bottom: 1px solid var(--border); }
.inspector-title span { color: var(--muted); }
.inspector-body { padding: 16px; }
.secondary { font-size: 12px; color: var(--muted); }
.endpoint { color: #435a6e; overflow-wrap: anywhere; padding: 0 0 14px; }
.endpoint div + div { margin-top: 5px; }
.section-label { font-size: 12px; color: var(--muted); margin: 16px 0 6px; }
.model-name { font-size: 18px; font-weight: 600; margin: 0 0 5px; }
.inspector-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
.primary { background: #1a68a6; border-color: #1a68a6; color: #fff; }
.primary:hover { background: #145b94; }
.inspector-body p { margin: 8px 0 14px; }
.parameter-form { display: grid; gap: 12px; margin-top: 14px; }
.parameter-form label { display: grid; gap: 5px; color: #405568; font-size: 12px; }
.parameter-form input { width: 100%; border: 1px solid #bdcbd8; border-radius: 3px; padding: 7px 9px; font-size: 14px; color: #243849; }
.parameter-form input[aria-invalid="true"] { border-color: #b46d22; }
.parameter-state { font-size: 12px; padding: 8px 0; color: #7b5a2c; }
.parameter-state.valid { color: #337055; }
.check-list { display: grid; gap: 8px; }
.check-item { padding: 10px; border: 1px solid #dce3e9; border-radius: 4px; }
.check-item button { margin-top: 7px; font-size: 12px; }
.check-item.complete { border-left: 3px solid #549375; }
.check-summary { padding: 10px 0; font-weight: 600; }
.check-summary.complete { color: #337055; }
.statusbar { display: flex; gap: 12px; justify-content: space-between; padding: 8px 16px; background: #f8fafc; border-top: 1px solid var(--border); color: #657588; font-size: 12px; }
dialog { width: min(620px, calc(100vw - 32px)); padding: 0; border: 1px solid #b6c4d1; border-radius: 7px; color: #263442; background: #fff; box-shadow: 0 16px 65px #182e4533; max-height: calc(100dvh - 32px); }
dialog::backdrop { background: #1c304347; }
.dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 17px 20px; }
h1 { font-size: 17px; margin: 0 0 5px; font-weight: 600; }
.icon-button { border: 0; font-size: 23px; padding: 0 8px; line-height: 1.3; }
.dialog-content { padding: 16px 20px; }
.selection-context { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 12px; }
.selection-context span { color: var(--muted); }
#model-options { display: grid; gap: 8px; }
.model-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #d3dde6; border-radius: 5px; padding: 12px; cursor: pointer; }
.model-option:has(input:checked) { border-color: #3382be; background: #f0f7fc; }
.model-option input { margin: 4px 0 0; accent-color: var(--blue); }
.model-option strong { display: inline-block; margin-right: 8px; }
.model-option small { display: block; color: #566c80; margin-top: 4px; font-size: 12px; }
.recommendation { font-size: 11px; color: #1f638f; font-weight: 400; }
.choice-note { font-size: 12px; color: #5d6b7a; margin: 12px 0; }
details { font-size: 12px; color: #5d6e80; }
summary { cursor: pointer; padding: 5px 0; }
#port-requirements { line-height: 1.8; margin: 7px 0; }
.excluded-model { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-top: 1px solid #e5ebf1; }
.dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 20px; background: #f6f8fa; border-top: 1px solid var(--border); }
@media(max-width: 1000px) { .workspace { grid-template-columns: minmax(0, 1fr) 275px; } .canvas { min-height: 390px; } .workflow button { padding: 7px 12px; } .workflow-note { display: none; } .component small { display: none; } }
@media(max-width: 760px) { .workspace { display: block; } .canvas { min-height: 340px; } .inspector { border-left: 0; border-top: 1px solid var(--border); } .topbar { align-items: start; padding: 12px; } .demo-tag { display: block; margin: 3px 0 0; } .top-actions { gap: 10px; font-size: 12px; } .workflow { padding: 7px; justify-content: space-between; } .workflow button { padding: 7px 9px; gap: 5px; } .canvas-toolbar { align-items: start; } .canvas-caption { font-size: 10px; max-width: 90%; } .component strong { font-size: 11px; } .statusbar { flex-wrap: wrap; } }
@media(pointer: coarse) { button, .port { min-height: 44px; } input[type="number"] { min-height: 44px; font-size: 16px; } }
@@ -0,0 +1,42 @@
# 管路子模型三栏布局 demo
打开:`http://127.0.0.1:5173/demos/pipe-submodels/index.html`
此版只开放 PNL00R、PNL0001、PNL0002、PNL0003 的管路子模型。左侧为管路子模型库,中间为固定气动网络,右侧为子模型属性及参数表,底部为配置检查信息。采用主应用的三栏、工作模式标签、紧凑工具栏和参数表结构,借鉴 Amesim 的子模型工作模式;不是完整复刻 Amesim 界面。
## 操作
1. 默认进入“子模型”。单击画布中的管路或左侧管路列表进行选择。
2. 左侧默认只展示兼容管路型号。点击预览,在右侧“应用到 L…”或“应用并下一条”。双击型号也可应用。
3. 切换“参数”后编辑数值;子模型阶段参数只读,避免选型时误改物理参数。
4. 切换“检查”,底部显示本例的局部匹配和必填参数检查。点击问题项直接定位。
5. 滚轮或左下角按钮缩放;拖动画布空白区平移查看;“适配视图”恢复视口。
## 位置锁定
- 部件坐标和连接端点为固定定义,选型及参数状态不包含坐标。
- 所有模式均不提供部件拖动、连线改路由、重新连接、删除、旋转或镜像。
- pnode 固定为 P4NODE2,参考端口不变。其他部件只作为管路端点的上下文。
- 允许的平移、缩放只改变观察视口,不改变模型坐标或连接。
## 演示范围
- 四条管路覆盖两侧提供状态、两侧需要状态、一侧提供/另一侧需要状态的局部匹配场景。
- 不包含其他组件子模型选型;理想连接不进入本版管路库。
- 管路接口元数据按上一版已核对的本机 Amesim 管路定义整理,数值代码未复制。
- C/R 图标沿用主应用储气/阻力表示。管路实际端口映射见“端口方向与选型说明”,与画线顺序无关。
- 子模型切换可能改变是否计入储气、储气分布及状态数量,界面明确展示其物理作用。仅按接口筛选,不能替代工程物理选型。
- 仅为固定局部网络的交互 demo。未接入全网方程分析、XML、SI 单位转换或仿真后端,不执行求解。
- 主应用及正式组件、pnode、数值内核不作修改。没有新增 npm 依赖。
## 恢复
- “重置演示”只重置本页的选型与参数。
- 本版独立存储键:`system-simulation:pipe-submodels-layout-demo:v1`。
- 上一版完整保留在 `../amesim-submodels/index.html`,两个演示互不读取对方存档。
- 撤回此版只需移除 `frontend/public/demos/pipe-submodels/`,不需要回退其他源码。
- 位于 Vite public 下的 demo 会随前端打包输出;正式发布前可移除演示目录。
## 本次验证
2026-09-10:JavaScript 语法与静态入口检查通过。Edge 浏览器验证部件拖动、连线拖动与 Delete 均不改变固定坐标/连接;缩放、空白区平移只改变视口。四种管路的兼容筛选、预览与应用分离、连续选型、子模型阶段参数只读、参数校验、更换型号保留共同参数、反向端口映射、刷新恢复及独立重置通过。桌面、窄桌面和移动宽度没有横向溢出,未发起后端 API 请求。检查截图在 `test/pipe-submodels-demo/`。
+313
View File
@@ -0,0 +1,313 @@
(() => {
'use strict';
const KEY = 'system-simulation:pipe-submodels-layout-demo:v1';
const $ = id => document.getElementById(id);
const safe = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// The demo never stores component coordinates or connection endpoints in editable state.
const endpoints = Object.freeze({
'A.1': Object.freeze({ label: '储气罐 A · 端口 1', state: 'out' }),
'N1.1': Object.freeze({ label: '节点 N1 · 端口 1', state: 'out' }),
'N1.2': Object.freeze({ label: '节点 N1 · 端口 2(参考)', state: 'in' }),
'N1.3': Object.freeze({ label: '节点 N1 · 端口 3', state: 'out' }),
'N1.4': Object.freeze({ label: '节点 N1 · 端口 4', state: 'out' }),
'B.2': Object.freeze({ label: '阀门 B · 端口 2', state: 'in' }),
'C.1': Object.freeze({ label: '封堵 C · 端口 1', state: 'in' }),
'D.1': Object.freeze({ label: '封堵 D · 端口 1', state: 'in' }),
});
const PIPES = Object.freeze([
Object.freeze({ id: 'L1', a: 'A.1', b: 'N1.1', path: 'M175 360H460', x: 315, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L2', a: 'N1.2', b: 'B.2', path: 'M500 360H790', x: 645, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L3', a: 'C.1', b: 'N1.3', path: 'M480 134V340', x: 507, y: 240, anchor: 'start' }),
Object.freeze({ id: 'L4', a: 'N1.4', b: 'D.1', path: 'M480 380V566', x: 507, y: 485, anchor: 'start' }),
]);
const MODELS = [
{ id: 'PNL00R', name: '阻性管路', sections: ['R'], ends: ['in', 'in'], caption: '压降 · 无储气', description: '计算摩擦压降,不增加管内储气状态。', states: 0, fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', name: '单端储气管路', sections: ['C', 'R'], ends: ['in', 'out'], caption: '单端储气', description: '考虑摩擦压降与一处储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', name: '中间储气管路', sections: ['R', 'C', 'R'], ends: ['in', 'in'], caption: '中间储气', description: '考虑摩擦压降与中间储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', name: '双端储气管路', sections: ['C', 'R', 'C'], ends: ['out', 'out'], caption: '双端储气', description: '考虑摩擦压降与两端储气容积,包含 4 个热力状态。', states: 4, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const FIELDS = {
length: { name: '管长', unit: 'm', min: 0, positive: true, default: '' },
diameter: { name: '内径', unit: 'mm', min: 0, positive: true, default: '' },
roughness: { name: '相对粗糙度', unit: '—', min: 0, max: .1, default: '0.00001' },
pressure: { name: '初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature: { name: '初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
pressure2: { name: '端口 2 初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature2: { name: '端口 2 初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
};
const modelById = id => MODELS.find(model => model.id === id);
const pipeById = id => PIPES.find(pipe => pipe.id === id);
const initial = () => ({ version: 1, mode: 'submodel', selected: 'L1', configs: Object.fromEntries(PIPES.map(pipe => [pipe.id, { model: null, params: {} }])) });
let state = initial();
let preview = null, storageOK = true, consoleOpen = true;
let pan = null, view = { zoom: 1, x: 0, y: 0 }, baseScale = 1;
let log = [{ kind: 'INFO', text: '选择中间画布的管路,再从左侧指定子模型。' }, { kind: 'INFO', text: '位置和连接关系固定;支持缩放与空白区平移查看。' }];
const activePipe = () => pipeById(state.selected);
const activeConfig = () => state.configs[state.selected];
function compatible(pipe, model) {
const a = endpoints[pipe.a].state, b = endpoints[pipe.b].state;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(pipe) { return MODELS.filter(model => compatible(pipe, model)); }
function choosePreview() { preview = activeConfig().model || candidates(activePipe())[0]?.id || null; }
function load() {
try {
const data = JSON.parse(localStorage.getItem(KEY) || 'null');
if (!data || data.version !== 1 || !data.configs || typeof data.configs !== 'object') return;
const restored = initial();
if (['modeling', 'submodel', 'parameters', 'check'].includes(data.mode)) restored.mode = data.mode;
if (pipeById(data.selected)) restored.selected = data.selected;
PIPES.forEach(pipe => {
const item = data.configs[pipe.id];
if (!item || !modelById(item.model) || !compatible(pipe, modelById(item.model))) return;
restored.configs[pipe.id] = { model: item.model, params: Object.fromEntries(Object.keys(FIELDS)
.filter(key => ['string', 'number'].includes(typeof item.params?.[key]))
.map(key => [key, String(item.params[key]).slice(0, 64)])) };
});
state = restored;
} catch { storageOK = false; }
}
function save() {
try { localStorage.setItem(KEY, JSON.stringify(state)); storageOK = true; }
catch { storageOK = false; }
$('save-state').textContent = storageOK ? '演示已保存' : '仅当前页面保留';
}
function tell(text) { $('notice').textContent = text; }
function addLog(text, kind = 'INFO') { log = [...log.slice(-15), { kind, text }]; renderLog(); }
function valid(key, value) {
const spec = FIELDS[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (spec.positive ? number > spec.min : number >= spec.min) && (spec.max === undefined || number <= spec.max);
}
function missing(pipe) {
const config = state.configs[pipe.id];
return (modelById(config.model)?.fields || []).filter(key => !valid(key, config.params[key]));
}
function pipeStatus(pipe) {
const config = state.configs[pipe.id];
return !config.model ? '未指定' : missing(pipe).length ? '待填参数' : '配置完成';
}
function pipeIcon(model) {
const width = 58 / model.sections.length;
return `<svg viewBox="0 0 84 44" aria-hidden="true"><path d="M1 22H13M71 22H83" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="13" y="10" width="58" height="24" fill="white" stroke="currentColor" stroke-width="1.5"/>${model.sections.map((section, index) => `${index ? `<path d="M${13 + index * width} 10V34" stroke="currentColor" stroke-width="1"/>` : ''}<text x="${13 + width * (index + .5)}" y="26" text-anchor="middle" font-family="Segoe UI,sans-serif" font-size="12" fill="currentColor">${section}</text>`).join('')}</svg>`;
}
function renderLibrary() {
const pipe = activePipe(), editable = state.mode === 'submodel';
const list = $('compatible-only').checked ? candidates(pipe) : MODELS;
$('library').innerHTML = list.map(model => {
const ok = compatible(pipe, model);
return `<button type="button" class="model-tile" data-model="${model.id}" draggable="false" aria-label="${model.id} ${model.name}${ok ? '' : ',不兼容'}" aria-pressed="${preview === model.id}" ${!ok || !editable ? 'disabled' : ''} title="${ok ? model.description : '与当前管路两端的变量供需不匹配'}">${pipeIcon(model)}<strong>${model.id}</strong><small>${model.caption}</small></button>`;
}).join('');
$('library-hint').textContent = editable ? `${pipe.id}:${candidates(pipe).length} 个兼容型号。点击预览,应用后生效。` : '切换到“子模型”模式可更换管路型号。';
$('pipe-list').innerHTML = PIPES.map(item => `<button type="button" data-pipe="${item.id}" aria-label="${item.id} ${pipeStatus(item)}" aria-pressed="${item.id === state.selected}"><span class="state-dot ${state.configs[item.id].model ? missing(item).length ? 'assigned' : 'ready' : ''}"></span><strong>${item.id}</strong><span class="model-id">${state.configs[item.id].model || '未指定'}</span></button>`).join('');
}
function drawFixedComponents() {
const port = (x, y, label = '') => `<circle cx="${x}" cy="${y}" r="4" fill="white" stroke="#b3809a" stroke-width="1.2"/>${label ? `<text class="port-label" x="${x + 9}" y="${y - 10}">${label}</text>` : ''}`;
$('component-layer').innerHTML = `
<g class="fixed-component" data-component="A" transform="translate(140 360)"><rect x="-35" y="-40" width="70" height="80" rx="0" fill="white" stroke="#be8ca5" stroke-width="1.5"/>${port(35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">储气罐 A</text><text class="component-code" x="0" y="101" text-anchor="middle">PNCH023</text></g>
<g class="fixed-component" data-component="N1" transform="translate(480 360)"><path d="M-20 0H20M0-20V20" stroke="#ae7391" stroke-width="1.6" fill="none"/>${port(-20, 0, '1')}${port(20, 0, '2')}${port(0, -20, '3')}${port(0, 20, '4')}<text class="component-label" x="36" y="63">节点 N1</text><text class="component-code" x="36" y="88">P4NODE2 · 固定</text></g>
<g class="fixed-component" data-component="B" transform="translate(825 360)"><path d="M-35 0H35M-20-17Q0 9 20-17M-20 17Q0-9 20 17M-18 31L18-31" stroke="#be8ca5" stroke-width="1.6" fill="none"/><path d="M18-31L9-23L18-20Z" fill="#be8ca5"/>${port(-35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">阀门 B</text><text class="component-code" x="0" y="101" text-anchor="middle">PNVO001</text></g>
<g class="fixed-component" data-component="C" transform="translate(480 130)"><path d="M-15-4H15M-12-4L-7-11M-3-4L2-11M6-4L11-11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, 4)}<text class="component-label" x="30" y="5">封堵 C</text></g>
<g class="fixed-component" data-component="D" transform="translate(480 570)"><path d="M-15 4H15M-12 4L-7 11M-3 4L2 11M6 4L11 11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, -4)}<text class="component-label" x="30" y="5">封堵 D</text></g>`;
}
function renderWires() {
$('wire-layer').innerHTML = PIPES.map(pipe => `<g><path class="wire ${state.configs[pipe.id].model ? 'assigned' : ''} ${pipe.id === state.selected ? 'selected' : ''}" d="${pipe.path}"/><path class="wire-hit" data-pipe="${pipe.id}" d="${pipe.path}"/></g>`).join('');
const compact = $('canvas').clientWidth < 460;
$('pipe-label-layer').innerHTML = PIPES.map(pipe => `<text class="pipe-name ${pipe.id === state.selected ? 'selected' : ''}" x="${pipe.x}" y="${pipe.y}" text-anchor="${pipe.anchor}">${pipe.id}${!compact && $('show-labels').checked && state.mode !== 'modeling' ? ' · ' + (state.configs[pipe.id].model || '未指定') : ''}</text>`).join('');
$('diagram').classList.toggle('compact', compact);
applyView();
}
function resizeCanvas() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
if (!width || !height) return;
$('diagram').setAttribute('viewBox', `0 0 ${width} ${height}`);
baseScale = Math.max(.1, Math.min((width - 30) / 960, (height - 35) / 660));
renderWires();
}
function applyView() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const scale = baseScale * view.zoom;
const x = (width - 960 * scale) / 2 + view.x, y = (height - 660 * scale) / 2 + view.y;
$('scene').setAttribute('transform', `translate(${x} ${y}) scale(${scale})`);
$('zoom-value').textContent = `${Math.round(view.zoom * 100)}%`;
const textScale = Math.max(baseScale, .42);
$('scene').querySelectorAll('.component-label,.pipe-name').forEach(label => { label.style.fontSize = `${12 / textScale}px`; });
$('scene').querySelectorAll('.component-code').forEach(label => { label.style.fontSize = `${10 / textScale}px`; });
$('grid-pattern').setAttribute('width', 20 * view.zoom);
$('grid-pattern').setAttribute('height', 20 * view.zoom);
$('grid-pattern').setAttribute('patternTransform', `translate(${view.x} ${view.y})`);
}
function portMapping(pipe, model) {
if (model.ends[0] === model.ends[1]) return `端口 1:${endpoints[pipe.a].label};端口 2:${endpoints[pipe.b].label}。两端接口对称。`;
const forward = endpoints[pipe.a].state !== model.ends[0];
return `端口 1:${endpoints[forward ? pipe.a : pipe.b].label};端口 2:${endpoints[forward ? pipe.b : pipe.a].label}。`;
}
function renderProperties() {
const pipe = activePipe(), config = activeConfig();
const model = modelById(state.mode === 'submodel' ? preview : config.model);
$('selected-pipe').textContent = pipe.id;
const editable = state.mode === 'submodel';
$('model-properties').innerHTML = `<table class="property-table"><tbody>
<tr><th>对象</th><td>管路 ${pipe.id}</td></tr>
<tr><th>连接端 A</th><td>${safe(endpoints[pipe.a].label)}</td></tr>
<tr><th>连接端 B</th><td>${safe(endpoints[pipe.b].label)}</td></tr>
<tr><th>已用型号</th><td>${config.model || '未指定'}</td></tr>
</tbody></table>${model ? `<div class="preview"><div class="preview-title">${pipeIcon(model)}<div><strong>${model.id}</strong><small>${model.name}${editable && model.id !== config.model ? ' · 待应用' : ''}</small></div></div><p class="preview-description">${model.description}</p>
${editable ? `<div class="apply-buttons"><button type="button" class="primary" data-action="apply" ${model.id === config.model ? 'disabled' : ''}>应用到 ${pipe.id}</button><button type="button" data-action="apply-next">应用并下一条</button></div>` : ''}
<details class="property-details"><summary>端口方向与选型说明</summary><p>${safe(portMapping(pipe, model))}</p><p>按接口筛选;储气、压降等物理作用由所选型号决定。</p><p>C 表示储气,R 表示阻力。图标沿用建模区表示方式。</p></details></div>` : '<div class="empty-hint">在“子模型”模式从左侧选择管路型号。</div>'}`;
renderParameters();
}
function renderParameters() {
const config = activeConfig(), model = modelById(config.model), editable = state.mode === 'parameters';
$('edit-parameters').disabled = !model || editable;
if (!model) { $('parameter-properties').innerHTML = '<div class="empty-hint">应用管路子模型后显示所需参数。</div>'; return; }
$('parameter-properties').innerHTML = `<table class="parameter-table"><thead><tr><th>参数</th><th>数值</th><th>单位</th></tr></thead><tbody>${model.fields.map(key => {
const spec = FIELDS[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '端口 1 ' + spec.name : spec.name;
return `<tr><td><label for="parameter-${key}">${label}</label></td><td><input id="parameter-${key}" type="number" data-parameter="${key}" value="${safe(config.params[key] ?? '')}" min="${spec.min}" ${spec.max !== undefined ? `max="${spec.max}"` : ''} step="any" ${editable ? '' : 'readonly'} aria-invalid="${editable && !valid(key, config.params[key])}" placeholder="待填"></td><td>${spec.unit}</td></tr>`;
}).join('')}</tbody></table><div class="parameter-note" id="parameter-note"></div>`;
updateParameterNote();
}
function updateParameterNote() {
const note = $('parameter-note');
if (!note) return;
const count = missing(activePipe()).length;
note.classList.toggle('ready', !count);
note.textContent = state.mode !== 'parameters' ? '当前只读;进入参数模式填写实际值。' : count ? `待填写或修正 ${count} 项。` : '参数完整 · 已保存到此演示。';
}
function updateProgress() {
const assigned = PIPES.filter(pipe => state.configs[pipe.id].model).length;
const ready = PIPES.filter(pipe => state.configs[pipe.id].model && !missing(pipe).length).length;
$('progress').textContent = `${assigned}/4 已选型 · ${ready}/4 参数完整`;
}
function renderLog() {
const items = state.mode === 'check' ? PIPES.map(pipe => {
const config = state.configs[pipe.id];
return { kind: !config.model || missing(pipe).length ? 'WARN' : 'OK', text: `${pipe.id} ${config.model || '未指定子模型'} ${pipeStatus(pipe)}`, pipe: !config.model || missing(pipe).length ? pipe.id : null };
}) : log;
$('console-content').innerHTML = items.map(item => `<div class="log-row"><span class="log-kind ${item.kind === 'WARN' ? 'warn' : item.kind === 'OK' ? 'ok' : ''}">${item.kind}</span>${item.pipe ? `<button type="button" data-fix="${item.pipe}">${safe(item.text)} → 处理</button>` : `<span>${safe(item.text)}</span>`}</div>`).join('');
if (state.mode === 'check') {
const complete = items.every(item => item.kind === 'OK');
$('console-summary').textContent = complete ? '局部配置检查通过 · 未执行仿真' : '点击问题项定位';
} else $('console-summary').textContent = '仅配置演示,不执行仿真';
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
$('mode-marker').textContent = ({ modeling: '建模预览', submodel: '子模型模式', parameters: '参数模式', check: '配置检查' })[state.mode];
renderLibrary(); renderWires(); renderProperties(); renderLog(); updateProgress();
}
function setMode(mode) {
state.mode = mode; choosePreview(); render(); save();
tell(mode === 'modeling' ? '此 demo 的部件、位置与连接固定;进入子模型模式选择管路型号。' : mode === 'submodel' ? '选中管路,在左侧预览型号,在右侧应用。' : mode === 'parameters' ? '在右侧填写选中管路的实际参数。' : '当前仅检查演示的接口匹配和必填参数。');
if (mode === 'check') { consoleOpen = true; renderConsoleVisibility(); }
}
function selectPipe(id) {
if (!pipeById(id)) return;
state.selected = id; choosePreview(); render(); save();
tell(`${id} 已选中。部件与连线位置保持锁定。`);
}
function nextUnassigned() {
const start = PIPES.findIndex(pipe => pipe.id === state.selected);
const ordered = [...PIPES.slice(start + 1), ...PIPES.slice(0, start + 1)];
const next = ordered.find(pipe => !state.configs[pipe.id].model);
if (next) { setMode('submodel'); selectPipe(next.id); }
else tell('所有管路均已选型。可进入参数模式继续填写。');
}
function applyModel(next = false) {
if (state.mode !== 'submodel') return;
const pipe = activePipe(), model = modelById(preview), config = activeConfig();
if (!model || !compatible(pipe, model)) return;
config.model = model.id;
model.fields.forEach(key => { if (config.params[key] === undefined) config.params[key] = FIELDS[key].default; });
render(); save(); addLog(`${pipe.id} 已应用 ${model.id}。${model.description}`);
tell(`${pipe.id} 已应用 ${model.id}。`);
if (next) {
if (PIPES.some(item => !state.configs[item.id].model)) nextUnassigned();
else { const target = PIPES.find(item => missing(item).length); if (target) state.selected = target.id; setMode('parameters'); tell('管路选型完成;填写实际管长、内径后检查配置。'); }
}
}
function zoom(factor, anchor = null) {
const next = Math.max(.5, Math.min(3, view.zoom * factor));
if (anchor) {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const ratio = next / view.zoom;
view.x = anchor.x - width / 2 - (anchor.x - width / 2 - view.x) * ratio;
view.y = anchor.y - height / 2 - (anchor.y - height / 2 - view.y) * ratio;
}
view.zoom = next; applyView();
}
function renderConsoleVisibility() {
$('console').classList.toggle('collapsed', !consoleOpen);
$('toggle-console').setAttribute('aria-expanded', String(consoleOpen));
$('toggle-console').textContent = `${consoleOpen ? '▾' : '▸'} 配置信息`;
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('pipe-list').addEventListener('click', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) selectPipe(id); });
$('diagram').addEventListener('click', event => {
if (pan?.moved) return;
const pipe = event.target.closest('[data-pipe]');
if (pipe) selectPipe(pipe.dataset.pipe);
else if (event.target.closest('[data-component]')) tell('此 demo 只开放管路子模型,部件与 pnode 固定。');
});
$('diagram').addEventListener('dblclick', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) { setMode('submodel'); selectPipe(id); $('library').querySelector('button:not(:disabled)')?.focus(); } });
$('library').addEventListener('click', event => {
const target = event.target.closest('[data-model]');
if (!target || target.disabled || state.mode !== 'submodel') return;
preview = target.dataset.model; renderLibrary(); renderProperties();
tell(`预览 ${preview};点击“应用到 ${state.selected}”生效。`);
});
$('library').addEventListener('dblclick', event => { const target = event.target.closest('[data-model]'); if (target && !target.disabled) { preview = target.dataset.model; applyModel(); } });
$('compatible-only').addEventListener('change', renderLibrary);
$('show-labels').addEventListener('change', renderWires);
$('next-unassigned').addEventListener('click', nextUnassigned);
$('model-properties').addEventListener('click', event => { const action = event.target.closest('[data-action]')?.dataset.action; if (action === 'apply' || action === 'apply-next') applyModel(action === 'apply-next'); });
$('edit-parameters').addEventListener('click', () => setMode('parameters'));
$('parameter-properties').addEventListener('input', event => {
if (state.mode !== 'parameters') return;
const key = event.target.dataset.parameter;
if (!FIELDS[key]) return;
activeConfig().params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!valid(key, event.target.value)));
updateParameterNote(); updateProgress(); renderLibrary(); save();
});
$('console-content').addEventListener('click', event => {
const id = event.target.closest('[data-fix]')?.dataset.fix;
if (id) { selectPipe(id); setMode(state.configs[id].model ? 'parameters' : 'submodel'); }
});
$('toggle-console').addEventListener('click', () => { consoleOpen = !consoleOpen; renderConsoleVisibility(); });
$('fit-view').addEventListener('click', () => { view = { zoom: 1, x: 0, y: 0 }; applyView(); });
$('zoom-in').addEventListener('click', () => zoom(1.2));
$('zoom-out').addEventListener('click', () => zoom(1 / 1.2));
$('diagram').addEventListener('wheel', event => { event.preventDefault(); const bounds = $('diagram').getBoundingClientRect(); zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, { x: event.clientX - bounds.left, y: event.clientY - bounds.top }); }, { passive: false });
// Only viewport movement is supported. No component/port/edge drag handler exists.
$('diagram').addEventListener('pointerdown', event => {
if (event.button !== 1 && !(event.button === 0 && event.target.hasAttribute('data-background'))) return;
event.preventDefault();
pan = { id: event.pointerId, x: event.clientX, y: event.clientY, initialX: view.x, initialY: view.y, moved: false };
$('diagram').setPointerCapture(event.pointerId); $('diagram').classList.add('panning');
});
$('diagram').addEventListener('pointermove', event => {
if (!pan || pan.id !== event.pointerId) return;
const dx = event.clientX - pan.x, dy = event.clientY - pan.y;
pan.moved ||= Math.abs(dx) + Math.abs(dy) > 3;
view.x = pan.initialX + dx; view.y = pan.initialY + dy; applyView();
});
function endPan(event) {
if (!pan || pan.id !== event.pointerId) return;
if ($('diagram').hasPointerCapture(event.pointerId)) $('diagram').releasePointerCapture(event.pointerId);
$('diagram').classList.remove('panning'); pan = null;
}
$('diagram').addEventListener('pointerup', endPan);
$('diagram').addEventListener('pointercancel', endPan);
$('diagram').addEventListener('lostpointercapture', endPan);
document.addEventListener('dragstart', event => { if (event.target.closest('#diagram,#library')) event.preventDefault(); });
document.addEventListener('keydown', event => {
if (event.key === 'Delete' && !event.target.closest('input,textarea,[contenteditable=true]')) { event.preventDefault(); tell('子模型演示的部件和连接固定,不能删除或移动。'); }
});
$('reset').addEventListener('click', () => {
state = initial(); view = { zoom: 1, x: 0, y: 0 }; pan = null; choosePreview();
log = [{ kind: 'INFO', text: '已重置本页的管路选型与参数。固定布局保持原样。' }];
render(); save(); tell('已重置此 demo;上一版和正式工程不受影响。');
});
load(); choosePreview(); drawFixedComponents(); render(); save();
new ResizeObserver(resizeCanvas).observe($('canvas'));
})();
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管路子模型 · System Simulation</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="product"><strong>System Simulation</strong><span>管路子模型布局演示</span></div>
<nav class="view-tabs" aria-label="工作模式">
<button data-mode="modeling" type="button" aria-pressed="false">建模</button>
<button data-mode="submodel" type="button" aria-pressed="true">子模型</button>
<button data-mode="parameters" type="button" aria-pressed="false">参数</button>
<button data-mode="check" type="button" aria-pressed="false">检查</button>
</nav>
<div class="topbar-actions"><a href="../amesim-submodels/index.html">上一版</a><a href="/">主应用</a><button id="reset" type="button">重置演示</button></div>
</header>
<nav class="editor-toolbar" aria-label="子模型工具">
<span class="mode-marker" id="mode-marker">子模型模式</span>
<span class="toolbar-separator"></span>
<button id="next-unassigned" type="button"><span aria-hidden="true">↦</span> 下一条待选</button>
<button id="fit-view" type="button">适配视图</button>
<label class="toolbar-check"><input type="checkbox" id="show-labels" checked>显示子模型名称</label>
<span class="toolbar-end"><svg width="13" height="14" viewBox="0 0 13 14" aria-hidden="true"><rect x="2" y="6" width="9" height="7" rx="1"/><path d="M4 6V4a2.5 2.5 0 0 1 5 0v2"/></svg>布局已锁定</span>
</nav>
<main class="workbench">
<aside class="palette" aria-label="管路子模型库">
<div class="panel-title"><strong>管路子模型</strong><span>4 个</span></div>
<div class="palette-content">
<label class="compat-filter"><input id="compatible-only" type="checkbox" checked>只显示当前连线兼容项</label>
<div class="library-heading">▾ 气动管路</div>
<div id="library" class="palette-grid" aria-label="可选管路子模型"></div>
<div id="library-hint" class="library-hint">先选中画布中的管路。</div>
</div>
<div class="section-heading">模型中的管路</div>
<div id="pipe-list" class="pipe-list" aria-label="管路列表"></div>
<div class="palette-footer">仅管路可选型<br>pnode 与其他部件固定</div>
</aside>
<div class="pane-divider" aria-hidden="true"></div>
<section class="center-pane" aria-label="模型画布与检查信息">
<div class="document-tab"><span>气动管路示例</span><span class="document-note">固定连接 · 独立 demo</span></div>
<div id="canvas" class="canvas">
<svg id="diagram" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="固定气动网络,选择管路可指定子模型,部件位置不可修改">
<defs><pattern id="grid-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="0.75" fill="#c8d1da"/></pattern></defs>
<rect width="100%" height="100%" fill="white" data-background="true"/>
<rect width="100%" height="100%" fill="url(#grid-pattern)" data-background="true"/>
<g id="scene"><g id="wire-layer"></g><g id="component-layer"></g><g id="pipe-label-layer"></g></g>
</svg>
<div class="canvas-lock">部件 / 连线位置锁定</div>
<div class="viewport-controls"><button type="button" id="zoom-out" aria-label="缩小画布">−</button><span id="zoom-value">100%</span><button type="button" id="zoom-in" aria-label="放大画布">+</button></div>
</div>
<section id="console" class="console" aria-label="配置检查信息">
<div class="console-header"><button id="toggle-console" type="button" aria-expanded="true">▾ 配置信息</button><span id="console-summary">仅配置演示,不执行仿真</span></div>
<div id="console-content" class="console-content"></div>
</section>
</section>
<div class="pane-divider" aria-hidden="true"></div>
<aside class="properties" aria-label="子模型属性与参数">
<section class="properties-subpane">
<div class="panel-title"><strong>子模型属性</strong><span id="selected-pipe">L1</span></div>
<div id="model-properties" class="properties-content"></div>
</section>
<div class="horizontal-divider" aria-hidden="true"></div>
<section class="properties-subpane">
<div class="panel-title"><strong>管路参数</strong><button id="edit-parameters" type="button">进入参数模式</button></div>
<div id="parameter-properties" class="properties-content"></div>
</section>
</aside>
</main>
<footer class="statusbar"><span id="notice" role="status" aria-live="polite">选择管路,再从左侧指定子模型。</span><span id="progress"></span><span id="save-state">独立保存</span></footer>
</div>
</body>
</html>
@@ -0,0 +1,126 @@
:root { font: 13px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #eef2f6; --blue: #1d6fb8; --border: #cfd8e3; --muted: #64748b; --gas: #8b134f; }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #c2ccda; background: #fff; color: #263442; padding: 5px 10px; border-radius: 4px; }
button:hover { background: #eff5fa; }
button:disabled { cursor: default; opacity: .48; }
button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
a { color: #155a94; text-decoration: none; }
input[type=checkbox] { accent-color: var(--blue); }
.app-shell { height: 100dvh; display: grid; grid-template-rows: 62px 42px minmax(0,1fr) 28px; }
.topbar { display: grid; grid-template-columns: minmax(220px,1fr) auto minmax(240px,1fr); align-items: center; gap: 16px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: #fff; }
.product strong { display: block; font-size: 15px; }
.product span { display: block; margin-top: 2px; font-size: 12px; color: var(--muted); }
.view-tabs { display: flex; height: 34px; border-bottom: 1px solid #bfcbd8; }
.view-tabs button { min-width: 68px; padding: 0 13px; border: 0; border-radius: 0; border-bottom: 2px solid transparent; color: #526172; background: transparent; }
.view-tabs button[aria-pressed=true] { border-bottom-color: var(--blue); color: #155a94; background: #eaf3fa; font-weight: 600; }
.topbar-actions { display: flex; justify-content: flex-end; align-items: center; gap: 13px; font-size: 12px; }
.editor-toolbar { display: flex; gap: 9px; align-items: center; padding: 5px 12px; border-bottom: 1px solid var(--border); background: #f5f7f9; }
.editor-toolbar button { font-size: 12px; background: transparent; border-color: transparent; }
.editor-toolbar button:hover { border-color: #c2ccda; background: #fff; }
.mode-marker { color: #155a94; font-size: 12px; font-weight: 600; }
.toolbar-separator { height: 22px; width: 1px; background: #d0d9e2; margin: 0 3px; }
.toolbar-check { display: flex; align-items: center; gap: 5px; font-size: 12px; cursor: pointer; }
.toolbar-end { margin-left: auto; display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); }
.toolbar-end svg { stroke: currentColor; stroke-width: 1.2; fill: none; }
.workbench { display: grid; grid-template-columns: 230px 7px minmax(0,1fr) 7px 330px; min-height: 0; }
.palette { display: flex; flex-direction: column; min-height: 0; overflow: auto; background: #fff; }
.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 0 10px; min-height: 33px; background: #f2f4f6; border-bottom: 1px solid #c7cdd4; font-size: 12px; }
.panel-title span { font-size: 11px; color: var(--muted); }
.panel-title button { font-size: 11px; border: 0; background: transparent; padding: 3px; color: #155a94; }
.palette > .panel-title { background: #fff; padding: 12px 14px; min-height: 49px; border-bottom: 1px solid #e3e8ef; font-size: 13px; }
.palette-content { padding: 12px; }
.compat-filter { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #526172; cursor: pointer; margin-bottom: 15px; }
.library-heading { color: #526172; font-size: 12px; margin-bottom: 10px; font-weight: 600; }
.palette-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }
.model-tile { display: grid; grid-template-rows: 39px 18px 15px; gap: 2px; padding: 7px 5px; place-items: center; min-width: 0; border: 1px solid #d6dee9; background: #fff; border-radius: 5px; }
.model-tile svg { width: 70px; height: 37px; color: var(--gas); }
.model-tile strong { font-size: 11px; line-height: 18px; }
.model-tile small { color: #66788c; font-size: 10px; line-height: 15px; }
.model-tile[aria-pressed=true] { border-color: #4f92c7; background: #eaf4fc; box-shadow: inset 0 0 0 1px #4f92c7; }
.model-tile:disabled { opacity: .4; background: #f5f6f7; }
.library-hint { font-size: 11px; color: var(--muted); margin-top: 12px; min-height: 33px; }
.section-heading { padding: 9px 13px; border-top: 1px solid #e3e8ef; border-bottom: 1px solid #e3e8ef; font-size: 12px; color: #475a6e; background: #f8fafc; }
.pipe-list { padding: 7px 0; }
.pipe-list button { display: grid; grid-template-columns: 8px 27px minmax(0,1fr); align-items: center; gap: 7px; width: 100%; border: 0; border-radius: 0; text-align: left; padding: 9px 14px; font-size: 12px; }
.pipe-list button[aria-pressed=true] { color: #155a94; background: #eaf3fa; }
.pipe-list .model-id { justify-self: end; font-size: 11px; color: #63788b; }
.state-dot { width: 6px; height: 6px; border-radius: 50%; background: #c3904f; }
.state-dot.assigned { background: #628594; }
.state-dot.ready { background: #398267; }
.palette-footer { margin-top: auto; padding: 15px 13px; color: #738295; font-size: 11px; border-top: 1px solid #e5ebf0; }
.pane-divider { background: linear-gradient(90deg,#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.horizontal-divider { height: 7px; background: linear-gradient(#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.center-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: 32px minmax(0,1fr) auto; }
.document-tab { display: flex; justify-content: space-between; align-items: center; background: #f2f5f8; border-bottom: 1px solid #cbd5df; font-size: 12px; }
.document-tab > span:first-child { background: #fff; height: 100%; padding: 7px 16px; border-right: 1px solid #cbd5df; }
.document-note { padding-right: 11px; color: #8290a0; font-size: 11px; }
.canvas { min-width: 0; min-height: 0; position: relative; overflow: hidden; background: #fff; }
#diagram { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; }
#diagram [data-background] { cursor: grab; }
#diagram.panning [data-background] { cursor: grabbing; }
.fixed-component { cursor: default; }
.fixed-component path, .fixed-component rect, .fixed-component circle { vector-effect: non-scaling-stroke; }
.component-label { font: 15px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #344759; }
.component-code { font: 11px "Segoe UI", sans-serif; fill: #8a97a4; }
.port-label { font: 11px "Segoe UI", sans-serif; fill: #8b134f; }
.wire { fill: none; stroke: #b66b92; stroke-width: 1.6; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire.assigned { stroke: #8b134f; stroke-dasharray: none; }
.wire.selected { stroke: #176eb0; stroke-width: 2.6; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; vector-effect: non-scaling-stroke; cursor: pointer; }
.pipe-name { font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #8b134f; paint-order: stroke; stroke: white; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.pipe-name.selected { fill: #155a94; }
.canvas-lock { position: absolute; top: 10px; right: 12px; font-size: 10px; color: #8a97a5; pointer-events: none; }
.viewport-controls { display: flex; align-items: center; position: absolute; left: 12px; bottom: 12px; border: 1px solid #cbd5e1; background: white; border-radius: 4px; box-shadow: 0 2px 5px #40526910; }
.viewport-controls button { padding: 2px 9px; border: 0; font-size: 18px; }
.viewport-controls span { font-size: 11px; color: #536b7f; min-width: 44px; text-align: center; }
.console { background: #fff; border-top: 1px solid #c7d1dc; min-height: 31px; }
.console-header { display: flex; gap: 10px; align-items: center; min-height: 31px; background: #f3f5f8; border-bottom: 1px solid #dfe5ec; padding-right: 11px; }
.console-header button { font-size: 12px; background: transparent; border: 0; text-align: left; border-radius: 0; }
.console-header span { margin-left: auto; color: #7f8d9b; font-size: 10px; }
.console-content { height: 120px; padding: 9px 12px; overflow: auto; font-size: 11px; color: #5e7286; }
.console.collapsed .console-content { display: none; }
.log-row { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; }
.log-kind { font-size: 10px; color: #6a8090; flex: 0 0 30px; }
.log-kind.warn { color: #956b28; }
.log-kind.ok { color: #388166; }
.log-row button { font-size: 11px; padding: 0; border: 0; color: #1764a0; background: transparent; text-align: left; }
.properties { display: grid; grid-template-rows: minmax(290px, 46%) 7px minmax(0,1fr); min-width: 0; min-height: 0; }
.properties-subpane { display: grid; grid-template-rows: 33px minmax(0,1fr); min-width: 0; min-height: 0; background: #fff; }
.properties-content { min-height: 0; overflow: auto; }
.property-table, .parameter-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
.property-table th, .property-table td, .parameter-table td, .parameter-table th { text-align: left; vertical-align: middle; padding: 7px 9px; border-bottom: 1px solid #e3e8ee; overflow-wrap: anywhere; }
.property-table th { width: 76px; font-weight: 400; color: #687c8e; background: #f8fafc; }
.preview { padding: 13px 11px 10px; }
.preview-title { display: flex; align-items: center; gap: 10px; }
.preview-title svg { width: 72px; height: 38px; color: var(--gas); }
.preview-title strong { font-size: 13px; }
.preview-title small { display: block; color: #7f8e9d; font-size: 11px; }
.preview-description { font-size: 11px; color: #62768a; margin: 7px 0 9px; }
.apply-buttons { display: flex; gap: 7px; flex-wrap: wrap; }
.apply-buttons button { font-size: 12px; }
.primary { background: var(--blue); border-color: var(--blue); color: white; }
.primary:hover { background: #155a94; }
.property-details { font-size: 11px; color: #6a7d8f; margin: 11px 0 0; }
summary { cursor: pointer; }
.property-details p { margin: 7px 0; }
.empty-hint { padding: 15px 12px; color: #7d8b99; font-size: 12px; }
.parameter-table th { background: #f2f4f6; color: #536576; font-size: 11px; font-weight: 400; }
.parameter-table th:nth-child(1) { width: 46%; }
.parameter-table th:nth-child(2) { width: 34%; }
.parameter-table th:nth-child(3) { width: 20%; }
.parameter-table td { padding: 3px 7px; height: 34px; }
.parameter-table td:first-child { color: #435b70; font-size: 11px; }
.parameter-table td:last-child { color: #7c8d9e; font-size: 10px; }
.parameter-table input { width: 100%; min-width: 0; height: 26px; color: #293f52; padding: 3px 5px; border: 1px solid #c9d4df; border-radius: 2px; font-size: 12px; background: white; }
.parameter-table input:read-only { background: #f7f9fb; color: #7b8a98; border-color: transparent; }
.parameter-table input[aria-invalid=true] { border-color: #c39450; }
.parameter-note { font-size: 11px; color: #7d8d9b; padding: 10px; }
.parameter-note.ready { color: #398267; }
.statusbar { display: flex; align-items: center; gap: 16px; padding: 0 12px; font-size: 11px; background: #f5f7f9; border-top: 1px solid #cbd5df; color: #647b8f; }
#notice { margin-right: auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#progress, #save-state { flex-shrink: 0; }
@media(max-width:1150px) { .workbench { grid-template-columns: 195px 7px minmax(0,1fr) 7px 280px; } .topbar { grid-template-columns: minmax(185px,1fr) auto minmax(180px,1fr); gap: 8px; } .view-tabs button { min-width: 56px; padding: 0 10px; } .topbar-actions { gap: 9px; } .palette-content { padding: 10px; } .model-tile svg { width: 60px; } }
@media(max-width:850px) { .app-shell { height: auto; min-height: 100dvh; grid-template-rows: auto auto 1fr auto; } .topbar { grid-template-columns: 1fr auto; padding: 10px; } .view-tabs { grid-row: 2; grid-column: 1 / -1; justify-content: center; margin-top: 6px; } .view-tabs button { flex: 1; } .editor-toolbar { flex-wrap: wrap; min-height: 42px; } .toolbar-end { margin-left: auto; } .workbench { grid-template-columns: 175px 5px minmax(0,1fr); grid-template-rows: 530px auto; } .workbench > .pane-divider:nth-of-type(2) { display: none; } .properties { grid-column: 1 / -1; grid-template-columns: 1fr 7px 1fr; grid-template-rows: 350px; border-top: 1px solid #cbd5df; } .horizontal-divider { height: 100%; background: #eef2f6; } .properties-subpane { height: 100%; } .palette { min-height: 0; } .document-note { display: none; } .console-header span { display: none; } .console-content { height: 95px; } .palette-content { padding: 8px; } .palette-footer { font-size: 10px; } .statusbar { min-height: 28px; flex-wrap: wrap; gap: 5px 10px; padding: 5px 10px; } #notice { flex: 1 1 100%; } }
@media(max-width:520px) { .topbar-actions { gap: 8px; font-size: 11px; } .topbar-actions button { padding: 4px 6px; } .product strong { font-size: 13px; } .product span { font-size: 10px; } .toolbar-check { margin-right: auto; } .toolbar-end { font-size: 10px; } .workbench { display: flex; flex-direction: column; } .workbench > .pane-divider { display: none; } .palette { display: grid; grid-template-columns: 1fr; overflow: visible; } .palette-content { padding: 10px; } .palette-grid { grid-template-columns: repeat(4,minmax(0,1fr)); gap: 5px; } .model-tile svg { width: 54px; } .model-tile small { font-size: 9px; } .palette-footer, .library-heading, .palette .section-heading { display: none; } .compat-filter { margin-bottom: 8px; } .library-hint { margin-top: 7px; min-height: 0; } .pipe-list { display: flex; padding: 0; } .pipe-list button { display: flex; gap: 5px; padding: 8px; } .pipe-list .model-id { display: none; } .center-pane { height: 450px; } .properties { display: flex; flex-direction: column; } .properties-subpane { min-height: 310px; max-height: none; overflow: visible; } .properties-content { overflow: visible; } .horizontal-divider { height: 7px; } }