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