Files

1409 lines
60 KiB
JavaScript
Raw Permalink 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: []
};
// ---------- 工具 ----------
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;
$('ver').textContent = cat.catalogName + ' v' + cat.catalogVersion;
buildSearchIndex();
renderTree();
renderCrossRef();
setStatus(t('目录加载完成,请选择产品系列', 'Catalog loaded, select 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://)'));
});
}
// ---------- 目录树 ----------
function renderTree() {
var tree = $('tree');
tree.innerHTML = '';
state.catalog.categories.forEach(function (cat) {
var catDiv = document.createElement('div');
catDiv.className = 'cat';
catDiv.textContent = t(cat.nameZh, cat.nameEn);
tree.appendChild(catDiv);
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);
div.onclick = function () {
var all = tree.querySelectorAll('.series');
for (var i = 0; i < all.length; i++) all[i].classList.remove('active');
div.classList.add('active');
selectSeries(s);
};
tree.appendChild(div);
});
});
}
// ---------- 选型 ----------
function selectSeries(s) {
state.series = s;
state.sel = {};
state.ranges = {};
state.tableFilter = {}; // 换系列重置参数表列筛选
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;
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('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(/\/+$/, '') : '';
}
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('正在生成数模,预计 1060 秒…', 'Generating — typically 1060 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('正在生成数模,预计 1060 秒…', 'Generating — typically 1060 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);
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 {
var resp = await fetch(it.stepPath);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
zip.file(name, await resp.blob());
} 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)');
}
}
$('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.series) selectSeries(state.series);
}
// ---------- 自测 (与离线版 --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();
});
})();
// ---------- 面板拖拽调宽 (目录树/参数面板, 与桌面版一致) ----------
function makeSplitter(id, prevSel, minW, maxW) {
var el = $(id);
var prev = document.querySelector(prevSel);
el.addEventListener('mousedown', function (e) {
e.preventDefault();
el.classList.add('dragging');
var startX = e.clientX, startW = prev.offsetWidth;
function move(ev) {
var w = Math.max(minW, Math.min(maxW, startW + ev.clientX - startX));
prev.style.width = w + 'px';
prev.style.flex = 'none';
}
function up() {
el.classList.remove('dragging');
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
}
makeSplitter('split1', '.tree', 150, 420);
makeSplitter('split2', '.params', 220, 520);
makeSplitter('split3', '.hsplit-left', 260, 900); // 3D/尺寸图 分栏分隔条 (同页内拖拽)
// ---------- 初始化 ----------
// 下载点击反馈: 立即提示 + 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);
}
})();