Files
OnebotCatalog/web/app.js
wangruiguo fc5ee1dd36 v2.1: DESTACO 风格选型界面 + 参数组合补全(缺组合也能出零件号)
网页版选型改版 (仅网页版, 桌面版待同步)
- 新增零件号实时条: 边选边拼, 已确定片段实心高亮, 未定片段虚化, 进度条随选推进;
  浅色配色 (初版深色块与页面不协调, 已改); 选满且唯一命中时整块转青色实心
- 技术方案: 40 系列中仅 KC 有编码模板 → 改用「当前候选型号的最长公共前缀」,
  已验证 40/40 系列前缀单调增长(不回退)
- 参数选择由下拉菜单改为可视化选项块: 悬停抬升+青色投影, 选中青色填充+✓,
  不可选取值置灰(虚线+划线, 不消失, 让用户看到选项存在但当前组合不适用),
  再点一次取消; 有说明的显示代号+说明, 纯数值用紧凑文字块
- 参数联动仍复用 allowedOptions 规则引擎, 语义未变, 只改渲染

参数组合补全
- 原逻辑: 必须有真实数模才能生成型号 → 库里没有的组合选不出来也出不了号
- infer-sku-template.py 从已有型号反推编码模板, 得 23 个系列 100% 可复现
- verify-templates.py 作为门禁: 用模板重渲染每个已有型号必须逐字一致 ——
  该检查抓出 ULM/ULP 行程补零位数写错(2位写3位), 否则会生成 3000 个不存在的零件号
- gen-missing-combos.py 补全 8 个系列共 3157 个型号 (全库 54136 → 57293)
- 占位数模: 3157 个型号共用 1 个 PLACEHOLDER.step(4.5KB), 构建器按路径打包只存一份,
  数据包仅 2.10GB → 2.14GB (中途用硬链接方案踩坑: NTFS 上限 1024, 超出退化为拷贝白占 500MB)
- _pending_models.csv 输出待补数模清单, 供 AI 补完后替换 step_file

新增脚本: infer-sku-template / verify-templates / quantify-combo-gap /
          gen-missing-combos / gen-placeholder-step / check-combo-unique
新增文档: docs/更改说明-v2.1.md

发现未处理 (已记录在更改说明):
- 夹紧缸 9 系列一组参数对应多型号 (310 个型号, 编码含参数外尾缀, 旧码/新码混用)
- UAGP/LAGP 参数顺序与官方编码结构不符 (传感器应在转角之前)
- 17 个系列因模板不干净未补全 (宁可少补, 不生成可能不存在的零件号)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 02:14:55 +08:00

