2 Commits
v2.2 ... main

Author SHA1 Message Date
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
wangruiguo
e030b65104 v2.3: NX 路径自动探测 + 修复批处理行尾被破坏
NX 路径自动探测 (换机不用改脚本)
- 新增 tools/find-nxbin.ps1, 三级探测: 环境变量 ONEBOT_NXBIN > 项目内
  nxbin.txt > C:\Program Files\Siemens\NX*\NXBIN
- 多版本共存取版本号最大者 (实测 NX2506+NX2512 共存时正确选 2512),
  故 2506 → 2512 换机无需改任何脚本
- _start_all.bat 接入探测, 并优雅降级: 找不到 NX 只警告并跳过 worker,
  网页/GenServer 照常启动 (不再因 NX 缺失导致整个启动失败)

修复批处理行尾 (本次改动引入的回归)
- 用工具写 .bat 时默认写成 LF, 而 cmd.exe 必须用 CRLF: 裸 LF 会把
  `if not defined X (` 拆成乱码, 报 `'ned' 不是内部或外部命令` / `此时不应有 else`
- _start_all.bat / _start_web.bat 因此被破坏, 已恢复 CRLF
- 新增 tools/fix-lineend.ps1 一键检查/修正 (幂等, 仿 fix-bom.ps1);
  该检查额外抓出 2 个同样有问题的文件并一并修正:
  一键导出CRA问卷.bat / tools/build-genserver.bat (内容未变, 仅行尾)
