Files
OnebotCatalog/web/app.js
wangruiguo 3c4f236cba v2.0: 4 分类真实数据替换 + 手册彩色重做 + 网页版改版
数据 (定位销/勾销缸/翻转缸/电缸 → Univer 真实数模)
- 新增 19 系列 1124 型号 (PIN 303 / HOK 73 / ROT 741 / ELEC 9)
- 命名规则取自官方图册 CODIFICATION KEY (pdftotext 提取), 非推测
- 解析用「官方值域校验 + 左对齐 DFS 填空」, 可处理省略空位的型号
- 参数组合唯一性门禁: 1124 型号全部通过 (避免选型静默错配)
- 占位/测试文件 (如 USP50J_0___K0 销径为空) 在解析阶段丢弃
- catalog.json 分类系列表同步; crossref 失效引用清理

手册页
- 全部 42 系列改为彩色 (此前脚本误写死 csGRAY 转灰度), 346 页
- 修复 UCBT 附图只有 1 页 (筛选条件写死 UCBT40 且漏 CLAMPING ARMS)
- gen-pending-series.py 改为扫描 manual 目录自动生成附件清单,
  避免重跑脚本把多页附件覆盖回单页

网页版
- 首页改为系列缩略图网格 (40 张: 27 张真实产品图 + 13 张占位图),
  按大类分组; 图放 web/series/<系列码>.webp, 覆盖同名文件即可替换
- 左侧树改手风琴 (默认收起, 一次展开一个), 父级带 SVG 图标,
  悬停放大动画, 按钮间距随高度均匀分布; 可收起为图标窄条
- 修复分隔条被拖出容器后无法找回 (上限改为容器感知 + resize 重夹取
  + 双击复位); 同时阻止图片原生拖拽
- 购物车打包改取 .step.gz 副档 (传输量降至约 1/4.5), 无副档自动回退
- 修复搜索到型号后点结果内容区空白 (switchTab('dim') 已失效)
- OpcPackage 超大包 (>1.5GB) 改走临时文件, 绕过 MemoryStream 2GB 上限

工具
- gen-pending-series/manual, copy-pending-step, gen-step-gz,
  gen-series-thumbs, scan-pending-series 等 11 个脚本

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 01:05:41 +08:00