1852 lines
80 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';
row.id = 'row-' + p.code;
var label = document.createElement('label');
label.id = 'lbl-' + p.code;
label.textContent = paramName(p);
var picked = document.createElement('span');
picked.className = 'picked';
picked.id = 'picked-' + p.code;
label.appendChild(picked);
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 grid = document.createElement('div');
grid.className = 'opt-grid';
grid.id = 'opts-' + p.code;
// 选项较多时用更紧凑的列宽
if ((p.values || []).length > 8) grid.classList.add('wide');
row.appendChild(grid);
panel.appendChild(row);
});
renderSkuBar();
$('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 vals = allowed[p.code] || [];
if (p.type === 'range') {
// 输入框: 更新 datalist 标准档位 (标注 标准); 已选值回显
var inp = $('sel-' + p.code);
var dl = $('dl-' + p.code);
if (!inp || !dl) return;
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);
});
inp.value = state.sel[p.code] !== undefined ? str(state.sel[p.code]) : '';
return;
}
var grid = $('opts-' + p.code);
if (!grid) return;
// 已选值变得不可选时清除
if (state.sel[p.code] !== undefined &&
!vals.some(function (v) { return str(v) === str(state.sel[p.code]); })) {
delete state.sel[p.code];
}
// 候选值: 定义域全集 (保证卡片位置稳定, 不可选的置灰而非消失)
var all = (p.values || []);
grid.innerHTML = '';
all.forEach(function (v) {
var usable = vals.some(function (x) { return str(x) === str(v); });
var on = state.sel[p.code] !== undefined && str(v) === str(state.sel[p.code]);
var tile = document.createElement('div');
tile.className = 'opt' + (on ? ' on' : '') + (usable ? '' : ' off');
var disp = displayValue(p, v);
// 有中文说明时: 代号大字 + 说明小字; 无说明 (如纯数字) 只显示值
var hasDesc = disp && disp !== str(v);
var k = document.createElement('div');
k.className = 'opt-k';
k.textContent = hasDesc ? optValue(v) .replace('__EMPTY__', '—') : disp;
tile.appendChild(k);
if (hasDesc) {
var dd = document.createElement('div');
dd.className = 'opt-d';
dd.textContent = disp;
tile.appendChild(dd);
} else {
tile.classList.add('txt');
}
if (usable) {
tile.onclick = function () {
if (state.sel[p.code] !== undefined && str(v) === str(state.sel[p.code])) {
delete state.sel[p.code]; // 再点一次取消
} else {
state.sel[p.code] = p.type === 'number' ? num(v) : (str(v) === '__EMPTY__' ? '' : v);
}
refreshOptions();
};
}
grid.appendChild(tile);
});
});
// 维度标签后面回显当前选择
s.parameters.forEach(function (p) {
var el = $('picked-' + p.code);
if (!el) return;
el.textContent = state.sel[p.code] !== undefined ? '· ' + displayValue(p, state.sel[p.code]) : '';
});
renderSkuBar();
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);
}
// ---------- 零件号实时拼装 (DESTACO 风) ----------
// 不依赖编码模板 (40 个系列里只有 KC 有模板): 用「当前候选型号的最长公共前缀」——
// 选得越多, 候选集越小, 公共前缀越长, 逐字拼出完整零件号。
// 已用 check-sku-lcp.py 验证 40/40 系列前缀单调增长 (不回退)。
function candidateCodes() {
var s = state.series;
if (!s) return [];
var keys = Object.keys(state.sel);
if (!keys.length) return (s.variants || []).map(function (v) { return v.modelCode; });
return (s.variants || []).filter(function (v) {
return keys.every(function (k) { return str(v.params[k]) === str(state.sel[k]); });
}).map(function (v) { return v.modelCode; });
}
function lcp(list) {
if (!list.length) return '';
var a = list.reduce(function (m, x) { return x < m ? x : m; });
var b = list.reduce(function (m, x) { return x > m ? x : m; });
var i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) i++;
return a.slice(0, i);
}
function renderSkuBar() {
var bar = $('skuBar');
if (!bar) return;
var s = state.series;
if (!s) return;
var chosen = Object.keys(state.sel).length;
var total = s.parameters.length;
var cand = candidateCodes();
var pre = lcp(cand);
// 未选的维度用占位符预演长度 (让用户看到"还差几位")
var rest = '';
s.parameters.forEach(function (p) {
if (state.sel[p.code] !== undefined) return;
rest += '·';
});
bar.classList.toggle('done', chosen === total && cand.length === 1);
$('skuCode').innerHTML = pre
? '<span class="sku-fixed">' + esc(pre) + '</span>' +
'<span class="sku-tbd">' + esc(rest) + '</span>'
: '<span class="sku-tbd">' + esc(t('选择参数以生成零件号', 'Select options to build part number')) + '</span>';
var bar2 = $('skuProg');
if (bar2) bar2.style.width = (total ? chosen / total * 100 : 0) + '%';
var hint = $('skuHint');
if (hint) {
if (chosen < total) {
hint.textContent = t('已选 ' + chosen + '/' + total + ' 项 · 候选 ' +
cand.length + ' 个型号', chosen + '/' + total + ' selected · ' + cand.length + ' candidates');
} else if (cand.length === 1) {
hint.textContent = t('零件号完整', 'Part number complete');
} else if (cand.length > 1) {
hint.textContent = t('该参数组合对应 ' + cand.length + ' 个型号, 请在下方选择',
cand.length + ' models share these options — pick one below');
} else {
hint.textContent = t('无匹配型号', 'No matching model');
}
}
}
// ---------- 参数表 (含范围筛选) ----------
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);
}
})();