- 新增 .gitattributes 声明 *.bat/*.cmd eol=crlf, 防止 checkout 后再被转成 LF

验证
- find-nxbin.ps1 实测探测到 C:\Program Files\Siemens\NX2506\NXBIN
- 多版本排序逻辑实测选中 2512
- 批处理行尾修复后实际运行通过
- 两个新 ps1 语法自检通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 14:00:38 +08:00
7 changed files with 513 additions and 28 deletions

7
.gitattributes vendored Normal file
View File

@@ -0,0 +1,7 @@
# 行尾约定: .bat/.cmd 交给 cmd.exe 解析, 必须是 CRLF —— 裸 LF 会让 cmd 误解析
# (踩过: 工具写文件默认 LF, 导致 `if not defined X (` 被拆成乱码命令, 批处理直接失败)。
# 下面显式声明, 避免 checkout 后又被转成 LF。
*.bat text eol=crlf
*.cmd text eol=crlf
# PowerShell 脚本走 PS 解析, 行尾不敏感, 保持平台默认
*.ps1 text

View File

@@ -27,7 +27,17 @@ start "GenServer" /min "%~dp0bin\GenServer.exe" "%~dp0genserver-local\genserver-
ping -n 3 127.0.0.1 >nul
echo [4/6] start NX worker ...
start "NXworker" /min "C:\Program Files\Siemens\NX2506\NXBIN\run_journal.exe" "%~dp0sample-data\tools\worker_journal.vb"
rem NX 路径自动探测 (环境变量 ONEBOT_NXBIN > 项目内 nxbin.txt > C:\Program Files\Siemens\NX*)
rem 多版本共存时取版本号最大者, 便于 2506/2512 换机不用改脚本
set "NXBIN="
for /f "usebackq delims=" %%i in (`powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\find-nxbin.ps1" -Quiet`) do set "NXBIN=%%i"
if not defined NXBIN (
echo [WARN] 未找到 NX ^(run_journal.exe^), 跳过 NX worker ^(网页/GenServer 不受影响^)
echo 可手动指定: set ONEBOT_NXBIN=D:\path\to\NXBIN 或写一行到 nxbin.txt
) else (
echo NX: %NXBIN%
start "NXworker" /min "%NXBIN%\run_journal.exe" "%~dp0sample-data\tools\worker_journal.vb"
)
ping -n 3 127.0.0.1 >nul
echo [5/6] start web server (port 8080, 后台无窗口) ...

80
tools/find-nxbin.ps1 Normal file
View File

@@ -0,0 +1,80 @@
# 探测 Siemens NX 的 NXBIN 目录 (含 run_journal.exe), 供启动脚本调用。
# 用法:
# powershell -NoProfile -ExecutionPolicy Bypass -File tools\find-nxbin.ps1 # 输出 NXBIN 绝对路径
# powershell ... -File tools\find-nxbin.ps1 -Quiet # 只输出路径, 失败无输出
# powershell ... -File tools\find-nxbin.ps1 -Explain # 输出探测过程
#
# 探测顺序 (先到先用):
# 1. 环境变量 ONEBOT_NXBIN —— 手动指定优先, 便于自定义安装路径
# 2. 项目内 nxbin.txt (单行路径) —— 便于把结果固化到部署机器
# 3. C:\Program Files\Siemens\NX*\NXBIN —— 官方默认安装位置, 多版本取版本号最大者
#
# 退出码: 0 = 找到 (stdout 为路径); 1 = 未找到 (stdout 为空)
param(
[switch]$Quiet,
[switch]$Explain
)
$ErrorActionPreference = 'SilentlyContinue'
function Say($m) { if (-not $Quiet) { Write-Output $m } }
function Test-NxBin($dir) {
if ([string]::IsNullOrWhiteSpace($dir)) { return $false }
return Test-Path (Join-Path $dir 'run_journal.exe')
}
# 1) 环境变量
$env_dir = $env:ONEBOT_NXBIN
if ($env_dir) {
Say ("[1] 环境变量 ONEBOT_NXBIN = " + $env_dir)
if (Test-NxBin $env_dir) { Write-Output $env_dir; exit 0 }
Say " 该目录下没有 run_journal.exe, 继续探测"
} else {
Say "[1] 环境变量 ONEBOT_NXBIN 未设置"
}
# 2) 项目内 nxbin.txt
$root = Split-Path $PSScriptRoot -Parent
$cfg = Join-Path $root 'nxbin.txt'
if (Test-Path $cfg) {
$line = (Get-Content $cfg -TotalCount 1)
if ($line) { $line = $line.Trim() }
Say ("[2] nxbin.txt = " + $line)
if (Test-NxBin $line) { Write-Output $line; exit 0 }
Say " 该目录下没有 run_journal.exe, 继续探测"
} else {
Say "[2] 无 nxbin.txt"
}
# 3) 扫描官方安装位置 (多版本取版本号最大的)
$roots = @(
(Join-Path ${env:ProgramFiles} 'Siemens'),
(Join-Path ${env:ProgramFiles(x86)} 'Siemens'),
'C:\Program Files\Siemens'
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -Unique
$cands = @()
foreach ($r in $roots) {
Say ("[3] 扫描 " + $r)
foreach ($ver in (Get-ChildItem $r -Directory -Filter 'NX*' -ErrorAction SilentlyContinue)) {
$bin = Join-Path $ver.FullName 'NXBIN'
if (Test-NxBin $bin) {
$cands += $bin
Say (" 找到 " + $bin)
}
}
}
if ($cands.Count -gt 0) {
# 按 NX 后面的版本号排序取最大 (如 NX2506 < NX2512); 无版本号的排最后
$best = $cands | Sort-Object {
$m = [regex]::Match($_, 'NX(\d+)')
if ($m.Success) { [long]$m.Groups[1].Value } else { 0 }
} -Descending | Select-Object -First 1
Write-Output $best
exit 0
}
Say "未找到 NX (含 run_journal.exe 的 NXBIN 目录)"
Say "可手动指定: set ONEBOT_NXBIN=D:\path\to\NXBIN 或 写一行到 nxbin.txt"
exit 1

36
tools/fix-lineend.ps1 Normal file
View File

@@ -0,0 +1,36 @@
# 把 .bat/.cmd 的行尾统一为 CRLF —— cmd.exe 必须用 CRLF, 裸 LF 会让它误解析。
# 症状: `if not defined X (` 被拆成 `'ned' 不是内部或外部命令` 之类的乱码报错。
# 触发场景: 用工具/编辑器写 .bat 时默认写成 LF (本项目踩过)。
#
# 幂等: 已是 CRLF 的文件不会被改动。
# 用法:
# powershell -NoProfile -ExecutionPolicy Bypass -File tools\fix-lineend.ps1 # 处理项目根 + tools 下全部 .bat/.cmd
# powershell ... -File tools\fix-lineend.ps1 -Files "a.bat","b.cmd" # 只处理指定文件
param([string[]]$Files)
if (-not $Files) {
$root = Split-Path $PSScriptRoot -Parent
$Files = @()
$Files += Get-ChildItem (Join-Path $root '*.bat') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
$Files += Get-ChildItem (Join-Path $root '*.cmd') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
$Files += Get-ChildItem (Join-Path $PSScriptRoot '*.bat') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
$Files += Get-ChildItem (Join-Path $PSScriptRoot '*.cmd') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
}
foreach ($f in $Files) {
if (-not (Test-Path $f)) { Write-Output ("跳过(不存在): " + $f); continue }
$bytes = [System.IO.File]::ReadAllBytes($f)
# 统一成 CRLF: 先把 CRLF 归一为 LF, 再把 LF 展开为 CRLF
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
$text = $text -replace "`r`n", "`n"
$text = $text -replace "`r", "`n"
$new = $text -replace "`n", "`r`n"
if ($new -eq [System.Text.Encoding]::UTF8.GetString($bytes)) {
Write-Output ("已是 CRLF: " + (Split-Path $f -Leaf))
} else {
# 不加 BOM (cmd 对 BOM 敏感, 且 .bat 无需 BOM)
$enc = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($f, $new, $enc)
Write-Output ("已修正为 CRLF: " + (Split-Path $f -Leaf))
}
}

View File

@@ -246,6 +246,7 @@
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 +
@@ -253,6 +254,67 @@
});
}
// ---------- 深度链接 (供 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 = {
@@ -588,11 +650,148 @@
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); }
@@ -772,8 +971,15 @@
if (a.kind === 'dimDrawing' && (a.model === v.modelCode || att === null)) att = a;
});
if (att) {
$('dimImage').src = att.path;
$('dimImage').classList.remove('hidden');
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');
}
// 数据表
@@ -781,9 +987,30 @@
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');
// 数据表 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');
}
@@ -1508,7 +1735,14 @@
$('cartDownload').textContent = t('下载全部 (zip + BOM)', 'Download All (zip + BOM)');
renderCartBadge();
$('btnDownload').textContent = t('下载 STEP', 'Download STEP');
$('btnDatasheet').textContent = t('数据表 PDF', 'Datasheet PDF');
// ⚠️ 数据表按钮带图标(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');
@@ -1576,10 +1810,14 @@
}
// ---- 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';
// 部署自适应: 原来硬编码 '/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');
@@ -1588,8 +1826,8 @@
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';
// 模型路径同样要带 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 () {

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OneBot数模下载</title>
<link rel="stylesheet" href="style.css?v=11">
<link rel="stylesheet" href="style.css?v=12">
</head>
<body>
<header>
@@ -43,6 +43,16 @@
<div class="sku-hint" id="skuHint"></div>
</div>
<div id="paramPanel"></div>
</section>
<div id="split2" class="split hidden" title="拖拽调整宽度"></div>
<section id="content" class="content hidden">
<div class="tabs" id="tabsBar">
<button class="tab active" data-tab="3d">3D / 尺寸图</button>
<button class="tab" data-tab="table">参数表</button>
<button class="tab" data-tab="search">搜索结果</button>
<button class="tab" data-tab="cross">平替对照</button>
<span class="tabs-spacer"></span>
<div id="actions">
<a id="btnDownload" class="btn primary hidden" download>下载 STEP</a>
<select id="btnGenFmt" class="hidden" title="下载格式 (仅非标档位在线生成)">
@@ -53,15 +63,6 @@
<a id="btnDatasheet" class="btn hidden">下载 PDF</a>
<button id="btnAddCart" class="btn hidden">加入清单</button>
</div>
</section>
<div id="split2" class="split hidden" title="拖拽调整宽度"></div>
<section id="content" class="content hidden">
<div class="tabs">
<button class="tab active" data-tab="3d">3D / 尺寸图</button>
<button class="tab" data-tab="table">参数表</button>
<button class="tab" data-tab="search">搜索结果</button>
<button class="tab" data-tab="cross">平替对照</button>
</div>
<div class="content-back">
<button id="btnBackHome" class="btn">← 返回系列总览</button>
@@ -122,6 +123,6 @@
<script src="libs/jszip.min.js?v=1"></script>
<script src="config.js?v=1"></script>
<script src="app.js?v=18"></script>
<script src="app.js?v=19"></script>
</body>
</html>

View File

@@ -265,7 +265,16 @@ main { flex: 1; display: flex; min-height: 0; }
.param-row select:focus, .param-input:focus {
border-color: var(--primary-500); box-shadow: 0 0 0 3px rgba(20,184,166,.15);
}
#actions { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
/* #actions 原在左侧参数面板底部,现移到 tab 行右侧(与 3D/尺寸图 等同一行)。
元素 id 未变,JS 按 id 取用,位置移动不影响逻辑。 */
#actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* tab 行的弹性占位:把 #actions 推到最右 */
.tabs-spacer { flex: 1; min-width: 8px; }
/* 窄屏时按钮换行到下一行,避免挤压 tab */
@media (max-width: 1100px) {
.tabs { flex-wrap: wrap; }
.tabs-spacer { flex-basis: 100%; height: 0; }
}
/* ============================================================ 零件号实时条 (DESTACO 风)
* 边选边拼: 已确定部分实心高亮, 未定部分虚化占位。 */
@@ -387,10 +396,114 @@ main { flex: 1; display: flex; min-height: 0; }
.hidden { display: none !important; }
/* ------------------------------------------------------------ 嵌入模式
供 Odoo 产品页 iframe 使用 (?embed=1):隐藏工具自身的顶栏与底栏,
避免在网站页面里出现"页中页",同时把高度让给内容区。
由 app.js 的 applyDeepLink() 给 body 加 .embed-mode 触发。 */
body.embed-mode header,
body.embed-mode footer { display: none !important; }
body.embed-mode { height: 100vh; }
/* 隐藏模式 (?nav=0):嵌入 Odoo 产品页时用。
产品页已经决定了是哪个系列,左侧系列树、分隔条、「返回系列总览」纯属多余,
隐藏后让参数面板 + 内容区铺满整宽,观感更干净。 */
body.nav-off #tree,
body.nav-off #split1,
body.nav-off .content-back { display: none !important; }
/* 隐藏侧栏后,参数面板适当加宽(原来 330px 是因为旁边还有树+主区抢空间) */
body.nav-off .params { width: 300px; }
/* ------------------------------------------------------------ 内容区 / 标签页 */
.content { flex: 1; display: flex; flex-direction: column; min-width: 0; padding: 14px; }
.tabs { display: flex; gap: 6px; flex: none; }
.tabs { display: flex; gap: 6px; flex: none; align-items: center; }
.content-back { flex: none; margin: 10px 0 0; }
/* ------------------------------------------------------------ 手册页图片放大
点击手册页图片 → 全屏遮罩显示大图。点遮罩任意处 / 按 Esc 关闭。
用 position:fixed 覆盖整个视口(含 iframe 内嵌场景)。 */
.manual-img { cursor: zoom-in; }
/* 数据表按钮图标(内联 SVG,工具无图标字体) */
.ds-ico { width: 15px; height: 15px; flex: none; vertical-align: -3px; }
.ds-ico-eye { opacity: .65; }
#btnDatasheet { display: inline-flex; align-items: center; gap: 6px; }
#btnDatasheet:hover .ds-ico-eye { opacity: 1; }
#btnDatasheet .ds-txt { white-space: nowrap; }
.img-zoom-overlay {
position: fixed; inset: 0; z-index: 900;
background: rgba(17, 24, 39, .88);
display: flex; align-items: center; justify-content: center;
padding: 24px; cursor: zoom-out;
animation: imgZoomIn .16s ease-out;
}
@keyframes imgZoomIn { from { opacity: 0; } to { opacity: 1; } }
.img-zoom-overlay img {
max-width: 100%; max-height: 100%;
object-fit: contain; border-radius: 6px;
box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
}
.img-zoom-close {
position: absolute; top: 14px; right: 18px;
width: 40px; height: 40px; border: 0; border-radius: 50%;
background: rgba(255, 255, 255, .16); color: #fff;
font-size: 22px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .18s;
}
.img-zoom-close:hover { background: rgba(255, 255, 255, .3); }
.img-zoom-cap {
position: absolute; bottom: 14px; left: 0; right: 0;
text-align: center; color: rgba(255, 255, 255, .75); font-size: 12.5px;
pointer-events: none;
}
/* ------------------------------------------------------------ 数据表 PDF 弹窗预览
点击「数据表」→ 弹窗内用浏览器内置 PDF 阅读器打开,标题栏含下载入口。 */
.pdf-zoom-overlay {
position: fixed; inset: 0; z-index: 910;
background: rgba(17, 24, 39, .72);
display: flex; align-items: center; justify-content: center;
padding: 24px;
animation: imgZoomIn .16s ease-out;
}
.pdf-zoom-box {
width: min(1100px, 100%); height: min(92vh, 100%);
display: flex; flex-direction: column;
background: var(--surface, #fff);
border-radius: 10px; overflow: hidden;
box-shadow: 0 16px 56px rgba(0, 0, 0, .45);
}
.pdf-zoom-head {
flex: none; display: flex; align-items: center; gap: 12px;
padding: 10px 12px 10px 16px;
border-bottom: 1px solid var(--border, #e5e7eb);
background: var(--surface, #fff);
}
.pdf-zoom-title {
flex: 1; font-size: 13px; font-weight: 600; color: var(--gray-900, #111827);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pdf-zoom-dl {
flex: none; font-size: 12.5px; text-decoration: none;
padding: 6px 14px; border-radius: 6px;
background: var(--primary, #14b8a6); color: #fff;
transition: filter .18s;
}
.pdf-zoom-dl:hover { filter: brightness(.94); }
.pdf-zoom-close {
position: static; flex: none;
width: 32px; height: 32px; font-size: 18px;
background: var(--gray-100, #f3f4f6); color: var(--gray-700, #374151);
}
.pdf-zoom-close:hover { background: var(--gray-200, #e5e7eb); }
.pdf-zoom-frame {
flex: 1; width: 100%; border: 0; background: #525659;
}
@media (max-width: 767.98px) {
.pdf-zoom-overlay { padding: 0; }
.pdf-zoom-box { width: 100%; height: 100%; border-radius: 0; }
}
.tab {
height: 36px; padding: 0 16px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--surface);