1750 lines
75 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 欧霓博在线目录 MVP (纯静态, 零依赖)
* 与离线版 (OnebotCatalog.exe) 消费同一份 catalog.json, 选型规则语义完全一致。
* 访问方式: 通过 Web 服务器 (tools\serve-web.ps1) 或上传到任意静态托管。
* 自测: URL 加 ?selftest=1, 页面底部显示断言结果。
*/
(function () {
'use strict';
var state = {
catalog: null,
lang: 'zh',
series: null, // 当前系列
sel: {}, // 已选参数 {code: value}
cart: [], // 购物车 (2026-08-24; 刷新页面才清空, 与选型状态解耦)
ranges: {}, // 数值参数范围 {code: [min|null, max|null]}
searchIndex: [], // [{series, variant, code, text}]
searchResult: [],
onHome: true, // 是否停留在首页缩略图 (false = 选型页)
treeCollapsed: false, // 左树侧边栏默认展开 (底部箭头按钮可收起/展开)
expandedCat: null // 左树当前展开的大类 (手风琴: 同时只展开一个)
};
// ---------- 工具 ----------
function $(id) { return document.getElementById(id); }
function str(v) { return v === null || v === undefined ? '' : String(v); }
function num(v) { return Number(str(v).replace(',', '.')); }
function t(zh, en) { return state.lang === 'en' ? en : zh; }
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function paramName(p) {
var n = t(p.nameZh, p.nameEn);
return p.unit ? n + ' (' + p.unit + ')' : n;
}
function displayValue(p, v) {
var key = str(v);
if (p.display && p.display[key] !== undefined) return p.display[key];
return key;
}
// ---------- 选型规则引擎 (与 C# Configurator 同语义) ----------
function condMatches(c, params) {
if (!c) return true;
var val = params[c.Param];
if (val === undefined || val === null) return false;
switch (c.Op) {
case 'eq': return str(val) === str(c.Value);
case 'ne': return str(val) !== str(c.Value);
case 'in':
if (!Array.isArray(c.Value)) return false;
for (var i = 0; i < c.Value.length; i++) if (str(c.Value[i]) === str(val)) return true;
return false;
case 'gt': return num(val) > num(c.Value);
case 'ge': return num(val) >= num(c.Value);
case 'lt': return num(val) < num(c.Value);
case 'le': return num(val) <= num(c.Value);
case 'between':
return Array.isArray(c.Value) && c.Value.length === 2 &&
num(val) >= num(c.Value[0]) && num(val) <= num(c.Value[1]);
default: return false;
}
}
function ruleViolated(v, rules) {
for (var i = 0; i < rules.length; i++) {
var r = rules[i];
if (condMatches(r.If, v.params) && !condMatches(r.Then, v.params)) return true;
}
return false;
}
// 部分选择下的规则违背判定 (参数化联动): Then 参数未选时视为"尚可满足" (与 C# RuleViolatedPartial 同语义)
function ruleViolatedPartial(params, rules) {
for (var i = 0; i < rules.length; i++) {
var r = rules[i];
if (condMatches(r.If, params) && (r.Then.Param in params) && !condMatches(r.Then, params)) return true;
}
return false;
}
function validatePartial(s, sel) {
for (var i = 0; i < s.parameters.length; i++) {
var p = s.parameters[i];
if (!(p.code in sel)) continue; // 未选参数不校验
if (!domainValid(p, sel[p.code])) return 'domain';
}
if (ruleViolatedPartial(sel, s.rules || [])) return 'rule';
return null;
}
function validVariants(series) {
var rules = series.rules || [];
return series.variants.filter(function (v) { return !ruleViolated(v, rules); });
}
function matches(v, sel) {
for (var k in sel) {
if (!(k in v.params) || str(v.params[k]) !== str(sel[k])) return false;
}
return true;
}
function allowedOptions(series, sel) {
var result = {};
for (var i = 0; i < series.parameters.length; i++) {
var p = series.parameters[i];
// 候选值: 枚举/数值参数取定义值; range 参数取 gridValues 快捷值
var cands = p.type === 'range' ? (p.gridValues || p.values || []) : (p.values || []);
var selForP = {};
for (var k in sel) { if (k !== p.code) selForP[k] = sel[k]; }
var allowed = [];
// 联动按"域+规则"判合法 (参数化引擎一期): 自由 range 值没有对应网格行,
// 若按网格行存在性过滤会把其他参数选项瞬间清空 — 与 C# 同语义
for (var j = 0; j < cands.length; j++) {
var combo = {};
for (var k in selForP) combo[k] = selForP[k];
combo[p.code] = cands[j];
if (validatePartial(series, combo) === null) allowed.push(cands[j]);
}
// range 参数: 保留当前已选的自由输入值 (域内合法) — 防止联动刷新时清掉自定义值
if (p.type === 'range' && sel[p.code] !== undefined &&
!allowed.some(function (v) { return str(v) === str(sel[p.code]); }) &&
domainValid(p, sel[p.code])) allowed.push(sel[p.code]);
result[p.code] = allowed;
}
return result;
}
function resolve(series, sel) {
if (Object.keys(sel).length < series.parameters.length) return null;
var valid = validVariants(series);
for (var i = 0; i < valid.length; i++) {
if (matches(valid[i], sel)) return valid[i];
}
return null;
}
// ---------- 参数化引擎 (一期 2026-08-25, 与 C# ParametricEngine 同语义; 同一份 parametric-vectors.json 对拍) ----------
function isParametric(s) { return !!s && s.mode === 'parametric'; }
function renderCode(template, p) {
if (!template) return null;
var out = '';
var i = 0;
while (i < template.length) {
if (template.charAt(i) === '{') {
var close = template.indexOf('}', i + 1);
if (close < 0) return null;
var token = template.substring(i + 1, close);
var parts = token.split(':');
var field = parts[0].trim();
if (!(field in p)) return null;
var sv = str(p[field]);
if (parts.length > 1) {
var padTok = parts[1].trim();
if (padTok.toLowerCase().indexOf('pad') === 0) padTok = padTok.substring(3);
var pad = parseInt(padTok, 10);
if (!(pad >= 1)) return null;
while (sv.length < pad) sv = '0' + sv;
}
out += sv;
i = close + 1;
} else { out += template.charAt(i); i++; }
}
return out;
}
function domainValid(p, v) {
if (v === undefined || v === null) return false;
if (p.type === 'range') {
if (v === '') return false;
var d = num(v);
if (isNaN(d)) return false;
if (p.min !== undefined && p.min !== null && d < p.min) return false;
if (p.max !== undefined && p.max !== null && d > p.max) return false;
var st = p.step || 1;
var base = p.min || 0;
if (st > 0 && Math.abs(d - base - Math.round((d - base) / st) * st) > 1e-6) return false;
return true;
}
// 枚举: 空串是合法值 (如 磁石=""=无磁石), 不得在此拦截 (与 C# DomainValid 同语义)
for (var i = 0; i < p.values.length; i++) if (str(p.values[i]) === str(v)) return true;
return false;
}
function validateCombo(s, sel) {
for (var i = 0; i < s.parameters.length; i++) {
var p = s.parameters[i];
if (!(p.code in sel)) return 'param';
if (!domainValid(p, sel[p.code])) return 'domain';
}
var fake = { modelCode: '', params: sel };
if (ruleViolated(fake, s.rules || [])) return 'rule';
return null;
}
function parametricGenerate(s, sel) {
var r = { params: sel, error: null, modelCode: null, isStandard: false, variant: null };
if (Object.keys(sel).length < s.parameters.length) { r.error = 'param'; return r; }
r.error = validateCombo(s, sel);
if (r.error) return r;
r.modelCode = renderCode(s.modelCodeTemplate, sel);
if (!r.modelCode) { r.error = 'template'; return r; }
var valid = validVariants(s);
for (var i = 0; i < valid.length; i++) {
if (valid[i].modelCode.toLowerCase() === r.modelCode.toLowerCase()) {
r.isStandard = true; r.variant = valid[i]; break;
}
}
return r;
}
function nearestStandard(s, sel) {
var valid = validVariants(s);
var best = null, bestDist = Infinity;
for (var i = 0; i < valid.length; i++) {
var v = valid[i];
var dist = 0;
for (var j = 0; j < s.parameters.length; j++) {
var p = s.parameters[j];
var a = sel[p.code], b = v.params[p.code];
if (a === undefined || b === undefined) continue;
if (p.type === 'range') dist += Math.abs(num(a) - num(b));
else if (str(a) !== str(b)) dist += 1e6;
}
if (best === null || dist < bestDist - 1e-9 ||
(Math.abs(dist - bestDist) < 1e-9 && v.modelCode < best.modelCode)) {
best = v; bestDist = dist;
}
}
return best;
}
// ---------- 加载 ----------
function load() {
fetch('catalog.json')
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (cat) {
state.catalog = cat;
if ($('ver')) $('ver').textContent = ''; // 版本标签不显示
buildSearchIndex();
renderTree();
renderHome(); // 首屏: 全部大类的系列缩略图铺在一页
renderCrossRef();
applyTreeCollapsed(state.treeCollapsed); // 左树默认折叠, 底部按钮可见
setStatus(t('目录加载完成,点击缩略图或左侧系列开始选型',
'Catalog loaded - click a thumbnail or pick a series'));
if (location.search.indexOf('selftest=1') >= 0) runSelfTest();
})
.catch(function (e) {
setStatus(t('加载 catalog.json 失败: ', 'Failed to load catalog.json: ') + e.message +
t(' (请通过 Web 服务器访问, 不能直接双击打开本页)', ' (access via a web server, not file://)'));
});
}
// ---------- 目录树 ----------
// 大类图标 (内联 SVG, 24x24 线性, stroke=currentColor → 随主题色/选中态自动变色)
var CAT_ICONS = {
AUT: '<rect x="2.5" y="7" width="12.5" height="10" rx="2"/><path d="M15 12h4.5"/><path d="M19.5 9.5v5"/><path d="M5 10.5v3"/>',
MIN: '<rect x="7" y="8.5" width="9" height="7" rx="1.5"/><path d="M16 12h3"/><path d="M5 10.5v3"/>',
ALU: '<rect x="4" y="8" width="11.5" height="8" rx="1.5"/><path d="M15.5 11h4"/><path d="M7.5 8v8M10.5 8v8"/>',
CLM: '<path d="M4 5v6.5A3.5 3.5 0 0 0 7.5 15H11"/><path d="M20 19v-6.5A3.5 3.5 0 0 0 16.5 9H13"/><path d="M11 15V9l3 3z"/><path d="M13 9V15l-3-3z"/>',
PIN: '<path d="M12 3v9"/><path d="M8 12h8l-4 8z"/><path d="M6.5 5.5h11"/>',
HOK: '<path d="M7 4v7.5a5 5 0 0 0 10 0V9"/><path d="M17 9l-2.2-2.2M17 9l2.2-2.2"/>',
GRP: '<path d="M6 4v5.5A3.5 3.5 0 0 0 9.5 13H12"/><path d="M18 4v5.5A3.5 3.5 0 0 1 14.5 13H12"/><path d="M9 16.5h6"/><path d="M12 13v8"/>',
ROT: '<path d="M20 12a8 7.5 0 1 1-2.4-5.4"/><path d="M20.5 3.5v4h-4"/>',
ELEC: '<rect x="3.5" y="8.5" width="10" height="7" rx="1.5"/><path d="M13.5 12h7"/><path d="M10 3.5L7 9h3.5l-2 5.5"/>',
};
function catIcon(code) {
var svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
(CAT_ICONS[code] || CAT_ICONS.AUT) + '</svg>';
var sp = document.createElement('span');
sp.className = 'cat-ico';
sp.innerHTML = svg; // 常量字符串, 无注入风险
return sp;
}
function renderTree() {
var tree = $('tree');
tree.innerHTML = '';
tree.classList.toggle('rail', !!state.treeCollapsed); // 收起态: 只显示图标
// 内层容器: 撑满侧栏高度并 space-evenly 均匀留白 (按钮本身不被拉伸)
var inner = document.createElement('div');
inner.className = 'tree-inner';
tree.appendChild(inner);
state.catalog.categories.forEach(function (cat) {
var isOpen = state.expandedCat === cat.code;
// 父级标题行: 图标 + 文字 (+ 展开箭头); 收起态只留图标
var catDiv = document.createElement('div');
catDiv.className = 'cat' + (isOpen ? ' open' : '');
catDiv.title = t(cat.nameZh, cat.nameEn);
catDiv.appendChild(catIcon(cat.code));
var tspan = document.createElement('span');
tspan.className = 'cat-name';
tspan.textContent = t(cat.nameZh, cat.nameEn);
catDiv.appendChild(tspan);
var arrow = document.createElement('span');
arrow.className = 'cat-arrow';
arrow.textContent = isOpen ? '▾' : '▸';
catDiv.appendChild(arrow);
// 点父级: 展开本类 + 自动收起其他 (手风琴), 右侧同步只显示该大类
catDiv.onclick = function () {
if (state.expandedCat === cat.code) {
state.expandedCat = null;
if (state.onHome) renderHome();
} else {
state.expandedCat = cat.code;
if (!state.onHome) showHome();
renderHome(cat.code);
}
renderTree(); // 图标窄条模式下只切右侧, 不改变宽度 (由底部按钮控制)
};
inner.appendChild(catDiv);
// 子级 (系列); 默认收起, 仅展开当前父级
var kids = document.createElement('div');
kids.className = 'cat-kids' + (isOpen ? '' : ' hidden');
cat.series.forEach(function (code) {
var s = state.catalog.series.filter(function (x) { return x.code === code; })[0];
if (!s) return;
var div = document.createElement('div');
div.className = 'series';
div.textContent = t(s.nameZh, s.nameEn);
if (state.series === s) div.classList.add('active');
div.onclick = function (ev) {
ev.stopPropagation();
var all = tree.querySelectorAll('.series');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
div.classList.add('active');
selectSeries(s);
};
kids.appendChild(div);
});
inner.appendChild(kids);
});
}
// ---------- 首页:系列缩略图网格 ----------
// 图放在 web/series/<系列码>.webp (真图覆盖同名文件即可, 无需改代码);
// 后补的图若是 png/jpg, 由 onerror 依次回退。
var THUMB_EXTS = ['webp', 'png', 'jpg', 'jpeg', 'svg'];
function thumbImg(code) {
var img = document.createElement('img');
img.alt = code;
img.loading = 'lazy';
img.decoding = 'async';
var idx = 0;
img.onerror = function () {
idx++;
// 依次尝试其他扩展名; 全失败则撤掉 img (留白色图位, 不显示裂图)
if (idx < THUMB_EXTS.length) img.src = 'series/' + code + '.' + THUMB_EXTS[idx];
else if (img.parentNode) img.parentNode.removeChild(img);
};
img.src = 'series/' + code + '.' + THUMB_EXTS[0];
return img;
}
// 渲染一张系列卡片 (首页所有大类共用)
function makeSeriesCard(code) {
var s = state.catalog.series.filter(function (x) { return x.code === code; })[0];
if (!s) return null;
var card = document.createElement('div');
card.className = 'sf-card' + (state.series === s ? ' on' : '');
var fig = document.createElement('div');
fig.className = 'sf-fig';
fig.appendChild(thumbImg(code));
card.appendChild(fig);
var codeEl = document.createElement('div');
codeEl.className = 'sf-code';
codeEl.textContent = code;
card.appendChild(codeEl);
var nameEl = document.createElement('div');
nameEl.className = 'sf-name';
nameEl.textContent = t(s.nameZh, s.nameEn);
nameEl.title = t(s.nameZh, s.nameEn);
card.appendChild(nameEl);
card.onclick = function () {
// selectSeries 不负责树高亮与视图切换 (既有契约), 由这里补齐
var all = $('tree').querySelectorAll('.series');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
var nm = t(s.nameZh, s.nameEn);
for (var j = 0; j < all.length; j++) {
if (all[j].textContent === nm) { all[j].classList.add('active'); break; }
}
selectSeries(s);
};
return card;
}
// 首页: 系列缩略图。catCode 为空 → 全部大类分组铺开; 指定大类 → 只显示该大类
function renderHome(catCode) {
if (!state.catalog) return;
var all = state.catalog.categories;
var cats = catCode
? all.filter(function (c) { return c.code === catCode; })
: all;
if (!cats.length) cats = all;
var nSeries = 0, nModels = 0;
cats.forEach(function (c) {
c.series.forEach(function (code) {
var s = state.catalog.series.filter(function (x) { return x.code === code; })[0];
if (!s) return;
nSeries++;
nModels += (s.variants || []).length;
});
});
if (catCode && cats.length) {
$('homeTitle').textContent = t(cats[0].nameZh, cats[0].nameEn);
$('homeCount').textContent = nSeries + t(' 系列 · ', ' series · ') +
nModels + t(' 型号', ' models');
} else {
$('homeTitle').textContent = t('全部产品', 'All Products');
$('homeCount').textContent = cats.length + t(' 大类 · ', ' categories · ') +
nSeries + t(' 系列 · ', ' series · ') + nModels + t(' 型号', ' models');
}
$('homeHint').textContent = t('点击缩略图进入选型', 'Click a thumbnail to configure');
var box = $('matrix');
box.innerHTML = '';
cats.forEach(function (cat) {
var cards = [];
cat.series.forEach(function (code) {
var c = makeSeriesCard(code);
if (c) cards.push(c);
});
if (!cards.length) return;
var sec = document.createElement('div');
sec.className = 'home-sec';
// 只显示单个大类时不再重复标题 (顶栏已有), 多类分组时保留分组标题
if (!catCode) {
var head = document.createElement('div');
head.className = 'home-sec-head';
var h3 = document.createElement('h3');
h3.textContent = t(cat.nameZh, cat.nameEn);
head.appendChild(h3);
var cnt = document.createElement('span');
cnt.className = 'n';
cnt.textContent = cards.length + t(' 个系列', ' series');
head.appendChild(cnt);
sec.appendChild(head);
}
var grid = document.createElement('div');
grid.className = 'sf-grid';
cards.forEach(function (c) { grid.appendChild(c); });
sec.appendChild(grid);
box.appendChild(sec);
});
}
// 首页 ↔ 选型页 切换。左树的显隐只由折叠状态决定 (首页也能展开浏览), 与视图无关。
function showHome() {
state.onHome = true;
$('home').classList.remove('hidden');
$('params').classList.add('hidden');
$('content').classList.add('hidden');
$('split2').classList.add('hidden');
applyTreeCollapsed(state.treeCollapsed);
}
function hideHome() {
state.onHome = false;
$('home').classList.add('hidden');
$('params').classList.remove('hidden');
$('content').classList.remove('hidden');
$('split2').classList.remove('hidden');
applyTreeCollapsed(state.treeCollapsed);
}
// 侧边栏折叠/展开 (默认折叠; 底部按钮切换)
// 侧边栏收起/展开。收起 = 窄条只留图标 (rail); 展开 = 图标 + 文字
function applyTreeCollapsed(collapsed) {
state.treeCollapsed = collapsed;
var tree = $('tree');
if (collapsed) {
tree.classList.add('rail'); // 不隐藏, 变成图标窄条 (点图标仍可切大类)
$('split1').classList.add('hidden');
} else {
tree.classList.remove('rail');
$('split1').classList.remove('hidden');
}
var b = $('btnTreeToggle');
if (!b) return;
b.classList.remove('hidden');
// 展开时显示左箭头 (点了往左收起); 收起时显示右箭头 (点了往右展开)
b.textContent = collapsed
? t('▶ 展开', '▶ Show')
: t('◀ 收起', '◀ Hide');
b.title = collapsed
? t('展开系列栏 (显示文字)', 'Expand series panel')
: t('收起为图标栏', 'Collapse to icon rail');
}
// ---------- 选型 ----------
function selectSeries(s) {
state.series = s;
state.sel = {};
state.ranges = {};
state.tableFilter = {}; // 换系列重置参数表列筛选
hideHome(); // 离开首页缩略图, 显示选型面板
var panel = $('paramPanel');
panel.innerHTML = '';
s.parameters.forEach(function (p) {
var row = document.createElement('div');
row.className = 'param-row';
var label = document.createElement('label');
label.textContent = paramName(p);
label.id = 'lbl-' + p.code;
row.appendChild(label);
if (p.type === 'range') {
// 参数化 range 域: 输入框 + 标准档位快捷选择 (与离线版可编辑下拉一致)
var inp = document.createElement('input');
inp.id = 'sel-' + p.code;
inp.className = 'param-input';
inp.setAttribute('list', 'dl-' + p.code);
inp.setAttribute('placeholder', t('输入或选标准档位', 'Type or pick standard'));
var dl = document.createElement('datalist');
dl.id = 'dl-' + p.code;
inp.onchange = function () {
var raw = inp.value.trim();
if (raw === '') { delete state.sel[p.code]; }
else {
var d = num(raw);
if (!isNaN(d) && domainValid(p, d)) state.sel[p.code] = d;
else {
setStatus(t('输入超出范围或与步长不符', 'Out of range or not aligned to step'));
delete state.sel[p.code];
}
}
refreshOptions();
};
row.appendChild(inp);
row.appendChild(dl);
panel.appendChild(row);
return;
}
var sel = document.createElement('select');
sel.id = 'sel-' + p.code;
sel.onchange = function () {
var raw = sel.value;
if (raw === '') { delete state.sel[p.code]; }
else {
var v = raw === '__EMPTY__' ? '' : (p.type === 'number' ? num(raw) : raw);
state.sel[p.code] = v;
}
refreshOptions();
};
row.appendChild(sel);
panel.appendChild(row);
});
$('modelTitle').textContent = t(s.nameZh, s.nameEn);
$('btnDatasheet').classList.add('hidden');
// 重置尺寸图区: 选型完成前只显示提示文字, 不显示占位图
var dimImg = $('dimImage');
dimImg.src = '';
dimImg.classList.add('hidden');
$('dimHint').classList.remove('hidden');
$('dimHint').textContent = t('选型完成后显示尺寸图', 'Dimension drawing appears after selection');
renderRangeRow();
renderManualPages(s);
refreshOptions();
renderTable();
}
// 手册页 (系列级附件 kind=manual, 尺寸图窗口滚动对照, 选系列即显示; 标题只显示序号不显示路径)
function renderManualPages(s) {
var mw = $('manualWrap');
mw.innerHTML = '';
var pages = (s.attachments || []).filter(function (a) { return a.kind === 'manual'; });
pages.forEach(function (a, idx) {
var cap = document.createElement('p');
cap.className = 'manual-cap';
cap.textContent = t('手册页', 'Manual page') + ' ' + (idx + 1) + '/' + pages.length;
var im = document.createElement('img');
im.className = 'manual-img';
im.src = a.path;
im.alt = a.path;
im.draggable = false; // 阻止原生图片拖拽 (见下方全局 dragstart 拦截)
mw.appendChild(cap);
mw.appendChild(im);
});
}
// 空字符串是合法枚举值 (如 磁石=""=无磁石), 用哨兵值避免与"未选择"混淆
function optValue(v) { return str(v) === '' ? '__EMPTY__' : str(v); }
function refreshOptions() {
var s = state.series;
if (!s) return;
var allowed = allowedOptions(s, state.sel);
s.parameters.forEach(function (p) {
var sel = $('sel-' + p.code);
var vals = allowed[p.code] || [];
if (p.type === 'range') {
// 输入框: 更新 datalist 标准档位 (标注 标准); 已选值回显
var dl = $('dl-' + p.code);
dl.innerHTML = '';
vals.forEach(function (v) {
var opt = document.createElement('option');
opt.value = str(v);
var isGrid = (p.gridValues || []).some(function (g) { return str(g) === str(v); });
opt.label = isGrid ? str(v) + ' (' + t('标准', 'Std') + ')' : str(v);
dl.appendChild(opt);
});
sel.value = state.sel[p.code] !== undefined ? str(state.sel[p.code]) : '';
return;
}
// 已选值变得不可选时清除
if (state.sel[p.code] !== undefined &&
!vals.some(function (v) { return str(v) === str(state.sel[p.code]); })) {
delete state.sel[p.code];
}
sel.innerHTML = '<option value="">' + t('请选择', 'Select') + '</option>';
vals.forEach(function (v) {
var opt = document.createElement('option');
opt.value = optValue(v);
opt.textContent = displayValue(p, v);
if (state.sel[p.code] !== undefined && str(v) === str(state.sel[p.code])) opt.selected = true;
sel.appendChild(opt);
});
});
updateSelection();
if (crossFill) crossFill(); // 平替对照随选型刷新 (只显示当前型号的平替)
}
function updateSelection() {
var s = state.series;
var v = resolve(s, state.sel);
var pres = null;
if (!v && isParametric(s) && Object.keys(state.sel).length === s.parameters.length) {
pres = parametricGenerate(s, state.sel);
if (pres && pres.error) pres = null; // 非法组合按"无匹配"处理
}
curVariant = v; // 购物车"加入清单"用 (2026-08-24)
curParam = pres; // 参数化非标结果 (2026-08-25)
var dl = $('btnDownload');
var ds = $('btnDatasheet');
if (!v && !pres) {
$('modelTitle').textContent = t(s.nameZh, s.nameEn) + ' — ' +
t('已选 ' + Object.keys(state.sel).length + '/' + s.parameters.length + ' 项', Object.keys(state.sel).length + '/' + s.parameters.length + ' selected');
dl.classList.add('hidden');
ds.classList.add('hidden');
$('btnGenFmt').classList.add('hidden');
$('btnAddCart').classList.add('hidden');
$('dimImage').classList.add('hidden');
$('dimHint').classList.remove('hidden');
$('dimHint').textContent = t('选型完成后显示尺寸图', 'Dimension drawing appears after selection');
$('viewer3dHint').classList.remove('hidden');
$('viewer3dHint').textContent = t('选型完成后显示 3D 外形 (拖拽旋转 / 滚轮缩放)', '3D preview appears after selection');
var frame0 = $('viewerFrame');
frame0.src = 'about:blank';
frame0.classList.add('hidden');
return;
}
// 非标档位 (参数化引擎): 徽标 + 下载置灰 + 3D 降级最近标准档位 + 提示条 (与离线版一致)
if (pres) {
$('modelTitle').innerHTML = t('已选型号: ', 'Model: ') + esc(pres.modelCode) +
'<span class="nonstd-badge">' + t('非标档位(无标准数模)', 'Non-standard (no standard model)') + '</span>';
dl.classList.remove('hidden');
dl.removeAttribute('href');
var fmtSel = $('btnGenFmt');
if (genApi()) {
// 按需生成服务可用 (二期): 按钮升级为【在线生成数模】+ 格式选择 (STEP/Parasolid/IGES/STL/全格式)
dl.disabled = false;
dl.title = '';
dl.setAttribute('data-gen', '1');
dl.textContent = t('在线生成数模', 'Generate 3D model online');
dl.onclick = function (ev) { ev.preventDefault(); genSingle(pres); };
fmtSel.classList.remove('hidden');
} else {
fmtSel.classList.add('hidden');
// 一期过渡态: 置灰 + 提示 (与离线版一致)
dl.disabled = true;
dl.removeAttribute('data-gen');
dl.textContent = t('下载 STEP', 'Download STEP');
dl.title = t('非标档位暂不支持数模下载,请联系销售或选择标准档位',
'STEP download unavailable for non-standard config. Contact sales or choose a standard config.');
dl.onclick = null;
}
$('btnAddCart').classList.remove('hidden');
// 数据表 PDF 照常可下
var dsAtt2 = (s.attachments || []).filter(function (a) {
return a.kind === 'datasheet' && a.lang === state.lang;
})[0] || (s.attachments || []).filter(function (a) { return a.kind === 'datasheet'; })[0];
if (dsAtt2) {
ds.href = dsAtt2.path;
ds.setAttribute('download', dsAtt2.path.split('/').pop() || 'datasheet.pdf');
ds.classList.remove('hidden');
} else ds.classList.add('hidden');
// 非标无尺寸图
$('dimImage').classList.add('hidden');
$('dimHint').classList.remove('hidden');
$('dimHint').textContent = t('非标档位无标准尺寸图', 'No dimension drawing for non-standard config');
var near = nearestStandard(s, state.sel);
if (near) load3D(near);
$('viewer3dHint').classList.remove('hidden');
$('viewer3dHint').textContent = t(
'当前为非标档位,展示最近标准档位 ' + (near ? near.modelCode : '?') + ' 的近似几何',
'Non-standard config — showing nearest standard model ' + (near ? near.modelCode : '?') + ' as approximation.');
setStatus(t('非标档位: 无对应标准数模; STEP 下载需联系销售或选择标准档位',
'Non-standard config: contact sales or choose a standard config for STEP'));
return;
}
$('modelTitle').textContent = t('已选型号: ', 'Model: ') + v.modelCode;
dl.disabled = false;
dl.title = '';
dl.textContent = t('下载 STEP', 'Download STEP');
dl.onclick = null; // 清除非标分支的在线生成处理器
dl.removeAttribute('data-gen');
$('btnGenFmt').classList.add('hidden'); // 标准档位无多格式 (升级项: 维护端批量出多格式)
dl.href = v.step;
dl.setAttribute('download', v.modelCode + '.step');
dl.classList.remove('hidden');
dl.dataset.model = v.modelCode;
$('btnAddCart').classList.remove('hidden');
setStatus(t('选型完成,点击下载 STEP', 'Ready - click to download STEP'));
// 尺寸图 (优先匹配型号)
var att = null;
(s.attachments || []).forEach(function (a) {
if (a.kind === 'dimDrawing' && (a.model === v.modelCode || att === null)) att = a;
});
if (att) {
$('dimImage').src = att.path;
$('dimImage').classList.remove('hidden');
$('dimHint').classList.add('hidden');
}
// 数据表
var dsAtt = (s.attachments || []).filter(function (a) {
return a.kind === 'datasheet' && a.lang === state.lang;
})[0] || (s.attachments || []).filter(function (a) { return a.kind === 'datasheet'; })[0];
if (dsAtt) {
// 下载行为 (2026-08-24): 点按钮直接下载 PDF, 与 STEP 下载一致
ds.href = dsAtt.path;
ds.setAttribute('download', dsAtt.path.split('/').pop() || 'datasheet.pdf');
ds.classList.remove('hidden');
}
// 3D 预览 (零依赖 Canvas 渲染)
load3D(v);
}
// ---------- 参数表 (含范围筛选) ----------
function renderRangeRow() {
var row = $('rangeRow');
row.innerHTML = '';
var s = state.series;
if (!s) return;
s.parameters.forEach(function (p) {
if (p.type !== 'number' && p.type !== 'range') return;
var lbl = document.createElement('span');
lbl.textContent = paramName(p);
var min = document.createElement('input');
min.placeholder = t('最小', 'min');
var max = document.createElement('input');
max.placeholder = t('最大', 'max');
var apply = document.createElement('button');
apply.textContent = t('应用', 'Apply');
apply.onclick = function () {
var lo = min.value === '' ? null : num(min.value);
var hi = max.value === '' ? null : num(max.value);
if (lo !== null || hi !== null) state.ranges[p.code] = [lo, hi];
else delete state.ranges[p.code];
renderTable();
};
row.appendChild(lbl); row.appendChild(min); row.appendChild(max); row.appendChild(apply);
});
}
function inRange(v) {
var s = state.series;
for (var code in state.ranges) {
var p = s.parameters.filter(function (x) { return x.code === code; })[0];
if (!p || p.type !== 'number') continue;
var val = num(v.params[code]);
var r = state.ranges[code];
if (r[0] !== null && val < r[0]) return false;
if (r[1] !== null && val > r[1]) return false;
}
return true;
}
// 2026-08-24: STEP 列去掉 (无意义); 每列表头可点击筛选取值, 与桌面版一致
var colFilterPop = null;
function renderTable() {
var s = state.series;
var thead = $('variantTable').querySelector('thead');
var tbody = $('variantTable').querySelector('tbody');
thead.innerHTML = ''; tbody.innerHTML = '';
closeColFilter();
if (!s) return;
var f = state.tableFilter || {};
var hr = document.createElement('tr');
hr.appendChild(th(t('型号 Model', 'Model')));
s.parameters.forEach(function (p) {
var h = th(paramName(p));
var vals = distinctColValues(s, p);
if (vals.length > 1) {
h.classList.add('col-filter');
h.title = t('点击筛选此列', 'Click to filter');
if (f[p.code] !== undefined) h.textContent = paramName(p) + ' ▾ ' + displayValue(p, f[p.code]);
(function (p2, h2, vals2) {
h2.onclick = function (ev) { showColFilter(h2, p2, vals2, ev); };
})(p, h, vals);
}
hr.appendChild(h);
});
thead.appendChild(hr);
validVariants(s).filter(inRange).filter(function (v) {
for (var k in f) { if (str(v.params[k]) !== str(f[k])) return false; }
return true;
}).forEach(function (v) {
var tr = document.createElement('tr');
tr.appendChild(td(v.modelCode));
s.parameters.forEach(function (p) {
tr.appendChild(td(v.params[p.code] !== undefined ? displayValue(p, v.params[p.code]) : ''));
});
// 行尾"+"加入清单 (2026-08-24, 不触发行点击)
var addTd = document.createElement('td');
var addBtn = document.createElement('button');
addBtn.className = 'add-cart-btn';
addBtn.textContent = '+';
addBtn.title = t('加入清单', 'Add to Cart');
addBtn.onclick = function (ev) { ev.stopPropagation(); addToCart(v); };
addTd.appendChild(addBtn);
tr.appendChild(addTd);
tr.onclick = function () {
state.sel = {};
s.parameters.forEach(function (p) { state.sel[p.code] = v.params[p.code]; });
refreshOptions();
switchTab('3d'); // 3D/尺寸图同页 (2026-08-24 合并后)
};
tbody.appendChild(tr);
});
setStatus(t('参数表: ' + tbody.children.length + ' 个型号', 'Table: ' + tbody.children.length + ' models'));
}
function distinctColValues(s, p) {
var out = [];
validVariants(s).forEach(function (v) {
var sv = str(v.params[p.code]);
if (out.indexOf(sv) < 0) out.push(sv);
});
return out;
}
function showColFilter(header, p, vals, ev) {
closeColFilter();
var pop = document.createElement('div');
pop.className = 'col-filter-pop';
var all = document.createElement('div');
all.className = 'col-filter-item';
all.textContent = t('全部 (清除筛选)', 'All (clear)');
all.onclick = function () { delete state.tableFilter[p.code]; closeColFilter(); renderTable(); };
pop.appendChild(all);
vals.forEach(function (sv) {
var it = document.createElement('div');
it.className = 'col-filter-item';
if ((state.tableFilter || {})[p.code] === sv) it.classList.add('sel');
it.textContent = sv === '' ? t('(空值)', '(empty)') : displayValue(p, sv);
it.onclick = function () {
state.tableFilter = state.tableFilter || {};
state.tableFilter[p.code] = sv;
closeColFilter();
renderTable();
};
pop.appendChild(it);
});
var r = header.getBoundingClientRect();
pop.style.left = r.left + 'px';
pop.style.top = (r.bottom + 2) + 'px';
document.body.appendChild(pop);
colFilterPop = pop;
ev.stopPropagation();
}
function closeColFilter() {
if (colFilterPop && colFilterPop.parentNode) colFilterPop.parentNode.removeChild(colFilterPop);
colFilterPop = null;
}
document.addEventListener('click', closeColFilter);
function th(txt) { var el = document.createElement('th'); el.textContent = txt; return el; }
function td(txt) { var el = document.createElement('td'); el.textContent = txt; return el; }
// ---------- 搜索 ----------
function buildSearchIndex() {
state.searchIndex = [];
state.catalog.series.forEach(function (s) {
(s.variants || []).forEach(function (v) {
var parts = [t(s.nameZh, s.nameEn), s.code, s.keywords || ''];
s.parameters.forEach(function (p) {
parts.push(t(p.nameZh, p.nameEn));
parts.push(p.keywords || '');
var dv = displayValue(p, v.params[p.code]);
parts.push(dv);
});
state.searchIndex.push({
series: s, variant: v, code: v.modelCode,
text: parts.join(' ').toLowerCase() + ' ' + v.modelCode.toLowerCase()
});
});
});
}
function doSearch() {
var q = $('searchBox').value.trim().toLowerCase();
if (!q) return;
var tokens = q.split(/[\s,,;;]+/).filter(function (x) { return x.length > 0; });
var results = [];
state.searchIndex.forEach(function (it) {
var code = it.code.toLowerCase();
var allIn = tokens.every(function (tk) { return code.indexOf(tk) >= 0 || it.text.indexOf(tk) >= 0; });
if (!allIn) return;
var score = 0;
if (tokens.some(function (tk) { return code === tk; })) score = 1000;
else if (tokens.every(function (tk) { return code.indexOf(tk) >= 0; })) score = 800;
else score = 500;
it.score = score;
results.push(it);
});
// 平替模糊搜索 (2026-08-24): 竞品型号包含关键词 → 对应我方型号也列入结果 (精确命中给高分)
(state.catalog.crossRefs || []).forEach(function (cr) {
var fm = (cr.foreignModel || '').toLowerCase();
var anyIn = tokens.some(function (tk) { return fm.indexOf(tk) >= 0; });
if (!anyIn) return;
if (results.some(function (r) { return r.code.toLowerCase() === (cr.ourModel || '').toLowerCase(); })) return;
var hit = null, hs = null;
state.catalog.series.forEach(function (s) {
(s.variants || []).forEach(function (v) {
if (v.modelCode.toLowerCase() === (cr.ourModel || '').toLowerCase()) { hit = v; hs = s; }
});
});
if (!hit) return;
results.push({
series: hs, variant: hit, code: hit.modelCode,
score: fm === q ? 950 : 750,
altNote: cr.brand + ' ' + cr.foreignModel
});
});
results.sort(function (a, b) { return b.score - a.score || (a.code < b.code ? -1 : 1); });
state.searchResult = results;
var tbody = $('searchTable').querySelector('tbody');
tbody.innerHTML = '';
results.forEach(function (it) {
var tr = document.createElement('tr');
tr.appendChild(td(it.code));
tr.appendChild(td(t(it.series.nameZh, it.series.nameEn) + (it.altNote ? (' ← ' + t('平替 ', 'Alt ') + it.altNote) : '')));
tr.appendChild(td(it.variant.step));
tr.onclick = function () {
selectSeries(it.series);
state.sel = {};
it.series.parameters.forEach(function (p) { state.sel[p.code] = it.variant.params[p.code]; });
refreshOptions();
switchTab('3d'); // 尺寸图已并入 3D 页 (原为 'dim', 会导致四个面板全被隐藏)
};
tbody.appendChild(tr);
});
switchTab('search');
setStatus(t('搜索 "' + q + '": ' + results.length + ' 条结果', 'Search "' + q + '": ' + results.length + ' result(s)'));
}
// ---------- 标签页 / 语言 / 状态 ----------
// 2026-08-24: 3D 与尺寸图合并为同一页 (tab-3d 内含左右分栏+可拖拽分隔条), 不再有独立尺寸图标签
function switchTab(name) {
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab') === name);
['3d', 'table', 'search', 'cross'].forEach(function (n) {
$('tab-' + n).classList.toggle('hidden', n !== name);
});
}
// ---------- 平替对照 (竞品型号 → 我方型号, 源 catalog.crossRefs) ----------
// 2026-08-24: 只显示与当前已选型号匹配的平替行 (未选型号则显示全部), 与桌面版一致
var crossFill = null;
function renderCrossRef() {
var sel = $('crossBrand');
var rows = state.catalog.crossRefs || [];
var brands = [];
rows.forEach(function (r) { if (brands.indexOf(r.brand) < 0) brands.push(r.brand); });
brands.sort();
sel.innerHTML = '';
var opt0 = document.createElement('option');
opt0.value = ''; opt0.textContent = t('全部品牌', 'All brands');
sel.appendChild(opt0);
brands.forEach(function (b) {
var o = document.createElement('option');
o.value = b; o.textContent = b;
sel.appendChild(o);
});
sel.onchange = crossFill = fillCrossRef;
crossFill();
function fillCrossRef() {
var b = sel.value;
var cur = null;
try { var sv = resolve(state.series, state.sel); cur = sv ? sv.modelCode : null; } catch (e) {}
var tb = $('crossTable').querySelector('tbody');
tb.innerHTML = '';
rows.forEach(function (r) {
if (b && r.brand !== b) return;
if (cur && r.ourModel.toLowerCase() !== cur.toLowerCase()) return;
var tr = document.createElement('tr');
tr.innerHTML = '<td>' + esc(r.brand) + '</td><td>' + esc(r.foreignModel) + '</td>' +
'<td><a href="#">' + esc(r.ourModel) + '</a></td><td>' + esc(r.note || '') + '</td>';
tr.onclick = function () { jumpToModel(r.ourModel); };
tb.appendChild(tr);
});
}
}
function jumpToModel(modelCode) {
var found = null, fs = null;
state.catalog.series.forEach(function (s) {
(s.variants || []).forEach(function (v) {
if (v.modelCode.toLowerCase() === modelCode.toLowerCase()) { found = v; fs = s; }
});
});
if (!found) { setStatus(t('平替型号未找到: ', 'Cross-ref model not found: ') + modelCode); return; }
selectSeries(fs);
state.sel = {};
for (var k in found.params) state.sel[k] = found.params[k];
refreshOptions();
switchTab('3d');
var all = document.querySelectorAll('#tree .series');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
var node = Array.prototype.filter.call(all, function (el) { return el.textContent === t(fs.nameZh, fs.nameEn); })[0];
if (node) node.classList.add('active');
}
// ---------- 购物车 (淘宝式清单批量导出, 2026-08-24; 与桌面版语义/文案一致) ----------
var curVariant = null; // 当前已解析型号 (加入清单用, 由 updateSelection 维护)
var curParam = null; // 当前参数化非标结果 (2026-08-25, 与 curVariant 互斥)
function paramsSummary(v) {
var s = state.series;
if (!s) return '';
var parts = [];
s.parameters.forEach(function (p) {
if (v.params[p.code] === undefined) return;
var sv = str(v.params[p.code]);
var label = (p.display && p.display[sv] !== undefined) ? p.display[sv] : sv;
parts.push(t(p.nameZh, p.nameEn) + '=' + (label === '' ? t('(无)', '(none)') : label));
});
return parts.join('; ');
}
function addToCart(v) {
if (!v) return;
var dup = state.cart.some(function (it) { return it.modelCode.toLowerCase() === v.modelCode.toLowerCase(); });
if (dup) { setStatus(t('该型号已在清单中', 'Already in cart')); return; }
// 非标档位 (无 step): 打包时跳过并在清单中注明 (与离线版一致)
var nonStdPrefix = v.step ? '' : t('【非标档位(打包时无 STEP)】 ', '[Non-standard (no STEP in package)] ');
state.cart.push({
modelCode: v.modelCode,
seriesCode: state.series ? state.series.code : '',
seriesName: state.series ? t(state.series.nameZh, state.series.nameEn) : '',
stepPath: v.step || null,
params: v.params || {}, // 非标批量生成提交用 (二期)
paramsSummary: nonStdPrefix + paramsSummary(v),
qty: 1
});
renderCartBadge();
setStatus(t('已加入清单 (共 ' + state.cart.length + ' 项)', 'Added to cart (' + state.cart.length + ' items)'));
}
function renderCartBadge() {
$('btnCart').textContent = t('清单 (' + state.cart.length + ')', 'Cart (' + state.cart.length + ')');
}
function openCart() {
$('cartOverlay').classList.remove('hidden');
$('cartPanel').classList.remove('hidden');
renderCart();
}
function closeCart() {
$('cartOverlay').classList.add('hidden');
$('cartPanel').classList.add('hidden');
}
function renderCart() {
var list = $('cartList');
list.innerHTML = '';
if (state.cart.length === 0) {
var empty = document.createElement('div');
empty.className = 'hint';
empty.textContent = t('清单为空, 请先加入型号', 'Cart is empty');
list.appendChild(empty);
}
state.cart.forEach(function (it, idx) {
var div = document.createElement('div');
div.className = 'cart-item';
div.innerHTML = '<div class="ci-model">' + esc(it.modelCode) + '</div>' +
'<div class="ci-sub">' + esc(it.seriesName) + ' · ' + esc(it.paramsSummary) + '</div>';
var ops = document.createElement('div');
ops.className = 'ci-ops';
var lbl = document.createElement('span');
lbl.textContent = t('数量', 'Qty');
var qty = document.createElement('input');
qty.type = 'number'; qty.min = '1'; qty.value = it.qty;
qty.onchange = function () { it.qty = Math.max(1, parseInt(qty.value, 10) || 1); qty.value = it.qty; };
var del = document.createElement('button');
del.className = 'ci-del';
del.textContent = t('删除', 'Remove');
del.onclick = function () { state.cart.splice(idx, 1); renderCart(); };
ops.appendChild(lbl); ops.appendChild(qty); ops.appendChild(del);
div.appendChild(ops);
list.appendChild(div);
});
renderCartBadge();
}
function csvEscape(c) {
c = c == null ? '' : String(c);
if (/[",\n\r]/.test(c)) c = '"' + c.replace(/"/g, '""') + '"';
return c;
}
function timestampName() {
var d = new Date();
function p2(x) { return (x < 10 ? '0' : '') + x; }
return '' + d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate()) + p2(d.getHours()) + p2(d.getMinutes()) + p2(d.getSeconds());
}
// ---------- NX 按需生成服务 (二期 A 版, 2026-08-25; 与离线版语义/文案一致) ----------
function genApi() {
return (window.GEN_CONFIG && window.GEN_CONFIG.api) ? window.GEN_CONFIG.api.replace(/\/+$/, '') : '';
}
// 数模取用: 优先取 .step.gz 压缩副档 (发布时预生成), 浏览器端解压 —
// 购物车打包从"逐个下载原始大文件"变为约 1/4.5 传输量。
// 无 gz 副档时回退原始文件, 老部署目录不受影响。
// 浏览器不支持 DecompressionStream (旧 Edge/Safari) 时同样回退原始文件。
function canGunzip() {
return typeof DecompressionStream === 'function';
}
async function fetchStepAsset(stepPath) {
if (canGunzip()) {
try {
var gzResp = await fetch(stepPath + '.gz');
if (gzResp.ok && gzResp.body) {
// 防"gz 请求被 SPA 回退成 index.html": 校验 gzip 魔数 0x1f 0x8b
var head = new Uint8Array(await gzResp.clone().arrayBuffer());
if (head.length > 2 && head[0] === 0x1f && head[1] === 0x8b) {
var ds = new DecompressionStream('gzip');
var stream = gzResp.body.pipeThrough(ds);
return await new Response(stream).blob();
}
}
} catch (e) { /* 回退原始文件 */ }
}
var resp = await fetch(stepPath);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.blob();
}
function triggerDownload(url, name) {
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
}
// 下载地址/文件名按所选格式 (btnGenFmt: zip/step/parasolid/iges/stl; 生成输出留存全格式, 格式只影响下载)
function genFmt() {
var sel = $('btnGenFmt');
return sel ? sel.value : 'zip';
}
function downloadUrl(api, code) {
var fmt = genFmt();
var url = api + '/download?series=' + encodeURIComponent(state.series.code) + '&code=' + encodeURIComponent(code);
if (fmt !== 'zip') url += '&fmt=' + encodeURIComponent(fmt);
return url;
}
function downloadName(code) {
var exts = { zip: '.zip', step: '.stp', iges: '.igs' };
return code + (exts[genFmt()] || '.zip');
}
// 单型号在线生成: 提交 → 轮询 → 网页内 3D 预览 → 选格式 → 下载 (检索先行命中直接进入已生成态)
async function genSingle(pres) {
var api = genApi();
if (!api || !pres) return;
var dl = $('btnDownload');
dl.disabled = true;
dl.textContent = t('正在生成数模,预计 10–60 秒…', 'Generating — typically 10–60 s…');
try {
var resp = await fetch(api + '/gen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series: state.series.code, code: pres.modelCode, params: pres.params })
});
if (!resp.ok) {
var ej = null;
try { ej = await resp.json(); } catch (e) { }
throw new Error(ej && ej.error === 'queue_busy' ? t('生成队列繁忙,请稍后再试', 'Queue busy — try again later')
: ej && ej.error === 'too_many_requests' ? t('请求过于频繁,请稍后再试', 'Too many requests — try again later')
: t('请求被拒绝', 'Request rejected'));
}
var r = await resp.json();
if (r.immediate) { finishGen(api, pres, dl); return; }
var done = false, last = '';
for (var i = 0; i < 150 && !done; i++) {
await new Promise(function (res) { setTimeout(res, 3000); });
var sr = await fetch(api + '/status?job=' + encodeURIComponent(r.jobId));
if (!sr.ok) continue;
var st = await sr.json();
if (st.state === 'queued') { last = t('生成队列繁忙,前面还有任务', 'Queue busy'); }
else if (st.state === 'running') { last = t('正在生成数模,预计 10–60 秒…', 'Generating — typically 10–60 s…'); }
else if (st.state === 'done' && st.ok) { done = true; break; }
else { throw new Error(t('生成失败,请重试或联系销售', 'Generation failed — retry or contact sales')); }
dl.textContent = last;
}
if (!done) throw new Error(t('生成超时,请重试', 'Generation timed out — retry'));
finishGen(api, pres, dl);
} catch (e) {
setStatus(t('生成失败: ', 'Generation failed: ') + e.message);
dl.disabled = false;
dl.textContent = t('在线生成数模', 'Generate 3D model online');
}
}
// 生成完成: 网页内 3D 预览 + 按钮变"已生成",用户选格式后点下载才真正下载
function finishGen(api, pres, dl) {
previewGenerated(api, pres);
dl.disabled = false;
dl.setAttribute('data-gen', '1');
dl.textContent = t('已生成,下载数模', 'Generated — download');
dl.onclick = function (ev) { ev.preventDefault(); triggerDownload(downloadUrl(api, pres.modelCode), downloadName(pres.modelCode)); };
setStatus(t('数模已生成,请选择格式后下载', 'Generated — select format and download'));
}
// 用生成服务返回的 STEP 做网页内 3D 预览 (查看器 url= 参数 fetch GenServer /preview/{series}/{code}.stp)
function previewGenerated(api, pres) {
var frame = $('viewerFrame');
var canvas = $('viewer3d');
var hint = $('viewer3dHint');
hint.classList.add('hidden');
frame.classList.remove('hidden');
canvas.classList.add('hidden');
var stepUrl = api + '/preview/' + encodeURIComponent(state.series.code) + '/' + encodeURIComponent(pres.modelCode) + '.stp';
frame.src = VIEWER_BASE + '?url=' + encodeURIComponent(stepUrl) + '&embed=1&bg=grey&menu=min';
if (v3d.fallbackTimer) clearTimeout(v3d.fallbackTimer);
// 查看器不可用 (404/离线) → 回退内置 Canvas (url= 跨域无法探测 contentDocument, 故直接标记可用)
v3d.fallbackTimer = setTimeout(function () {
try {
var d = frame.contentDocument;
var ok = !!(d && d.body && d.body.innerHTML.length > 200);
if (!ok) {
frame.classList.add('hidden');
canvas.classList.remove('hidden');
$('viewer3dHint').classList.remove('hidden');
$('viewer3dHint').textContent = t('3D 预览加载失败,请直接下载后本地查看', '3D preview failed — download to view locally');
}
} catch (e) { /* 跨域视为可用 */ }
}, 8000);
}
async function downloadCartZip() {
if (state.cart.length === 0) { setStatus(t('清单为空, 请先加入型号', 'Cart is empty')); return; }
var btn = $('cartDownload');
btn.disabled = true;
btn.textContent = t('打包中...', 'Packing...');
try {
var snap = state.cart.slice(); // 打包快照, 打包期间操作只影响下一次
var zip = new JSZip();
var missing = [];
var csv = '序号,型号编码,系列代码,系列名,数量,参数摘要,STEP文件名\r\n';
var used = {};
// 非标档位 + 生成服务可用 → 先批量在线生成, 成功后合并进打包 zip (型号子文件夹多格式)
var nonStd = snap.filter(function (it) { return !it.stepPath; });
var api = genApi();
if (nonStd.length && api) {
btn.textContent = t('正在在线生成非标数模…', 'Generating non-standard models…');
var st0 = null;
try {
var r0 = await fetch(api + '/gen-batch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: nonStd.map(function (it) {
return { series: it.seriesCode, code: it.modelCode, params: it.params || {} };
}) })
});
if (!r0.ok) throw new Error(t('请求被拒绝', 'Request rejected'));
var j0 = await r0.json();
if (j0.error) throw new Error(j0.error);
var done0 = false;
for (var w = 0; w < 200 && !done0; w++) {
await new Promise(function (res) { setTimeout(res, 3000); });
var sr0 = await fetch(api + '/status?job=' + encodeURIComponent(j0.jobId));
if (!sr0.ok) continue;
st0 = await sr0.json();
if (st0.state === 'queued') { btn.textContent = t('生成队列繁忙,请稍候…', 'Queued…'); continue; }
if (st0.state === 'running') {
if (st0.done !== undefined && st0.total) btn.textContent = t('正在生成 ' + st0.done + '/' + st0.total + '…', 'Generating ' + st0.done + '/' + st0.total + '…');
else btn.textContent = t('正在生成数模…', 'Generating…');
continue;
}
if (st0.state === 'done') { done0 = true; break; }
throw new Error(st0.error || t('生成失败,请重试或联系销售', 'Generation failed — retry or contact sales'));
}
if (!done0) throw new Error(t('生成超时,请重试', 'Generation timed out — retry'));
// 整体 zip (含检索命中的已留存型号) → 合并进打包 zip
var zr0 = await fetch(api + '/files?job=' + encodeURIComponent(j0.jobId) + '&token=' + encodeURIComponent(j0.token));
if (!zr0.ok) throw new Error(t('下载失败', 'Download failed'));
var sub = await JSZip.loadAsync(await zr0.blob());
await Promise.all(Object.keys(sub.files).map(function (p) {
if (sub.files[p].dir) return Promise.resolve();
return sub.files[p].async('uint8array').then(function (d) { zip.file(p, d); });
}));
var fail0 = (st0 && st0.items ? st0.items : []).filter(function (x) { return !x.ok; }).map(function (x) { return x.code; });
if (fail0.length) setStatus(t('部分非标生成失败: ', 'Some generation failed: ') + fail0.join(', '));
} catch (e) {
btn.disabled = false;
btn.textContent = t('下载全部 (zip + BOM)', 'Download All (zip + BOM)');
setStatus(t('非标生成失败: ', 'Generation failed: ') + e.message);
return;
}
}
for (var i = 0; i < snap.length; i++) {
var it = snap[i];
// 非标档位: 生成服务可用时已合并为型号子文件夹; 不可用则显式注明 (不静默跳过)
if (!it.stepPath) {
csv += [i + 1, it.modelCode, it.seriesCode, it.seriesName, it.qty, it.paramsSummary,
genApi() ? t('(在线生成, 型号子文件夹)', '(generated online, subfolder)') : t('(非标档位,无 STEP)', '(non-standard, no STEP)')].map(csvEscape).join(',') + '\r\n';
continue;
}
var seg = (it.stepPath || '').split('/').pop();
var ext = seg.indexOf('.') >= 0 ? seg.substring(seg.lastIndexOf('.')) : '.step';
var name = it.modelCode + ext;
if (used[name]) name = it.modelCode + '_' + it.seriesCode + ext; // 重名冲突加系列码
used[name] = true;
try {
zip.file(name, await fetchStepAsset(it.stepPath));
} catch (e) { missing.push(it.modelCode); }
csv += [i + 1, it.modelCode, it.seriesCode, it.seriesName, it.qty, it.paramsSummary, name].map(csvEscape).join(',') + '\r\n';
}
zip.file('BOM.csv', '' + csv); // UTF-8 BOM, Excel 中文不乱码
zip.file('清单.txt', csv.replace(/,/g, ' '));
var blob = await zip.generateAsync({ type: 'blob' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'ONEBOT_清单_' + timestampName() + '.zip';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 8000);
if (missing.length) setStatus(t('打包完成, 但以下型号文件缺失: ', 'Packed, but missing: ') + missing.join(', '));
else setStatus(t('已开始下载清单压缩包', 'Cart zip download started'));
} catch (e) {
setStatus(t('打包失败: ', 'Pack failed: ') + e.message);
} finally {
btn.disabled = false;
btn.textContent = t('下载全部 (zip + BOM)', 'Download All (zip + BOM)');
}
}
// 返回系列总览 (首页缩略图): 清除系列选中与展开的大类, 回到全部产品
$('btnBackHome').onclick = function () {
var all = $('tree').querySelectorAll('.series');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
state.series = null;
state.expandedCat = null;
state.sel = {};
showHome();
renderHome();
renderTree();
setStatus(t('已返回系列总览', 'Back to series overview'));
};
// 侧边栏折叠/展开
$('btnTreeToggle').onclick = function () { applyTreeCollapsed(!state.treeCollapsed); };
$('btnCart').onclick = openCart;
$('cartClose').onclick = closeCart;
$('cartOverlay').onclick = closeCart;
$('cartClear').onclick = function () { state.cart = []; renderCart(); };
$('cartDownload').onclick = downloadCartZip;
$('btnAddCart').onclick = function () { addToCart(curVariant || curParam); };
function setStatus(msg) { var m = $('statusMsg'); if (m) m.textContent = msg + ' [v3]'; }
function applyLang() {
$('btnLang').textContent = state.lang === 'zh' ? 'EN' : '中';
$('searchBox').placeholder = t('搜索型号 / 关键词 / 拼音', 'Search model / keyword / pinyin');
$('btnSearch').textContent = t('搜索', 'Search');
$('btnAddCart').textContent = t('加入清单', 'Add to Cart');
$('cartTitle').textContent = t('我的清单', 'My Cart');
$('cartClear').textContent = t('清空', 'Clear All');
$('cartDownload').textContent = t('下载全部 (zip + BOM)', 'Download All (zip + BOM)');
renderCartBadge();
$('btnDownload').textContent = t('下载 STEP', 'Download STEP');
$('btnDatasheet').textContent = t('数据表 PDF', 'Datasheet PDF');
$('dimHint').textContent = t('选型完成后显示尺寸图', 'Dimension drawing appears after selection');
var tabs = document.querySelectorAll('.tab');
tabs[0].textContent = t('3D / 尺寸图', '3D / Dimension');
tabs[1].textContent = t('参数表', 'Parameters');
tabs[2].textContent = t('搜索结果', 'Search');
tabs[3].textContent = t('平替对照', 'Cross-Reference');
renderTree();
if (state.onHome) renderHome(state.expandedCat); // 保持当前大类筛选
else if (state.series) selectSeries(state.series);
if ($('btnTreeToggle')) applyTreeCollapsed(state.treeCollapsed);
if ($('btnBackHome')) $('btnBackHome').textContent = t('← 返回系列总览', '← Back to overview');
}
// ---------- 自测 (与离线版 --selftest 对齐的关键场景) ----------
function runSelfTest() {
var pass = 0, fail = 0;
var out = [];
function check(name, ok) { out.push((ok ? 'PASS ' : 'FAIL ') + name); if (ok) pass++; else fail++; }
try {
var cat = state.catalog;
check('catalog 加载 (系列数 ' + cat.series.length + ')', cat.series.length >= 2);
var sc = cat.series[0];
check('SC 有效变体 72', validVariants(sc).length === 72);
var a1 = allowedOptions(sc, { bore: 16 });
var strokes = a1.stroke.map(function (x) { return num(x); }).sort(function (x, y) { return x - y; });
check('缸径16→行程仅25/50', strokes.length === 2 && strokes[0] === 25 && strokes[1] === 50);
var a2 = allowedOptions(sc, { magnet: 'S' });
check('磁石S→缸径不含16', a2.bore.length === 3 && a2.bore.every(function (x) { return num(x) !== 16; }));
var v = resolve(sc, { bore: 32, stroke: 50, magnet: 'S', mount: 'LB' });
check('选型命中 SC32x50S-LB', v && v.modelCode === 'SC32x50S-LB');
var maq = cat.series[1];
var a3 = allowedOptions(maq, { bore: 6 });
check('MAQ 缸径6→行程仅10/20', a3.stroke.length === 2 && num(a3.stroke[0]) === 10 && num(a3.stroke[1]) === 20);
buildSearchIndex();
var r1 = [];
state.searchIndex.forEach(function (it) { if (it.code.indexOf('sc32') >= 0) r1.push(it.code); });
check('搜索索引含 SC32 系列', r1.length >= 4);
var r2 = [];
state.searchIndex.forEach(function (it) { if (it.text.indexOf('gangjing') >= 0) r2.push(it.code); });
check('拼音关键词 gangjing 索引存在', r2.length > 0);
} catch (e) {
fail++;
out.push('EXCEPTION: ' + e.message);
}
out.push('结果: ' + pass + ' 通过, ' + fail + ' 失败');
setStatus('WEB SELFTEST — ' + out.join(' | '));
console.log(out.join('\n'));
}
// ---------- 3D 预览 (零依赖 Canvas: 解析自有 STEP 圆柱 → 面片画家算法) ----------
var v3d = { cyls: null, theta: 0.7, phi: 0.45, zoom: 1.0, dragging: false, lastX: 0, lastY: 0 };
var V3D_COLORS = ['#8C8C8C', '#2F6FB3', '#8C8C8C', '#C8C8C8'];
function parseStepCyls(text) {
var rs = [], xs = [], zs = [], m;
var re = /CYLINDRICAL_SURFACE\('',#\d+,([\d.]+)\);/g;
while ((m = re.exec(text))) rs.push(parseFloat(m[1]));
// 圆心点 = 仅有的 y=0 点: (X, 0, Z) 每圆柱一对
var re2 = /CARTESIAN_POINT\('',\((-?[\d.]+),0,(-?[\d.]+)\)\);/g;
while ((m = re2.exec(text))) { xs.push(parseFloat(m[1])); zs.push(parseFloat(m[2])); }
var cy = [];
for (var i = 0; i < rs.length && 2 * i + 1 < zs.length; i++)
cy.push({ z0: Math.min(zs[2 * i], zs[2 * i + 1]), z1: Math.max(zs[2 * i], zs[2 * i + 1]), r: rs[i], x: xs[2 * i] });
return cy;
}
// ---- 3D 查看器联动 (新迪 STEPViewer: occt-import-js + three.js, 支持真实 STEP) ----
// 本地联调: /viewer/ 前缀由 serve-web.ps1 -ViewerRoot 映射到新迪 dev 目录
// 生产同域部署 (3d.ruiguo.wang): 改为 '/STEPViewer/index.html'
// (模型文件已同步到 STEPViewer/step/ 目录, file 参数 = 目录内相对路径)
var VIEWER_BASE = '/viewer/STEPViewer/index.html';
function load3D(variant) {
var frame = $('viewerFrame');
var canvas = $('viewer3d');
var hint = $('viewer3dHint');
hint.classList.add('hidden');
frame.classList.remove('hidden');
canvas.classList.add('hidden');
// 站点根绝对路径 (/step/...): 模型始终从站点 step\ 目录取, 查看器无需自带模型副本 (踩坑 #20)
frame.src = VIEWER_BASE + '?file=' + encodeURIComponent('/' + variant.step) + '&embed=1&bg=grey&menu=min';
if (v3d.fallbackTimer) clearTimeout(v3d.fallbackTimer);
// 查看器不可用 (404/离线) → 回退内置 Canvas 渲染
v3d.fallbackTimer = setTimeout(function () {
var ok = false;
try {
var d = frame.contentDocument;
ok = !!(d && d.body && d.body.innerHTML.length > 200);
} catch (e) { ok = true; } // 跨域无法探测, 视为可用
if (!ok) {
frame.classList.add('hidden');
canvas.classList.remove('hidden');
load3DFallback(variant);
}
}, 8000);
}
function load3DFallback(variant) {
fetch(variant.step)
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
.then(function (text) {
v3d.cyls = parseStepCyls(text);
$('viewer3dHint').classList.add('hidden');
draw3D();
})
.catch(function (e) {
v3d.cyls = null;
$('viewer3dHint').classList.remove('hidden');
$('viewer3dHint').textContent = t('3D 加载失败: ', '3D load failed: ') + e.message;
});
}
function draw3D() {
var canvas = $('viewer3d');
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
if (!v3d.cyls || v3d.cyls.length === 0) return;
var scale = 60 * v3d.zoom;
var cx = W / 2, cy = H / 2;
function proj(p) {
var ct = Math.cos(v3d.theta), st = Math.sin(v3d.theta);
var cp = Math.cos(v3d.phi), sp = Math.sin(v3d.phi);
var x1 = p[0] * ct + p[2] * st;
var z1 = -p[0] * st + p[2] * ct;
var y1 = p[1] * cp - z1 * sp;
var z2 = p[1] * sp + z1 * cp;
var f = 420 / (420 + z2);
return { x: cx + x1 * f * scale, y: cy - y1 * f * scale, z: z2 };
}
var faces = [];
for (var ci = 0; ci < v3d.cyls.length; ci++) {
var c = v3d.cyls[ci];
var seg = 24;
var color = V3D_COLORS[ci % V3D_COLORS.length];
var ring0 = [], ring1 = [];
for (var i = 0; i <= seg; i++) {
var a = 2 * Math.PI * i / seg, x = Math.cos(a) * c.r + (c.x || 0), y = Math.sin(a) * c.r;
ring0.push([x, y, c.z0]);
ring1.push([x, y, c.z1]);
}
for (i = 0; i < seg; i++) {
var an = (i + 0.5) * 2 * Math.PI / seg;
faces.push({
pts: [ring0[i], ring0[i + 1], ring1[i + 1], ring1[i]],
n: [Math.cos(an), Math.sin(an), 0],
c: color
});
}
faces.push({ pts: ring0, n: [0, 0, -1], c: color });
faces.push({ pts: ring1, n: [0, 0, 1], c: color });
}
var light = [-0.5, 0.55, -0.75];
var ll = Math.sqrt(light[0] * light[0] + light[1] * light[1] + light[2] * light[2]);
light = [light[0] / ll, light[1] / ll, light[2] / ll];
faces.forEach(function (f) {
var pr = f.pts.map(proj);
f.depth = pr.reduce(function (s, q) { return s + q.z; }, 0) / pr.length;
f.pr = pr;
var nl = f.n[0] * light[0] + f.n[1] * light[1] + f.n[2] * light[2];
f.shade = Math.max(0.35, Math.min(1, 0.55 + 0.45 * nl));
});
faces.sort(function (a, b) { return a.depth - b.depth; }); // 远 → 近
faces.forEach(function (f) {
ctx.beginPath();
ctx.moveTo(f.pr[0].x, f.pr[0].y);
for (var k = 1; k < f.pr.length; k++) ctx.lineTo(f.pr[k].x, f.pr[k].y);
ctx.closePath();
var rr = parseInt(f.c.substr(1, 2), 16), gg = parseInt(f.c.substr(3, 2), 16), bb = parseInt(f.c.substr(5, 2), 16);
ctx.fillStyle = 'rgb(' + Math.round(rr * f.shade) + ',' + Math.round(gg * f.shade) + ',' + Math.round(bb * f.shade) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
ctx.stroke();
});
}
(function () {
var canvas = $('viewer3d');
canvas.addEventListener('mousedown', function (e) {
v3d.dragging = true; v3d.lastX = e.clientX; v3d.lastY = e.clientY;
});
window.addEventListener('mousemove', function (e) {
if (!v3d.dragging) return;
v3d.theta += (e.clientX - v3d.lastX) * 0.01;
v3d.phi = Math.max(-1.4, Math.min(1.4, v3d.phi + (e.clientY - v3d.lastY) * 0.01));
v3d.lastX = e.clientX; v3d.lastY = e.clientY;
draw3D();
});
window.addEventListener('mouseup', function () { v3d.dragging = false; });
canvas.addEventListener('wheel', function (e) {
e.preventDefault();
v3d.zoom = Math.max(0.3, Math.min(5, v3d.zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
draw3D();
});
})();
// ---------- 面板拖拽调宽 (目录树/参数面板, 与桌面版一致) ----------
// 全局阻止图片被原生拖拽: 手册页/尺寸图都是扫描大图, 一旦触发浏览器原生拖拽
// 会浮出半透明拖影、松手可能在新标签打开图片, 且拖拽期间 mousemove/mouseup 被浏览器
// 接管 → 分隔条卡死 (用户反馈"点击拖动就弹出来, 缩不回去")。
document.addEventListener('dragstart', function (e) {
var t = e.target;
if (t && t.tagName === 'IMG') e.preventDefault();
});
// 分隔条拖拽: 用 pointer 事件 + setPointerCapture (指针移出窗口/经过图片也不丢 pointerup)。
// 另有两道护栏, 否则分隔条会被拖出容器:
// 1) 夹取上限取 min(静态 maxW, 容器可用宽 - 分隔条 - 右侧最小宽) —— 固定 maxW(900) 在窄屏
// 会超过容器宽, 一旦写进 inline width 就把分隔条挤出 #tab-3d (overflow:hidden),
// 既看不见也点不到 → "缩不回去/没法自定义拖拽" (实测 ≤1512px 必然发生)。
// 2) 窗口 resize 时把已拖出的宽重新夹回容器可用宽。
function makeSplitter(id, prevSel, minW, maxW, minRight) {
var el = $(id);
var prev = document.querySelector(prevSel);
if (!el || !prev) return;
minRight = minRight || 0;
// 可视上限 = min(静态 maxW, 容器可用宽 - 分隔条 - 右侧最小宽)
function liveLimit() {
var box = el.parentElement;
var avail = (box ? box.clientWidth : 0) - el.offsetWidth - minRight;
return Math.max(minW, Math.min(maxW, avail));
}
// 双击复位为默认宽度 (CSS 里的初始比例)
el.addEventListener('dblclick', function () { prev.style.width = ''; prev.style.flex = ''; });
el.addEventListener('pointerdown', function (e) {
if (e.button !== 0) return; // 只响应左键
e.preventDefault();
el.classList.add('dragging');
var startW = Math.min(prev.offsetWidth, liveLimit());
var startX = e.clientX;
var pid = e.pointerId;
try { el.setPointerCapture(pid); } catch (err) { /* 个别环境不支持, 退化为普通监听 */ }
function apply(w) { prev.style.width = w + 'px'; prev.style.flex = 'none'; }
function move(ev) {
if (ev.pointerId !== pid) return;
apply(Math.max(minW, Math.min(liveLimit(), startW + ev.clientX - startX)));
}
function up(ev) {
if (ev && ev.pointerId !== pid) return;
el.classList.remove('dragging');
try { el.releasePointerCapture(pid); } catch (err) { }
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
window.removeEventListener('blur', up); // 切窗口/失焦也要收尾
}
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
window.addEventListener('blur', up);
// 兜底: 指针已松开却仍在拖拽态 (事件丢失) → 下次移动即纠正
document.addEventListener('pointermove', function watchdog(ev) {
if (ev.buttons === 0 && el.classList.contains('dragging')) up({ pointerId: pid });
else if (!el.classList.contains('dragging')) document.removeEventListener('pointermove', watchdog);
});
});
}
makeSplitter('split1', '.tree', 150, 420);
makeSplitter('split2', '.params', 220, 520);
// minW 260→220 让窄屏 3D 区仍可用; minRight=180 防止尺寸图/手册页被压成细条
makeSplitter('split3', '.hsplit-left', 220, 900, 180); // 3D/尺寸图 分栏分隔条 (同页内拖拽)
// 窗口尺寸变化时, 把已拖出的像素宽重新夹回容器可用宽 ——
// 1920px 下拉出的 900px 在缩到 1366px 后仍会挤走分隔条, 仅靠拖拽时的夹取防不住。
(function () {
var PREV = { split1: '.tree', split2: '.params', split3: '.hsplit-left' };
var MINW = { split1: 150, split2: 220, split3: 220 };
var MINR = { split1: 0, split2: 0, split3: 180 };
var t = null;
window.addEventListener('resize', function () {
if (t) clearTimeout(t);
t = setTimeout(function () {
Object.keys(PREV).forEach(function (id) {
var el = $(id), prev = document.querySelector(PREV[id]);
if (!el || !prev || !prev.style.width) return;
if (!el.offsetWidth) return; // 分隔条被隐藏 (左树 rail) 时不干预
var avail = el.parentElement.clientWidth - el.offsetWidth - MINR[id];
var w = Math.max(MINW[id], Math.min(parseFloat(prev.style.width), avail));
prev.style.width = w + 'px';
});
}, 120);
});
})();
// ---------- 初始化 ----------
// 下载点击反馈: 立即提示 + 3 秒内防重复点击 (此前无反馈, 用户多次点击导致重复下载)
var lastDl = 0;
$('btnDownload').addEventListener('click', function () {
var now = Date.now();
if (now - lastDl < 3000) return;
lastDl = now;
var m = $('btnDownload').dataset.model || '';
setStatus(t('正在准备下载 ' + m + '.step ...', 'Preparing ' + m + '.step ...'));
setTimeout(function () {
setStatus(t('选型完成,点击下载 STEP', 'Ready - click to download STEP'));
}, 5000);
});
$('btnSearch').onclick = doSearch;
$('searchBox').addEventListener('keydown', function (e) { if (e.key === 'Enter') doSearch(); });
$('btnLang').onclick = function () { state.lang = state.lang === 'zh' ? 'en' : 'zh'; applyLang(); };
document.querySelectorAll('.tab').forEach(function (el) {
el.onclick = function () { switchTab(el.getAttribute('data-tab')); };
});
load();
startServicePoll();
// ---------- 生成服务状态轮询 (底部信息栏圆球: 绿=就绪, 红=故障; 文字"服务器状态·状态") ----------
function startServicePoll() {
var el = $('srvStatus');
if (!el) return;
var txtEl = $('srvStatusText');
function setState(cls, word) {
el.className = 'srv-status ' + cls;
el.title = word;
if (txtEl) txtEl.textContent = t('服务器状态', 'Server') + ' · ' + word;
}
function tick() {
var api = genApi();
if (!api) { setState('down', t('未配置', 'Not configured')); return; }
fetch(api + '/health', { method: 'GET' })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (h) {
if (!h.nxOk) setState('down', t('NX 未就绪', 'NX not ready'));
else if (h.workerAlive) setState('ready', t('就绪', 'Ready'));
else if (h.queued > 0 || h.running > 0) setState('no-worker', t('等待 worker', 'Waiting'));
else setState('idle', t('就绪', 'Ready'));
})
.catch(function () { setState('down', t('不可达', 'Unreachable')); });
}
tick();
setInterval(tick, 5000);
}
})();