mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-01 15:33:06 +00:00
- TipInfo 新增 tip_above_rack_length 可选字段 - 编辑器支持 tip_above 与 dz 互算,更新中文标签 - 侧视图绘制枪头露出部分并标注,俯视图/侧视图增加 dx/dy/dz 标注 - 预览增加回中按钮,详情页展示新字段 - 导入时自动计算 tip_above_rack_length - 批量更新 PRCXI 枪头物理尺寸及 registry YAML Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
9.5 KiB
JavaScript
293 lines
9.5 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,
|
||
tip_above_rack_length: g('f-tip_above'),
|
||
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);
|
||
|
||
// tip_above_rack_length 与 dz 互算
|
||
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
||
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
||
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
||
|
||
function _getVal(id) {
|
||
const el = document.getElementById(id);
|
||
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
||
}
|
||
function _setVal(id, v) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = Math.round(v * 1000) / 1000;
|
||
}
|
||
|
||
function autoCalcTipAbove(changedId) {
|
||
const typeEl = document.getElementById('f-type');
|
||
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
||
|
||
const tipLen = _getVal('f-tip_len');
|
||
const sizeZ = _getVal('f-size_z');
|
||
const dz = _getVal('f-grid_dz');
|
||
const above = _getVal('f-tip_above');
|
||
|
||
// 需要 tip_length 和 size_z 才能计算
|
||
if (tipLen == null || sizeZ == null) return;
|
||
|
||
if (changedId === 'f-tip_above') {
|
||
// 用户填了 tip_above → 算 dz
|
||
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
||
} else if (changedId === 'f-grid_dz') {
|
||
// 用户填了 dz → 算 tip_above
|
||
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
||
} else {
|
||
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
||
if (dz != null) {
|
||
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
||
} else if (above != null) {
|
||
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 绑定 input 事件
|
||
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
||
}
|
||
|
||
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
||
const typeEl = document.getElementById('f-type');
|
||
if (typeEl && typeEl.value === 'tip_rack') {
|
||
autoCalcTipAbove('f-grid_dz');
|
||
}
|
||
});
|
||
|
||
// 自动居中:根据板尺寸和孔阵列参数计算 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);
|
||
}
|
||
}
|