v2.1: DESTACO 风格选型界面 + 参数组合补全(缺组合也能出零件号)
网页版选型改版 (仅网页版, 桌面版待同步)
- 新增零件号实时条: 边选边拼, 已确定片段实心高亮, 未定片段虚化, 进度条随选推进;
浅色配色 (初版深色块与页面不协调, 已改); 选满且唯一命中时整块转青色实心
- 技术方案: 40 系列中仅 KC 有编码模板 → 改用「当前候选型号的最长公共前缀」,
已验证 40/40 系列前缀单调增长(不回退)
- 参数选择由下拉菜单改为可视化选项块: 悬停抬升+青色投影, 选中青色填充+✓,
不可选取值置灰(虚线+划线, 不消失, 让用户看到选项存在但当前组合不适用),
再点一次取消; 有说明的显示代号+说明, 纯数值用紧凑文字块
- 参数联动仍复用 allowedOptions 规则引擎, 语义未变, 只改渲染
参数组合补全
- 原逻辑: 必须有真实数模才能生成型号 → 库里没有的组合选不出来也出不了号
- infer-sku-template.py 从已有型号反推编码模板, 得 23 个系列 100% 可复现
- verify-templates.py 作为门禁: 用模板重渲染每个已有型号必须逐字一致 ——
该检查抓出 ULM/ULP 行程补零位数写错(2位写3位), 否则会生成 3000 个不存在的零件号
- gen-missing-combos.py 补全 8 个系列共 3157 个型号 (全库 54136 → 57293)
- 占位数模: 3157 个型号共用 1 个 PLACEHOLDER.step(4.5KB), 构建器按路径打包只存一份,
数据包仅 2.10GB → 2.14GB (中途用硬链接方案踩坑: NTFS 上限 1024, 超出退化为拷贝白占 500MB)
- _pending_models.csv 输出待补数模清单, 供 AI 补完后替换 step_file
新增脚本: infer-sku-template / verify-templates / quantify-combo-gap /
gen-missing-combos / gen-placeholder-step / check-combo-unique
新增文档: docs/更改说明-v2.1.md
发现未处理 (已记录在更改说明):
- 夹紧缸 9 系列一组参数对应多型号 (310 个型号, 编码含参数外尾缀, 旧码/新码混用)
- UAGP/LAGP 参数顺序与官方编码结构不符 (传感器应在转角之前)
- 17 个系列因模板不干净未补全 (宁可少补, 不生成可能不存在的零件号)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
148
web/app.js
148
web/app.js
@@ -514,12 +514,17 @@
|
||||
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.textContent = paramName(p);
|
||||
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 域: 输入框 + 标准档位快捷选择 (与离线版可编辑下拉一致)
|
||||
// 连续域 (range) 无法枚举成卡片 → 保留输入框 + 标准档位
|
||||
var inp = document.createElement('input');
|
||||
inp.id = 'sel-' + p.code;
|
||||
inp.className = 'param-input';
|
||||
@@ -545,20 +550,16 @@
|
||||
panel.appendChild(row);
|
||||
return;
|
||||
}
|
||||
var sel = document.createElement('select');
|
||||
sel.id = 'sel-' + p.code;
|
||||
sel.onchange = function () {
|
||||
var raw = sel.value;
|
||||
if (raw === '') { delete state.sel[p.code]; }
|
||||
else {
|
||||
var v = raw === '__EMPTY__' ? '' : (p.type === 'number' ? num(raw) : raw);
|
||||
state.sel[p.code] = v;
|
||||
}
|
||||
refreshOptions();
|
||||
};
|
||||
row.appendChild(sel);
|
||||
// 枚举维度 → 选项块 (有图卡片 / 无图文字块)
|
||||
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');
|
||||
// 重置尺寸图区: 选型完成前只显示提示文字, 不显示占位图
|
||||
@@ -600,11 +601,12 @@
|
||||
if (!s) return;
|
||||
var allowed = allowedOptions(s, state.sel);
|
||||
s.parameters.forEach(function (p) {
|
||||
var sel = $('sel-' + p.code);
|
||||
var vals = allowed[p.code] || [];
|
||||
if (p.type === 'range') {
|
||||
// 输入框: 更新 datalist 标准档位 (标注 标准); 已选值回显
|
||||
var inp = $('sel-' + p.code);
|
||||
var dl = $('dl-' + p.code);
|
||||
if (!inp || !dl) return;
|
||||
dl.innerHTML = '';
|
||||
vals.forEach(function (v) {
|
||||
var opt = document.createElement('option');
|
||||
@@ -613,23 +615,59 @@
|
||||
opt.label = isGrid ? str(v) + ' (' + t('标准', 'Std') + ')' : str(v);
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
sel.value = state.sel[p.code] !== undefined ? str(state.sel[p.code]) : '';
|
||||
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];
|
||||
}
|
||||
sel.innerHTML = '<option value="">' + t('请选择', 'Select') + '</option>';
|
||||
vals.forEach(function (v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = optValue(v);
|
||||
opt.textContent = displayValue(p, v);
|
||||
if (state.sel[p.code] !== undefined && str(v) === str(state.sel[p.code])) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
// 候选值: 定义域全集 (保证卡片位置稳定, 不可选的置灰而非消失)
|
||||
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(); // 平替对照随选型刷新 (只显示当前型号的平替)
|
||||
}
|
||||
@@ -753,6 +791,70 @@
|
||||
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');
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
|
||||
<section id="params" class="params hidden">
|
||||
<div id="modelTitle" class="model-title">请选择产品系列</div>
|
||||
<!-- 零件号实时条: 边选边拼, 已确定片段高亮 / 待定片段虚化 -->
|
||||
<div id="skuBar" class="sku-bar">
|
||||
<div class="sku-label" id="skuLabel">零件号 Part Number</div>
|
||||
<div class="sku-code" id="skuCode"></div>
|
||||
<div class="sku-prog"><i id="skuProg"></i></div>
|
||||
<div class="sku-hint" id="skuHint"></div>
|
||||
</div>
|
||||
<div id="paramPanel"></div>
|
||||
<div id="actions">
|
||||
<a id="btnDownload" class="btn primary hidden" download>下载 STEP</a>
|
||||
|
||||
111
web/style.css
111
web/style.css
@@ -245,8 +245,17 @@ main { flex: 1; display: flex; min-height: 0; }
|
||||
font-size: 16px; font-weight: 700; margin-bottom: 12px;
|
||||
color: var(--gray-900); letter-spacing: -.3px; line-height: 1.4;
|
||||
}
|
||||
.param-row { margin-bottom: 11px; }
|
||||
.param-row label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.param-row { margin-bottom: 14px; }
|
||||
.param-row label {
|
||||
display: flex; align-items: baseline; gap: 7px;
|
||||
font-size: 11px; font-weight: 700; color: var(--text-faint);
|
||||
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 7px;
|
||||
}
|
||||
/* 维度已选值回显在其后 */
|
||||
.param-row label .picked {
|
||||
text-transform: none; letter-spacing: 0; font-weight: 600;
|
||||
color: var(--primary-700); font-size: 11.5px;
|
||||
}
|
||||
.param-row select, .param-input {
|
||||
width: 100%; height: 36px; padding: 0 10px; font-size: 13px;
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
@@ -258,6 +267,104 @@ main { flex: 1; display: flex; min-height: 0; }
|
||||
}
|
||||
#actions { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ============================================================ 零件号实时条 (DESTACO 风)
|
||||
* 边选边拼: 已确定部分实心高亮, 未定部分虚化占位。 */
|
||||
/* ============================================================ 零件号实时条
|
||||
* 边选边拼: 已确定片段的实心高亮, 未定片段虚化占位。
|
||||
* 配色走浅色主调 (与整体卡片风格一致), 不用深色块。 */
|
||||
.sku-bar {
|
||||
background: linear-gradient(135deg, var(--primary-50), #ffffff 70%);
|
||||
border: 1px solid var(--primary-200);
|
||||
border-radius: var(--radius); padding: 13px 15px; margin-bottom: 14px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.sku-bar .sku-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: .7px;
|
||||
color: var(--text-faint); margin-bottom: 5px;
|
||||
}
|
||||
.sku-bar .sku-code {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 20px; font-weight: 700; letter-spacing: -.4px;
|
||||
word-break: break-all; line-height: 1.25; min-height: 25px;
|
||||
}
|
||||
.sku-bar .sku-fixed { color: var(--primary-700); } /* 已确定片段 */
|
||||
.sku-bar .sku-tbd { color: var(--gray-300); } /* 待定占位 */
|
||||
.sku-bar .sku-prog {
|
||||
margin-top: 9px; height: 3px; border-radius: 999px;
|
||||
background: var(--primary-100); overflow: hidden;
|
||||
}
|
||||
.sku-bar .sku-prog > i {
|
||||
display: block; height: 100%; width: 0;
|
||||
background: linear-gradient(90deg, var(--primary-400), var(--primary-600));
|
||||
border-radius: 999px; transition: width .3s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
/* 零件号完整: 整块转为实心青色, 作为"完成"的强反馈 */
|
||||
.sku-bar.done {
|
||||
background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
|
||||
border-color: transparent;
|
||||
box-shadow: var(--shadow-btn);
|
||||
}
|
||||
.sku-bar.done .sku-label { color: rgba(255,255,255,.72); }
|
||||
.sku-bar.done .sku-fixed { color: #fff; }
|
||||
.sku-bar.done .sku-tbd { color: rgba(255,255,255,.5); }
|
||||
.sku-bar.done .sku-prog { background: rgba(255,255,255,.28); }
|
||||
.sku-bar.done .sku-prog > i { background: #fff; }
|
||||
.sku-bar.done .sku-hint { color: rgba(255,255,255,.85); }
|
||||
.sku-bar .sku-hint {
|
||||
margin-top: 8px; font-size: 11px; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ============================================================ 选项块 (替代下拉)
|
||||
* 有图用卡片, 无图用文字块; 一行多个, 自动换行。 */
|
||||
.opt-grid {
|
||||
display: grid; gap: 7px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
}
|
||||
.opt-grid.wide { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
|
||||
.opt {
|
||||
position: relative; cursor: pointer; user-select: none;
|
||||
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--surface); padding: 8px 9px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
min-height: 46px; justify-content: center;
|
||||
transition: transform .18s cubic-bezier(.2,.8,.3,1),
|
||||
border-color .18s, background .18s, box-shadow .18s;
|
||||
}
|
||||
.opt:hover {
|
||||
transform: translateY(-2px); border-color: var(--primary-400);
|
||||
box-shadow: 0 6px 16px rgba(20,184,166,.18);
|
||||
}
|
||||
.opt .opt-k {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 13px; font-weight: 700; color: var(--gray-900);
|
||||
line-height: 1.2; text-align: center; word-break: break-word;
|
||||
}
|
||||
.opt .opt-d {
|
||||
font-size: 10px; color: var(--text-faint); text-align: center;
|
||||
line-height: 1.25; word-break: break-word;
|
||||
}
|
||||
/* 选中态: 青色填充 */
|
||||
.opt.on {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
box-shadow: 0 0 0 2px rgba(20,184,166,.22);
|
||||
}
|
||||
.opt.on .opt-k { color: var(--primary-700); }
|
||||
.opt.on::after {
|
||||
content: '✓'; position: absolute; top: 3px; right: 5px;
|
||||
font-size: 10px; color: var(--primary-600); font-weight: 700;
|
||||
}
|
||||
/* 不可选 (被联动规则排除): 置灰 + 划线 + 不可点 */
|
||||
.opt.off {
|
||||
opacity: .32; cursor: not-allowed; background: var(--gray-50);
|
||||
border-style: dashed;
|
||||
}
|
||||
.opt.off .opt-k { text-decoration: line-through; }
|
||||
.opt.off:hover { transform: none; box-shadow: none; border-color: var(--border); }
|
||||
/* 无图时的纯文字块: 更紧凑 */
|
||||
.opt.txt { min-height: 38px; padding: 7px 8px; }
|
||||
.opt.txt .opt-k { font-size: 12.5px; }
|
||||
|
||||
/* ------------------------------------------------------------ 按钮 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user