mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-01 19:57:41 +00:00
新增 labware_manager 模块: - Web UI 支持耗材 CRUD、SVG 俯视图/侧面图实时预览 - SVG 支持触控板双指缩放(pinch-to-zoom)和平移 - 网格排列自动居中按钮(autoCenter) - 表单参数标签中英文双语显示 - 从已有代码/YAML 导入、Python/YAML 代码生成 更新 CLAUDE.md:补充 labware manager、decorator 注册模式、CI 说明 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
238 lines
7.4 KiB
JavaScript
238 lines
7.4 KiB
JavaScript
/**
|
|
* form_handler.js — 动态表单逻辑 + 实时预览
|
|
*/
|
|
|
|
// 根据类型显示/隐藏对应的表单段
|
|
function onTypeChange() {
|
|
const type = document.getElementById('f-type').value;
|
|
const sections = {
|
|
grid: ['plate', 'tip_rack', 'tube_rack'],
|
|
well: ['plate'],
|
|
tip: ['tip_rack'],
|
|
tube: ['tube_rack'],
|
|
adapter: ['plate_adapter'],
|
|
};
|
|
|
|
for (const [sec, types] of Object.entries(sections)) {
|
|
const el = document.getElementById('section-' + sec);
|
|
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
|
}
|
|
|
|
// plate_type 行只对 plate 显示
|
|
const ptRow = document.getElementById('row-plate_type');
|
|
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
|
|
|
updatePreview();
|
|
}
|
|
|
|
// 从表单收集数据
|
|
function collectFormData() {
|
|
const g = id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return null;
|
|
if (el.type === 'checkbox') return el.checked;
|
|
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
|
return el.value || null;
|
|
};
|
|
|
|
const type = g('f-type');
|
|
|
|
const data = {
|
|
type: type,
|
|
function_name: g('f-function_name') || 'PRCXI_new',
|
|
model: g('f-model'),
|
|
docstring: g('f-docstring') || '',
|
|
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
|
size_x: g('f-size_x') || 127,
|
|
size_y: g('f-size_y') || 85,
|
|
size_z: g('f-size_z') || 20,
|
|
material_info: {
|
|
uuid: g('f-mi_uuid') || '',
|
|
Code: g('f-mi_code') || '',
|
|
Name: g('f-mi_name') || '',
|
|
materialEnum: g('f-mi_menum'),
|
|
SupplyType: g('f-mi_stype'),
|
|
},
|
|
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
|
registry_description: g('f-reg_desc') || '',
|
|
include_in_template_matching: g('f-in_tpl') || false,
|
|
template_kind: g('f-tpl_kind') || null,
|
|
grid: null,
|
|
well: null,
|
|
tip: null,
|
|
tube: null,
|
|
adapter: null,
|
|
volume_functions: null,
|
|
};
|
|
|
|
// Grid
|
|
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
|
data.grid = {
|
|
num_items_x: g('f-grid_nx') || 12,
|
|
num_items_y: g('f-grid_ny') || 8,
|
|
dx: g('f-grid_dx') || 0,
|
|
dy: g('f-grid_dy') || 0,
|
|
dz: g('f-grid_dz') || 0,
|
|
item_dx: g('f-grid_idx') || 9,
|
|
item_dy: g('f-grid_idy') || 9,
|
|
};
|
|
}
|
|
|
|
// Well
|
|
if (type === 'plate') {
|
|
data.well = {
|
|
size_x: g('f-well_sx') || 8,
|
|
size_y: g('f-well_sy') || 8,
|
|
size_z: g('f-well_sz') || 10,
|
|
max_volume: g('f-well_vol'),
|
|
material_z_thickness: g('f-well_mzt'),
|
|
bottom_type: g('f-well_bt') || 'FLAT',
|
|
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
|
};
|
|
if (g('f-has_vf')) {
|
|
data.volume_functions = {
|
|
type: 'rectangle',
|
|
well_length: data.well.size_x,
|
|
well_width: data.well.size_y,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Tip
|
|
if (type === 'tip_rack') {
|
|
data.tip = {
|
|
spot_size_x: g('f-tip_sx') || 7,
|
|
spot_size_y: g('f-tip_sy') || 7,
|
|
spot_size_z: g('f-tip_sz') || 0,
|
|
tip_volume: g('f-tip_vol') || 300,
|
|
tip_length: g('f-tip_len') || 60,
|
|
tip_fitting_depth: g('f-tip_dep') || 51,
|
|
has_filter: g('f-tip_filter') || false,
|
|
};
|
|
}
|
|
|
|
// Tube
|
|
if (type === 'tube_rack') {
|
|
data.tube = {
|
|
size_x: g('f-tube_sx') || 10.6,
|
|
size_y: g('f-tube_sy') || 10.6,
|
|
size_z: g('f-tube_sz') || 40,
|
|
max_volume: g('f-tube_vol') || 1500,
|
|
};
|
|
}
|
|
|
|
// Adapter
|
|
if (type === 'plate_adapter') {
|
|
data.adapter = {
|
|
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
|
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
|
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
|
dx: g('f-adp_dx'),
|
|
dy: g('f-adp_dy'),
|
|
dz: g('f-adp_dz') || 0,
|
|
};
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// 实时预览 (debounce)
|
|
let _previewTimer = null;
|
|
function updatePreview() {
|
|
if (_previewTimer) clearTimeout(_previewTimer);
|
|
_previewTimer = setTimeout(() => {
|
|
const data = collectFormData();
|
|
const topEl = document.getElementById('svg-topdown');
|
|
const sideEl = document.getElementById('svg-side');
|
|
if (topEl) renderTopDown(topEl, data);
|
|
if (sideEl) renderSideProfile(sideEl, data);
|
|
}, 200);
|
|
}
|
|
|
|
// 给所有表单元素绑定 input 事件
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const form = document.getElementById('labware-form');
|
|
if (!form) return;
|
|
form.addEventListener('input', updatePreview);
|
|
form.addEventListener('change', updatePreview);
|
|
});
|
|
|
|
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
|
function autoCenter() {
|
|
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
|
|
|
const sizeX = g('f-size_x') || 127;
|
|
const sizeY = g('f-size_y') || 85;
|
|
const nx = g('f-grid_nx') || 1;
|
|
const ny = g('f-grid_ny') || 1;
|
|
const itemDx = g('f-grid_idx') || 9;
|
|
const itemDy = g('f-grid_idy') || 9;
|
|
|
|
// 根据当前耗材类型确定子元素尺寸
|
|
const type = document.getElementById('f-type').value;
|
|
let childSx = 0, childSy = 0;
|
|
if (type === 'plate') {
|
|
childSx = g('f-well_sx') || 8;
|
|
childSy = g('f-well_sy') || 8;
|
|
} else if (type === 'tip_rack') {
|
|
childSx = g('f-tip_sx') || 7;
|
|
childSy = g('f-tip_sy') || 7;
|
|
} else if (type === 'tube_rack') {
|
|
childSx = g('f-tube_sx') || 10.6;
|
|
childSy = g('f-tube_sy') || 10.6;
|
|
}
|
|
|
|
// dx = (板宽 - 孔阵列总占宽) / 2
|
|
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
|
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
|
|
|
const elDx = document.getElementById('f-grid_dx');
|
|
const elDy = document.getElementById('f-grid_dy');
|
|
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
|
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
|
|
|
updatePreview();
|
|
}
|
|
|
|
// 保存
|
|
function showMsg(text, ok) {
|
|
const el = document.getElementById('status-msg');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
|
el.style.display = 'block';
|
|
setTimeout(() => el.style.display = 'none', 4000);
|
|
}
|
|
|
|
async function saveForm() {
|
|
const data = collectFormData();
|
|
|
|
let url, method;
|
|
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
|
url = '/api/labware';
|
|
method = 'POST';
|
|
} else {
|
|
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
|
method = 'PUT';
|
|
}
|
|
|
|
try {
|
|
const r = await fetch(url, {
|
|
method: method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
const d = await r.json();
|
|
if (d.status === 'ok') {
|
|
showMsg('保存成功', true);
|
|
if (IS_NEW) {
|
|
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
|
}
|
|
} else {
|
|
showMsg('保存失败: ' + JSON.stringify(d), false);
|
|
}
|
|
} catch (e) {
|
|
showMsg('请求错误: ' + e.message, false);
|
|
}
|
|
}
|