Files
OnebotCatalog/web/app.js
wangruiguo f343b26444 v2.4: 网页版适配 Odoo 网站嵌入
- URL 深度链接:?series= 打开系列 / ?model= 定位型号 / ?embed=1 隐藏自身顶栏 / ?nav=0 隐藏侧边栏 / ?lang= 切语言
- 路径自适应:VIEWER_BASE 与模型路径从 location.pathname 推导,支持子路径部署(原来硬编码 /viewer/ 和 /step/ 会在子路径下 404)
- embed 模式样式:隐藏 header/footer,让 iframe 内更干净
- 手册页图片点击放大:全屏弹窗,← → 翻页,Esc/点遮罩关闭
- 数据表 PDF 弹窗预览:原为直接下载,改为内联预览(弹窗内保留下载入口)
- 数据表按钮加内联 SVG 图标(PDF + 放大镜),区别于纯下载按钮
- 隐藏「返回系列总览」按钮(嵌入模式下属多余)

注:本次改动服务于 OneBot 官网产品页的选型 tab 嵌入。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 02:57:34 +08:00

2090 lines
89 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();
applyDeepLink(); // URL 参数深度链接 (?series= / ?model= / ?embed=1)
})
.catch(function (e) {
setStatus(t('加载 catalog.json 失败: ', 'Failed to load catalog.json: ') + e.message +
t(' (请通过 Web 服务器访问, 不能直接双击打开本页)', ' (access via a web server, not file://)'));
});
}
// ---------- 深度链接 (供 Odoo 产品页 iframe 调用) ----------
// ?series=UCBM 直接打开某系列(型号编码前缀或系列 code)
// ?model=UCBM32AK010 直接定位到某个具体型号(复用 jumpToModel)
// ?embed=1 嵌入模式:隐藏自身顶栏/底栏,让 iframe 里更干净
// ?nav=0 隐藏左侧系列树(产品页已定系列,不需要再让用户挑)
// 说明: 本函数只在 catalog.json 加载完成后调用一次(见 load())
function applyDeepLink() {
var q = new URLSearchParams(location.search || '');
// 嵌入模式:隐藏工具自身的 header/footer,避免在 Odoo 页面里出现"页中页"
if (q.get('embed') === '1') {
document.body.classList.add('embed-mode');
var st = $('status');
if (st) st.classList.add('hidden');
var hd = document.querySelector('header');
if (hd) hd.classList.add('hidden');
}
// 隐藏左侧系列树(产品页场景)
if (q.get('nav') === '0') {
document.body.classList.add('nav-off');
}
// 语言: ?lang=en
var lg = q.get('lang');
if (lg === 'en' || lg === 'zh') {
state.lang = lg;
renderTree();
renderHome();
renderCrossRef();
}
// 定位型号(优先级最高)
var model = q.get('model');
if (model) { jumpToModel(model); return; }
// 打开系列:支持传系列 code(KC)或型号前缀(UCBM32)
var want = q.get('series');
if (!want) return;
var up = String(want).toUpperCase();
var hit = null;
(state.catalog.series || []).forEach(function (s) {
if (String(s.code).toUpperCase() === up) hit = s;
});
if (!hit) {
// 退一步:按型号编码前缀匹配(如 UCBM32 → 系列 UCBM)
(state.catalog.series || []).forEach(function (s) {
if (hit) return;
(s.variants || []).some(function (v) {
if (String(v.modelCode).toUpperCase().indexOf(up) === 0) { hit = s; return true; }
return false;
});
});
}
if (hit) {
selectSeries(hit);
} else {
setStatus(t('未找到系列: ', 'Series not found: ') + want);
}
}
// ---------- 目录树 ----------
// 大类图标 (内联 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 拦截)
im.title = t('点击放大', 'Click to enlarge');
// 点击放大: 同一手册页的多张图可左右切换 (← → 键)
im.onclick = function () { openImageViewer(pages, idx); };
mw.appendChild(cap);
mw.appendChild(im);
});
}
// ---------- 手册页图片放大 (单击弹窗, 支持多页左右切换) ----------
var zview = { el: null, pages: [], idx: 0 };
function openImageViewer(pages, idx) {
closeImageViewer(); // 先清掉可能存在的旧弹窗
zview.pages = pages || [];
zview.idx = idx || 0;
var ov = document.createElement('div');
ov.className = 'img-zoom-overlay';
var im = document.createElement('img');
im.src = zview.pages[zview.idx].path;
im.alt = zview.pages[zview.idx].path;
im.draggable = false;
var btn = document.createElement('button');
btn.className = 'img-zoom-close';
btn.type = 'button';
btn.innerHTML = '&times;';
btn.setAttribute('aria-label', t('关闭', 'Close'));
btn.onclick = function (ev) { ev.stopPropagation(); closeImageViewer(); };
var cap = document.createElement('div');
cap.className = 'img-zoom-cap';
cap.textContent = t('手册页', 'Manual page') + ' ' + (zview.idx + 1) + '/' + zview.pages.length
+ (zview.pages.length > 1 ? t(' (← → 切换,Esc 关闭)', ' (← → to switch, Esc to close)') : '');
ov.appendChild(im);
ov.appendChild(btn);
ov.appendChild(cap);
// 点遮罩空白处关闭(点图片本身不关)
ov.onclick = function (ev) { if (ev.target === ov) closeImageViewer(); };
document.body.appendChild(ov);
zview.el = ov;
document.addEventListener('keydown', onZoomKey);
}
function onZoomKey(ev) {
if (!zview.el) return;
if (ev.key === 'Escape') { closeImageViewer(); }
else if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
if (zview.pages.length < 2) return;
var d = ev.key === 'ArrowRight' ? 1 : -1;
zview.idx = (zview.idx + d + zview.pages.length) % zview.pages.length;
var im = zview.el.querySelector('img');
im.src = zview.pages[zview.idx].path;
var cap = zview.el.querySelector('.img-zoom-cap');
if (cap) cap.textContent = t('手册页', 'Manual page') + ' ' + (zview.idx + 1) + '/' + zview.pages.length
+ t(' (← → 切换,Esc 关闭)', ' (← → to switch, Esc to close)');
}
}
function closeImageViewer() {
if (zview.el && zview.el.parentNode) zview.el.parentNode.removeChild(zview.el);
zview.el = null;
document.removeEventListener('keydown', onZoomKey);
}
// ---------- 数据表 PDF 弹窗预览 ----------
// 用浏览器内置 PDF 阅读器(iframe),弹窗内保留下载入口。
// 移动端部分浏览器(尤其 iOS Safari)不内联显示 PDF,所以在弹窗里同时给下载按钮兜底。
var pview = { el: null };
function openPdfViewer(url, fname) {
closePdfViewer();
var ov = document.createElement('div');
ov.className = 'pdf-zoom-overlay';
var box = document.createElement('div');
box.className = 'pdf-zoom-box';
var head = document.createElement('div');
head.className = 'pdf-zoom-head';
var title = document.createElement('span');
title.className = 'pdf-zoom-title';
title.textContent = fname;
var dl = document.createElement('a');
dl.className = 'pdf-zoom-dl';
dl.href = url;
dl.setAttribute('download', fname);
// 内联 SVG 下载图标(工具无图标字体)
dl.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" '
+ 'stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;vertical-align:-2px">'
+ '<path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M4 20h16"/></svg> '
+ t('下载', 'Download');
var btn = document.createElement('button');
btn.className = 'img-zoom-close pdf-zoom-close';
btn.type = 'button';
btn.innerHTML = '&times;';
btn.setAttribute('aria-label', t('关闭', 'Close'));
btn.onclick = function (ev) { ev.stopPropagation(); closePdfViewer(); };
head.appendChild(title);
head.appendChild(dl);
head.appendChild(btn);
var fr = document.createElement('iframe');
fr.className = 'pdf-zoom-frame';
fr.src = url + '#toolbar=1&view=FitH';
fr.setAttribute('title', fname);
box.appendChild(head);
box.appendChild(fr);
ov.appendChild(box);
// 点遮罩空白处关闭(点弹窗内部不关)
ov.onclick = function (ev) { if (ev.target === ov) closePdfViewer(); };
document.body.appendChild(ov);
pview.el = ov;
document.addEventListener('keydown', onPdfKey);
}
function onPdfKey(ev) {
if (pview.el && ev.key === 'Escape') closePdfViewer();
}
function closePdfViewer() {
if (pview.el && pview.el.parentNode) {
// 先清空 iframe.src 再移除,避免某些浏览器里 PDF 后台继续加载
var fr = pview.el.querySelector('iframe');
if (fr) fr.src = 'about:blank';
pview.el.parentNode.removeChild(pview.el);
}
pview.el = null;
document.removeEventListener('keydown', onPdfKey);
}
// 空字符串是合法枚举值 (如 磁石=""=无磁石), 用哨兵值避免与"未选择"混淆
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) {
var dimEl = $('dimImage');
dimEl.src = att.path;
dimEl.classList.remove('hidden');
dimEl.title = t('点击放大', 'Click to enlarge');
dimEl.style.cursor = 'zoom-in';
// 技术文档(尺寸图)同样支持点击弹窗放大
dimEl.onclick = function () {
openImageViewer([{ path: att.path }], 0);
};
$('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) {
// 数据表 PDF: 点击弹窗预览(用浏览器内置 PDF 阅读器),弹窗内保留下载入口。
// 原来是直接下载(2026-08-24),用户反馈希望先预览(2026-09-16)。
// ⚠️ 不能沿用 <a download>:那会直接下载。改成 button + 弹窗。
ds.removeAttribute('href');
ds.removeAttribute('download');
ds.style.cursor = 'pointer';
ds.title = t('点击预览', 'Click to preview');
// 带预览图标,区别于纯下载按钮(用户要求能一眼看出可预览)。
// ⚠️ 工具没有图标字体(只带 JSZip),所以用**内联 SVG**(与分类图标同做法)。
ds.innerHTML =
'<svg class="ds-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>'
+ '<path d="M14 2v6h6"/><path d="M8 13h2.5a1.5 1.5 0 0 1 0 3H8v-6"/>'
+ '</svg>'
+ '<span class="ds-txt">' + t('数据表 PDF', 'Datasheet PDF') + '</span>'
+ '<svg class="ds-ico ds-ico-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
+ '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5M11 8v6M8 11h6"/>'
+ '</svg>';
ds.onclick = function (ev) {
ev.preventDefault();
openPdfViewer(dsAtt.path, 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');
// ⚠️ 数据表按钮带图标(pdf + 放大镜),不能用 textContent 整段替换,
// 否则切语言时图标会被抹掉。只改文字节点。
var dsBtn = $('btnDatasheet');
if (dsBtn) {
var dsTxt = dsBtn.querySelector('.ds-txt');
if (dsTxt) dsTxt.textContent = t('数据表 PDF', 'Datasheet PDF');
else dsBtn.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/STEPViewer/index.html' + 站点根绝对路径 '/step/…',
// 一旦部署到子路径(如 Odoo 的 /catalog/)就会 404。
// 改为从当前页面 URL 推导根路径,本地联调 / 生产子路径 / 根路径都能用。
// 例: 页面 /catalog/index.html → BASE_DIR = '/catalog'
var BASE_DIR = location.pathname.replace(/\/[^/]*$/, ''); // 去掉文件名, 得 '/catalog' 或 ''
// 查看器位置: 优先同目录下的 viewer/STEPViewer(生产整站一起部署时就是这个)
// 回退到 /viewer/STEPViewer(本地 serve-web.ps1 -ViewerRoot 联调用)
var VIEWER_BASE = BASE_DIR + '/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');
// 模型路径同样要带 BASE_DIR 前缀(原为站点根 '/' + step)
frame.src = VIEWER_BASE + '?file=' + encodeURIComponent(BASE_DIR + '/' + 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);
}
})();