Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f343b26444 | ||
|
|
e030b65104 | ||
|
|
e94d2aebc9 |
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,3 +53,4 @@ onebot-data/tools/_scan_cache.json
|
||||
__pycache__/
|
||||
.probe/
|
||||
_iconprev/
|
||||
.serve-web.pid
|
||||
|
||||
@@ -27,12 +27,22 @@ 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) ...
|
||||
start "OnebotWeb" /min powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\serve-web.ps1"
|
||||
ping -n 4 127.0.0.1 >nul
|
||||
echo [5/6] start web server (port 8080, 后台无窗口) ...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\serve-web-bg.ps1"
|
||||
ping -n 2 127.0.0.1 >nul
|
||||
|
||||
echo [6/6] health check ...
|
||||
curl.exe -s http://localhost:8899/health
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ==== start web server (default root = release\web_2026.08, port 8080) ====
|
||||
start "OnebotWeb" powershell -NoProfile -ExecutionPolicy Bypass -File "tools\serve-web.ps1"
|
||||
echo ==== 启动网页预览服务器 (后台运行, 无 PowerShell 窗口) ====
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "tools\serve-web-bg.ps1" -Root "release\web_2026.09"
|
||||
|
||||
timeout /t 4 /nobreak >nul
|
||||
|
||||
echo ==== check web (8080) + genserver (8899) ====
|
||||
curl.exe -s -o nul -w "web : %%{http_code}\n" http://localhost:8080/index.html
|
||||
curl.exe -s http://localhost:8899/health
|
||||
echo.
|
||||
echo ==== 检查服务 ====
|
||||
curl.exe -s -o nul -w "web : %%{http_code}\n" http://localhost:8080/index.html
|
||||
|
||||
echo ==== done. browser: http://localhost:8080 ====
|
||||
echo (keep the OnebotWeb window open; close it to stop the server)
|
||||
echo.
|
||||
echo 浏览器访问: http://localhost:8080
|
||||
echo 停止服务: powershell -File tools\serve-web-bg.ps1 -Stop
|
||||
echo 查看状态: powershell -File tools\serve-web-bg.ps1 -Status
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
76
onebot-data/tools/assess-combo-risk.py
Normal file
76
onebot-data/tools/assess-combo-risk.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用【同缸径的真实数据值域】作为官方值域的代理, 评估生成候选的保守程度。
|
||||
|
||||
为什么用代理: 官方图册是双栏排版, pdftotext 后左右栏交错, 正则解析多次尝试均不可靠
|
||||
(把真实数据都判成越界 = 解析错误)。而【真实型号】是最可靠的一手证据:
|
||||
某缸径的真实型号里从未出现过的取值, 生成它就是推测。
|
||||
|
||||
判定口径 (保守):
|
||||
某候选的每个参数值, 是否都在【同缸径的真实型号】中出现过?
|
||||
- 全部出现过 → 属"组合缺失", 补全风险低
|
||||
- 有值从未出现 → 属"引入新值", 补全风险高 (需人工/AI 核对官方图册)
|
||||
"""
|
||||
import csv, io, os, collections
|
||||
|
||||
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||||
DIMS = ['stroke', 'rod', 'orient', 'sensor', 'mount', 'style', 'pos', 'type']
|
||||
|
||||
|
||||
def main():
|
||||
rows = list(csv.DictReader(io.open(os.path.join(CAT, 'csv', 'ULP.csv'), encoding='utf-8-sig')))
|
||||
real = [r for r in rows if r['step_file'] != 'PLACEHOLDER.step']
|
||||
gen = [r for r in rows if r['step_file'] == 'PLACEHOLDER.step']
|
||||
dims = [d for d in DIMS if d in rows[0]]
|
||||
|
||||
# 每个缸径的真实取值集合
|
||||
domain = collections.defaultdict(lambda: collections.defaultdict(set))
|
||||
for r in real:
|
||||
for d in dims:
|
||||
if r[d] != '':
|
||||
domain[r['size']][d].add(r[d])
|
||||
|
||||
print('=' * 74)
|
||||
print('真实数据里各缸径的取值域 (作为官方值域的代理)')
|
||||
print('=' * 74)
|
||||
for b in sorted(domain, key=int):
|
||||
parts = [f'{d}={sorted(domain[b][d])}' for d in dims if domain[b][d]]
|
||||
n = sum(1 for r in real if r['size'] == b)
|
||||
print(f' ULP{b:>3s} ({n:3d} 个真实型号)')
|
||||
for p in parts:
|
||||
print(f' {p}')
|
||||
|
||||
# 评估生成候选
|
||||
safe, risky = 0, 0
|
||||
risky_detail = collections.Counter()
|
||||
risky_models = []
|
||||
for r in gen:
|
||||
b = r['size']
|
||||
novel = [f'{d}={r[d]}' for d in dims
|
||||
if r[d] != '' and r[d] not in domain.get(b, {}).get(d, set())]
|
||||
if novel:
|
||||
risky += 1
|
||||
for x in novel:
|
||||
risky_detail[f'ULP{b} {x}'] += 1
|
||||
if len(risky_models) < 5:
|
||||
risky_models.append((r['model_code'], novel))
|
||||
else:
|
||||
safe += 1
|
||||
|
||||
print()
|
||||
print('=' * 74)
|
||||
print(f'生成候选 {len(gen)} 个的保守度评估')
|
||||
print('=' * 74)
|
||||
print(f' 低风险 (全部取值在同缸径真实数据中出现过): {safe} ({safe*100//len(gen)}%)')
|
||||
print(f' 高风险 (含该缸径从未出现的取值) : {risky} ({risky*100//len(gen)}%)')
|
||||
if risky_detail:
|
||||
print('\n 高风险取值 TOP15 (该缸径真实数据里从未出现):')
|
||||
for k, v in risky_detail.most_common(15):
|
||||
print(f' {k:28s} {v:5d} 个候选')
|
||||
print('\n 高风险样本:')
|
||||
for mc, novel in risky_models:
|
||||
print(f' {mc:18s} 新值 {novel}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
109
onebot-data/tools/make-pending-list.py
Normal file
109
onebot-data/tools/make-pending-list.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
重写待补数模清单, 增加「核对」相关列, 便于 AI 逐个核实后补真实数模。
|
||||
|
||||
背景: v2.1 按编码模板外推生成了 3157 个组合, 但它们是【候选代号】而非已确认的产品 ——
|
||||
规律在已有型号上 100% 成立, 外推区域是否有真实产品未经核实。
|
||||
本清单把每个候选与其"最近的真实型号"并列, 便于逐个比对核实。
|
||||
|
||||
输出列:
|
||||
series 系列码
|
||||
model_code 候选零件号 (已进目录, 选型可选)
|
||||
params 完整参数
|
||||
nearest_real 同系列中最接近的【真实数模】型号 (按参数相同个数最多, 平手取编码最近)
|
||||
diff 与 nearest_real 不同的参数 (供人工/AI 判断该差异是否真实存在)
|
||||
step_file 当前指向 PLACEHOLDER.step; 补完真实数模后改为 <候选型号>.step
|
||||
status 固定为 pending (待核对+待补数模)
|
||||
|
||||
用法: PYTHONUTF8=1 python make-pending-list.py
|
||||
"""
|
||||
import csv, io, os, json, glob, collections
|
||||
|
||||
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||||
OUT = os.path.join(CAT, '_pending_models.csv')
|
||||
PLACEHOLDER = 'PLACEHOLDER.step'
|
||||
|
||||
|
||||
def load_real_models():
|
||||
"""返回 {系列: [(model_code, {参数})]} —— 只含指向真实数模的型号。"""
|
||||
real = {}
|
||||
for f in glob.glob(os.path.join(CAT, 'csv', '*.csv')):
|
||||
code = os.path.basename(f)[:-4]
|
||||
rows = []
|
||||
for r in csv.DictReader(io.open(f, encoding='utf-8-sig')):
|
||||
if r.get('step_file') == PLACEHOLDER:
|
||||
continue # 跳过占位行
|
||||
prm = {k: v for k, v in r.items() if k not in ('model_code', 'step_file')}
|
||||
rows.append((r['model_code'], prm))
|
||||
if rows:
|
||||
real[code] = rows
|
||||
return real
|
||||
|
||||
|
||||
def dist(params_a, params_b):
|
||||
"""参数相同个数 (越大越接近)。"""
|
||||
keys = set(params_a) | set(params_b)
|
||||
return sum(1 for k in keys if str(params_a.get(k, '')) == str(params_b.get(k, '')))
|
||||
|
||||
|
||||
def main():
|
||||
real = load_real_models()
|
||||
total_real = sum(len(v) for v in real.values())
|
||||
print(f'真实数模型号: {total_real} 个, 覆盖 {len(real)} 个系列')
|
||||
|
||||
# 收集候选项
|
||||
pending = []
|
||||
for f in sorted(glob.glob(os.path.join(CAT, 'csv', '*.csv'))):
|
||||
code = os.path.basename(f)[:-4]
|
||||
for r in csv.DictReader(io.open(f, encoding='utf-8-sig')):
|
||||
if r.get('step_file') != PLACEHOLDER:
|
||||
continue
|
||||
prm = {k: v for k, v in r.items() if k not in ('model_code', 'step_file')}
|
||||
pending.append((code, r['model_code'], prm))
|
||||
|
||||
print(f'待补候选: {len(pending)} 个')
|
||||
|
||||
rows = []
|
||||
for code, mc, prm in pending:
|
||||
cands = real.get(code) or []
|
||||
near, ndiff = '', ''
|
||||
if cands:
|
||||
# 最接近的真实型号: 参数相同个数最多; 平手取编码字典序 (稳定)
|
||||
best = max(cands, key=lambda x: (dist(prm, x[1]), -len(x[0])))
|
||||
near = best[0]
|
||||
diffs = [f'{k}={prm.get(k, "")}≠{best[1].get(k, "")}'
|
||||
for k in sorted(set(prm) | set(best[1]))
|
||||
if str(prm.get(k, '')) != str(best[1].get(k, ''))]
|
||||
ndiff = '; '.join(diffs)
|
||||
rows.append([code, mc,
|
||||
' '.join(f'{k}={v}' for k, v in prm.items()),
|
||||
near, ndiff, PLACEHOLDER, 'pending'])
|
||||
|
||||
with io.open(OUT, 'w', encoding='utf-8-sig', newline='') as fp:
|
||||
w = csv.writer(fp)
|
||||
w.writerow(['series', 'model_code', 'params', 'nearest_real',
|
||||
'diff_from_nearest', 'step_file', 'status'])
|
||||
w.writerows(rows)
|
||||
print(f'\n写入 {OUT} ({len(rows)} 行)')
|
||||
|
||||
# 覆盖度: 多少候选能找到同系列真实型号作参照
|
||||
withref = sum(1 for r in rows if r[3])
|
||||
print(f' 有同系列真实型号可参照: {withref}/{len(rows)}')
|
||||
nop = [r[0] for r in rows if not r[3]]
|
||||
if nop:
|
||||
print(f' 无参照的系列: {sorted(set(nop))}')
|
||||
|
||||
# 按系列 + 缺失维度统计
|
||||
print('\n各系列待补 (按"与最近真实型号差异的维度数"分布):')
|
||||
bycat = collections.defaultdict(collections.Counter)
|
||||
for r in rows:
|
||||
n = len([x for x in r[4].split('; ') if x]) if r[4] else -1
|
||||
bycat[r[0]][n] += 1
|
||||
for code in sorted(bycat):
|
||||
dists = bycat[code]
|
||||
parts = ', '.join(f'{k}维差:{v}' for k, v in sorted(dists.items()) if k >= 0)
|
||||
print(f' {code:6s} 共{sum(dists.values()):5d} {parts}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -36,6 +36,118 @@ namespace Ounibo.Catalog.App
|
||||
public override string ToString() { return Text; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选项块 (DESTACO 风, 2026-09-15): 替代下拉菜单的单值可选卡片。
|
||||
/// 三态: 可选 / 已选 / 不可选 (置灰划线但保留占位, 让用户看到选项存在)。
|
||||
/// </summary>
|
||||
public class OptionTile : Border
|
||||
{
|
||||
public object Value;
|
||||
public ParameterDef Param;
|
||||
readonly TextBlock _code;
|
||||
readonly TextBlock _desc;
|
||||
bool _usable = true, _on;
|
||||
|
||||
static readonly Brush BorderIdle = new SolidColorBrush(Color.FromRgb(0xE5, 0xE7, 0xEB));
|
||||
static readonly Brush BorderHover = new SolidColorBrush(Color.FromRgb(0x2D, 0xD4, 0xBF));
|
||||
static readonly Brush BorderOn = new SolidColorBrush(Color.FromRgb(0x14, 0xB8, 0xA6));
|
||||
static readonly Brush BgIdle = Brushes.White;
|
||||
static readonly Brush BgHover = new SolidColorBrush(Color.FromRgb(0xF0, 0xFD, 0xFA));
|
||||
static readonly Brush BgOn = new SolidColorBrush(Color.FromRgb(0xF0, 0xFD, 0xFA));
|
||||
static readonly Brush BgOff = new SolidColorBrush(Color.FromRgb(0xF9, 0xFA, 0xFB));
|
||||
static readonly Brush FgCodeIdle = new SolidColorBrush(Color.FromRgb(0x11, 0x18, 0x27));
|
||||
static readonly Brush FgCodeOn = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E));
|
||||
|
||||
public OptionTile(ParameterDef p, object value, string code, string desc, Action<OptionTile> onClick)
|
||||
{
|
||||
Param = p; Value = value;
|
||||
BorderThickness = new Thickness(1.5);
|
||||
CornerRadius = new CornerRadius(10);
|
||||
Padding = new Thickness(9, 7, 9, 7);
|
||||
Margin = new Thickness(0, 0, 6, 6);
|
||||
MinWidth = 58;
|
||||
Cursor = Cursors.Hand;
|
||||
Background = BgIdle;
|
||||
BorderBrush = BorderIdle;
|
||||
|
||||
var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||||
_code = new TextBlock
|
||||
{
|
||||
Text = code,
|
||||
FontFamily = new FontFamily("Consolas, monospace"),
|
||||
FontWeight = FontWeights.Bold,
|
||||
FontSize = 13,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
};
|
||||
sp.Children.Add(_code);
|
||||
if (!string.IsNullOrEmpty(desc) && desc != code)
|
||||
{
|
||||
_desc = new TextBlock
|
||||
{
|
||||
Text = desc,
|
||||
FontSize = 10,
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxWidth = 120,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF))
|
||||
};
|
||||
sp.Children.Add(_desc);
|
||||
}
|
||||
Child = sp;
|
||||
|
||||
MouseEnter += (s, e) => { if (_usable && !_on) { BorderBrush = BorderHover; Background = BgHover; } };
|
||||
MouseLeave += (s, e) => { if (_usable && !_on) { BorderBrush = BorderIdle; Background = BgIdle; } };
|
||||
MouseLeftButtonUp += (s, e) => { if (_usable) onClick(this); };
|
||||
}
|
||||
|
||||
/// <summary>设置可用性 (被联动规则排除时置灰但不移除)。</summary>
|
||||
public void SetUsable(bool usable)
|
||||
{
|
||||
_usable = usable;
|
||||
Cursor = usable ? Cursors.Hand : Cursors.Arrow;
|
||||
if (!usable)
|
||||
{
|
||||
Background = BgOff; BorderBrush = BorderIdle;
|
||||
BorderThickness = new Thickness(1.5);
|
||||
_code.Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF));
|
||||
_code.TextDecorations = TextDecorations.Strikethrough;
|
||||
Opacity = 0.4;
|
||||
if (_desc != null) _desc.Opacity = 0.7;
|
||||
}
|
||||
else
|
||||
{
|
||||
Opacity = 1;
|
||||
_code.TextDecorations = null;
|
||||
if (_desc != null) _desc.Opacity = 1;
|
||||
ApplyState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>设置选中态。</summary>
|
||||
public void SetOn(bool on)
|
||||
{
|
||||
_on = on;
|
||||
if (_usable) ApplyState();
|
||||
}
|
||||
|
||||
void ApplyState()
|
||||
{
|
||||
if (_on)
|
||||
{
|
||||
Background = BgOn; BorderBrush = BorderOn;
|
||||
_code.Foreground = FgCodeOn;
|
||||
}
|
||||
else
|
||||
{
|
||||
Background = BgIdle; BorderBrush = BorderIdle;
|
||||
_code.Foreground = FgCodeIdle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主窗口: 目录树 + 参数选型 + 3D 预览 + 尺寸图/参数表/数据表 + 搜索 + STEP 下载/批量下载。
|
||||
/// </summary>
|
||||
@@ -48,6 +160,12 @@ namespace Ounibo.Catalog.App
|
||||
Series _series;
|
||||
readonly Dictionary<string, object> _sel = new Dictionary<string, object>();
|
||||
readonly Dictionary<string, ComboBox> _boxes = new Dictionary<string, ComboBox>();
|
||||
// 选项块 (DESTACO 风, 2026-09-15): 每个参数一组可点选项块, 替代下拉菜单
|
||||
readonly Dictionary<string, List<OptionTile>> _tiles = new Dictionary<string, List<OptionTile>>();
|
||||
readonly Dictionary<string, WrapPanel> _tilePanels = new Dictionary<string, WrapPanel>();
|
||||
TextBlock _skuCode, _skuHint; // 零件号实时条
|
||||
Border _skuBar;
|
||||
ProgressBar _skuProg;
|
||||
readonly Dictionary<string, double?[]> _ranges = new Dictionary<string, double?[]>();
|
||||
readonly Dictionary<string, TextBox[]> _rangeBoxes = new Dictionary<string, TextBox[]>();
|
||||
List<SearchEngine.SearchItem> _searchResult;
|
||||
@@ -165,6 +283,53 @@ namespace Ounibo.Catalog.App
|
||||
var left = new StackPanel { Margin = new Thickness(8) };
|
||||
_modelTitle = new TextBlock { FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 8), TextWrapping = TextWrapping.Wrap };
|
||||
left.Children.Add(_modelTitle);
|
||||
|
||||
// 零件号实时条 (DESTACO 风, 2026-09-15): 边选边拼, 已确定片段高亮 / 待定虚化
|
||||
_skuBar = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(12),
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = new SolidColorBrush(Color.FromRgb(0x99, 0xF6, 0xE4)),
|
||||
Background = new LinearGradientBrush(
|
||||
Color.FromRgb(0xF0, 0xFD, 0xFA), Colors.White, 45),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Margin = new Thickness(0, 0, 0, 10)
|
||||
};
|
||||
var skuInner = new StackPanel();
|
||||
skuInner.Children.Add(new TextBlock
|
||||
{
|
||||
Text = _lang.T("零件号 Part Number", "Part Number"),
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
|
||||
Margin = new Thickness(0, 0, 0, 4)
|
||||
});
|
||||
_skuCode = new TextBlock
|
||||
{
|
||||
FontFamily = new FontFamily("Consolas, monospace"),
|
||||
FontSize = 17, FontWeight = FontWeights.Bold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MinHeight = 22
|
||||
};
|
||||
skuInner.Children.Add(_skuCode);
|
||||
_skuProg = new ProgressBar
|
||||
{
|
||||
Height = 3, Margin = new Thickness(0, 7, 0, 0),
|
||||
Minimum = 0, Maximum = 1, Value = 0,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x14, 0xB8, 0xA6)),
|
||||
Background = new SolidColorBrush(Color.FromRgb(0xCC, 0xFB, 0xF1)),
|
||||
BorderThickness = new Thickness(0)
|
||||
};
|
||||
skuInner.Children.Add(_skuProg);
|
||||
_skuHint = new TextBlock
|
||||
{
|
||||
FontSize = 11, Margin = new Thickness(0, 6, 0, 0),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80))
|
||||
};
|
||||
skuInner.Children.Add(_skuHint);
|
||||
_skuBar.Child = skuInner;
|
||||
left.Children.Add(_skuBar);
|
||||
|
||||
_paramPanel = new StackPanel();
|
||||
left.Children.Add(_paramPanel); // 参数最多 7 行, 直接平铺不滚动 (避免下方参数被遮挡)
|
||||
// 下载操作区 (2026-08-24, 与网页版一致: 参数下方并排 下载 STEP / 下载 PDF, 同款配色)
|
||||
@@ -494,6 +659,8 @@ namespace Ounibo.Catalog.App
|
||||
_series = null; _cfg = null;
|
||||
_paramPanel.Children.Clear();
|
||||
_boxes.Clear(); _sel.Clear();
|
||||
_tiles.Clear(); _tilePanels.Clear();
|
||||
if (_skuBar != null) _skuBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
void TreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
|
||||
@@ -512,26 +679,67 @@ namespace Ounibo.Catalog.App
|
||||
_cfg = new Configurator(s);
|
||||
_sel.Clear();
|
||||
_boxes.Clear();
|
||||
_tiles.Clear();
|
||||
_tilePanels.Clear();
|
||||
_paramPanel.Children.Clear();
|
||||
_ranges.Clear(); _rangeBoxes.Clear(); _rangePanel.Children.Clear();
|
||||
if (_skuBar != null) _skuBar.Visibility = Visibility.Visible;
|
||||
|
||||
foreach (var p in s.parameters)
|
||||
{
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
|
||||
row.Children.Add(new TextBlock { Text = ParamLabel(p), Width = 120, VerticalAlignment = VerticalAlignment.Center });
|
||||
var cb = new ComboBox { Width = 150, Tag = p };
|
||||
var block = new StackPanel { Margin = new Thickness(0, 0, 0, 10) };
|
||||
// 维度标题: 小号大写 + 已选值回显
|
||||
var head = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 5) };
|
||||
head.Children.Add(new TextBlock
|
||||
{
|
||||
Text = ParamLabel(p).ToUpperInvariant(),
|
||||
FontSize = 10.5, FontWeight = FontWeights.Bold,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
});
|
||||
var picked = new TextBlock
|
||||
{
|
||||
FontSize = 11.5, FontWeight = FontWeights.SemiBold,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E)),
|
||||
Margin = new Thickness(7, 0, 0, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
picked.Tag = p.code; // 供 RefreshOptions 回显
|
||||
head.Children.Add(picked);
|
||||
block.Children.Add(head);
|
||||
|
||||
if (p.type == "range")
|
||||
{
|
||||
// 参数化 range 域: 可编辑下拉 (标准档位快捷选 + 自由输入, 与网页版一致)
|
||||
cb.IsEditable = true;
|
||||
cb.IsTextSearchEnabled = false;
|
||||
// 连续域无法枚举成选项块 → 保留可编辑下拉 (标准档位 + 自由输入)
|
||||
var cb = new ComboBox { Width = 150, Tag = p, IsEditable = true, IsTextSearchEnabled = false, HorizontalAlignment = HorizontalAlignment.Left };
|
||||
cb.KeyDown += (s2, e2) => { if (e2.Key == System.Windows.Input.Key.Enter) TrySetRangeText(cb); };
|
||||
cb.LostKeyboardFocus += (s2, e2) => TrySetRangeText(cb);
|
||||
cb.SelectionChanged += ParamChanged;
|
||||
block.Children.Add(cb);
|
||||
_boxes[p.code] = cb;
|
||||
}
|
||||
cb.SelectionChanged += ParamChanged;
|
||||
row.Children.Add(cb);
|
||||
_paramPanel.Children.Add(row);
|
||||
_boxes[p.code] = cb;
|
||||
else
|
||||
{
|
||||
// 枚举维度 → 选项块网格 (有说明显示说明, 无说明只显示代号)
|
||||
var wrap = new WrapPanel();
|
||||
var list = new List<OptionTile>();
|
||||
foreach (var v in (p.values ?? new List<object>()))
|
||||
{
|
||||
var key = v == null ? "" : Convert.ToString(v, CultureInfo.InvariantCulture);
|
||||
var disp = DisplayText(p, v);
|
||||
bool hasDesc = !string.IsNullOrEmpty(disp) && disp != key;
|
||||
var tile = new OptionTile(p, v,
|
||||
hasDesc ? (key.Length == 0 ? "—" : key) : disp,
|
||||
hasDesc ? disp : null,
|
||||
OnTileClick);
|
||||
list.Add(tile);
|
||||
wrap.Children.Add(tile);
|
||||
}
|
||||
block.Children.Add(wrap);
|
||||
_tilePanels[p.code] = wrap;
|
||||
_tiles[p.code] = list;
|
||||
}
|
||||
_paramPanel.Children.Add(block);
|
||||
|
||||
// 数值/range 参数 → 参数表范围筛选控件
|
||||
if (p.type == "number" || p.type == "range")
|
||||
@@ -598,6 +806,116 @@ namespace Ounibo.Catalog.App
|
||||
RefreshOptions();
|
||||
}
|
||||
|
||||
/// <summary>选项块点击: 切换选择 (再点一次取消); 与下拉时代语义一致。</summary>
|
||||
void OnTileClick(OptionTile tile)
|
||||
{
|
||||
if (_refreshing || _cfg == null) return;
|
||||
var code = tile.Param.code;
|
||||
if (_sel.ContainsKey(code) && EqStr(_sel[code], tile.Value)) _sel.Remove(code);
|
||||
else _sel[code] = tile.Value;
|
||||
RefreshOptions();
|
||||
}
|
||||
|
||||
// ---------- 零件号实时拼装 (DESTACO 风, 与网页版同一套语义) ----------
|
||||
// 不依赖编码模板 (40 系列仅 KC 有): 用「当前候选型号的最长公共前缀」——
|
||||
// 选得越多候选越少、前缀越长, 逐字拼出完整零件号。
|
||||
List<string> CandidateCodes()
|
||||
{
|
||||
var res = new List<string>();
|
||||
if (_series == null) return res;
|
||||
var vs = _series.variants ?? new List<Variant>();
|
||||
if (_sel.Count == 0) { foreach (var v in vs) res.Add(v.modelCode); return res; }
|
||||
foreach (var v in vs)
|
||||
{
|
||||
bool ok = true;
|
||||
foreach (var kv in _sel)
|
||||
{
|
||||
object val;
|
||||
if (!v.@params.TryGetValue(kv.Key, out val) || !EqStr(val, kv.Value)) { ok = false; break; }
|
||||
}
|
||||
if (ok) res.Add(v.modelCode);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
static string LongestCommonPrefix(List<string> list)
|
||||
{
|
||||
if (list == null || list.Count == 0) return "";
|
||||
var a = list[0]; var b = list[0];
|
||||
foreach (var x in list)
|
||||
{
|
||||
if (string.CompareOrdinal(x, a) < 0) a = x;
|
||||
if (string.CompareOrdinal(x, b) > 0) b = x;
|
||||
}
|
||||
int i = 0;
|
||||
while (i < a.Length && i < b.Length && a[i] == b[i]) i++;
|
||||
return a.Substring(0, i);
|
||||
}
|
||||
|
||||
/// <summary>刷新零件号实时条: 已确定前缀 + 待定位数 + 进度 + 提示。</summary>
|
||||
void RefreshSkuBar()
|
||||
{
|
||||
if (_skuBar == null || _series == null) return;
|
||||
var total = (_series.parameters ?? new List<ParameterDef>()).Count;
|
||||
var chosen = _sel.Count;
|
||||
var cand = CandidateCodes();
|
||||
var pre = LongestCommonPrefix(cand);
|
||||
|
||||
_skuCode.Inlines.Clear();
|
||||
if (pre.Length > 0)
|
||||
{
|
||||
_skuCode.Inlines.Add(new System.Windows.Documents.Run(pre)
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x0F, 0x76, 0x6E))
|
||||
});
|
||||
}
|
||||
var rest = new string('·', Math.Max(0, total - chosen));
|
||||
if (rest.Length > 0)
|
||||
{
|
||||
_skuCode.Inlines.Add(new System.Windows.Documents.Run(rest)
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0xD1, 0xD5, 0xDB))
|
||||
});
|
||||
}
|
||||
if (pre.Length == 0 && rest.Length == 0)
|
||||
{
|
||||
_skuCode.Inlines.Add(new System.Windows.Documents.Run(
|
||||
_lang.T("选择参数以生成零件号", "Select options to build part number"))
|
||||
{ Foreground = new SolidColorBrush(Color.FromRgb(0xD1, 0xD5, 0xDB)) });
|
||||
}
|
||||
|
||||
bool done = chosen == total && cand.Count == 1;
|
||||
if (done)
|
||||
{
|
||||
// 完成态: 整块转青色实心
|
||||
_skuBar.Background = new LinearGradientBrush(
|
||||
Color.FromRgb(0x14, 0xB8, 0xA6), Color.FromRgb(0x0D, 0x94, 0x88), 45);
|
||||
_skuBar.BorderBrush = Brushes.Transparent;
|
||||
_skuCode.Inlines.Clear();
|
||||
_skuCode.Inlines.Add(new System.Windows.Documents.Run(pre) { Foreground = Brushes.White });
|
||||
}
|
||||
else
|
||||
{
|
||||
_skuBar.Background = new LinearGradientBrush(
|
||||
Color.FromRgb(0xF0, 0xFD, 0xFA), Colors.White, 45);
|
||||
_skuBar.BorderBrush = new SolidColorBrush(Color.FromRgb(0x99, 0xF6, 0xE4));
|
||||
}
|
||||
_skuProg.Value = total == 0 ? 0 : (double)chosen / total;
|
||||
|
||||
if (chosen < total)
|
||||
_skuHint.Text = _lang.T(
|
||||
"已选 " + chosen + "/" + total + " 项 · 候选 " + cand.Count + " 个型号",
|
||||
chosen + "/" + total + " selected · " + cand.Count + " candidates");
|
||||
else if (cand.Count == 1)
|
||||
_skuHint.Text = _lang.T("零件号完整", "Part number complete");
|
||||
else if (cand.Count > 1)
|
||||
_skuHint.Text = _lang.T(
|
||||
"该参数组合对应 " + cand.Count + " 个型号, 请在参数表中选择",
|
||||
cand.Count + " models share these options — pick one in the table");
|
||||
else
|
||||
_skuHint.Text = _lang.T("无匹配型号", "No matching model");
|
||||
}
|
||||
|
||||
// range 参数自由输入 (参数化引擎): 回车/失焦时校验并写入选择; 非法输入恢复原显示 (与网页版一致)
|
||||
void TrySetRangeText(ComboBox cb)
|
||||
{
|
||||
@@ -632,6 +950,7 @@ namespace Ounibo.Catalog.App
|
||||
try
|
||||
{
|
||||
var allowed = _cfg.AllowedOptions(_sel);
|
||||
// 下拉 (range 参数)
|
||||
foreach (var kv in _boxes)
|
||||
{
|
||||
var cb = kv.Value;
|
||||
@@ -649,11 +968,52 @@ namespace Ounibo.Catalog.App
|
||||
if (_sel.ContainsKey(p.code))
|
||||
cb.SelectedItem = cb.Items.OfType<ComboItem>().FirstOrDefault(x => EqStr(x.Value, _sel[p.code]));
|
||||
}
|
||||
// 选项块 (枚举参数): 不可选的置灰保留, 已选的打勾
|
||||
foreach (var kv in _tiles)
|
||||
{
|
||||
var code = kv.Key;
|
||||
List<object> list;
|
||||
if (!allowed.TryGetValue(code, out list)) list = new List<object>();
|
||||
if (_sel.ContainsKey(code) && !list.Any(x => EqStr(x, _sel[code])))
|
||||
_sel.Remove(code);
|
||||
foreach (var tile in kv.Value)
|
||||
{
|
||||
tile.SetUsable(list.Any(x => EqStr(x, tile.Value)));
|
||||
tile.SetOn(_sel.ContainsKey(code) && EqStr(_sel[code], tile.Value));
|
||||
}
|
||||
}
|
||||
// 维度标题后的已选值回显
|
||||
if (_series != null)
|
||||
foreach (var p in _series.parameters)
|
||||
{
|
||||
var found = FindPickedTextBlock(p.code);
|
||||
if (found != null)
|
||||
found.Text = _sel.ContainsKey(p.code) ? "· " + DisplayText(p, _sel[p.code]) : "";
|
||||
}
|
||||
RefreshSkuBar();
|
||||
TryUpdateSelection();
|
||||
}
|
||||
finally { _refreshing = false; }
|
||||
}
|
||||
|
||||
/// <summary>按 Tag (=参数 code) 找到维度标题后的回显 TextBlock。</summary>
|
||||
TextBlock FindPickedTextBlock(string code)
|
||||
{
|
||||
foreach (var child in _paramPanel.Children)
|
||||
{
|
||||
var block = child as StackPanel;
|
||||
if (block == null || block.Children.Count == 0) continue;
|
||||
var head = block.Children[0] as StackPanel;
|
||||
if (head == null) continue;
|
||||
foreach (var h in head.Children)
|
||||
{
|
||||
var tb = h as TextBlock;
|
||||
if (tb != null && tb.Tag as string == code) return tb;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool EqStr(object a, object b)
|
||||
{
|
||||
return string.Equals(
|
||||
|
||||
80
tools/find-nxbin.ps1
Normal file
80
tools/find-nxbin.ps1
Normal 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
36
tools/fix-lineend.ps1
Normal 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))
|
||||
}
|
||||
}
|
||||
81
tools/serve-web-bg.ps1
Normal file
81
tools/serve-web-bg.ps1
Normal file
@@ -0,0 +1,81 @@
|
||||
# 后台启动/停止网页预览服务器 (无 PowerShell 窗口)
|
||||
# 用法:
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File tools\serve-web-bg.ps1 # 后台启动
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File tools\serve-web-bg.ps1 -Stop # 停止
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File tools\serve-web-bg.ps1 -Status # 查看状态
|
||||
# ... -Root release\web_2026.09 -Port 8080 # 指定目录/端口
|
||||
#
|
||||
# 原理: 用 Start-Process -WindowStyle Hidden 启动 serve-web.ps1, 并把 PID + 参数写入
|
||||
# .serve-web.pid 文件, 便于 -Stop / -Status 定位。
|
||||
param(
|
||||
[string]$Root = '',
|
||||
[int]$Port = 8080,
|
||||
[switch]$Stop,
|
||||
[switch]$Status
|
||||
)
|
||||
|
||||
$projRoot = Split-Path $PSScriptRoot -Parent
|
||||
$pidFile = Join-Path $projRoot '.serve-web.pid'
|
||||
# 相对路径按【项目根】转绝对 —— Start-Process 启动的子进程工作目录不确定,
|
||||
# 传相对路径会被解析到别处; 也不能依赖 Get-Location (调用方可能在任意目录)。
|
||||
# 注意 1: PowerShell 变量名【大小写不敏感】, 不能同时用 $root 和 $Root (会互相覆盖),
|
||||
# 故项目根变量取名 $projRoot。
|
||||
# 注意 2: Join-Path 只做拼接不做 .. 归约, 必须再用 GetFullPath 规范化。
|
||||
if ([string]::IsNullOrWhiteSpace($Root)) { $Root = 'release\web_2026.09' }
|
||||
if (-not [System.IO.Path]::IsPathRooted($Root)) { $Root = Join-Path $projRoot $Root }
|
||||
$Root = [System.IO.Path]::GetFullPath($Root)
|
||||
if (-not (Test-Path $Root)) { Write-Output ("目录不存在: " + $Root); exit 1 }
|
||||
$script = Join-Path $PSScriptRoot 'serve-web.ps1'
|
||||
|
||||
function Get-Running {
|
||||
# 用命令行匹配定位服务进程 (比只靠 PID 文件更可靠: 窗口关过/崩溃后 PID 会失效)
|
||||
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CommandLine -like '*serve-web.ps1*' }
|
||||
}
|
||||
|
||||
if ($Stop) {
|
||||
$procs = Get-Running
|
||||
if (-not $procs) { Write-Output '服务未在运行'; if (Test-Path $pidFile) { Remove-Item $pidFile -Force }; exit 0 }
|
||||
foreach ($p in $procs) {
|
||||
Write-Output ("停止 serve-web (PID " + $p.ProcessId + ")")
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (Test-Path $pidFile) { Remove-Item $pidFile -Force }
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Status) {
|
||||
$procs = Get-Running
|
||||
if ($procs) { Write-Output ("运行中: " + ($procs.ProcessId -join ', ')) }
|
||||
else { Write-Output '未运行' }
|
||||
try {
|
||||
$code = (Invoke-WebRequest -Uri ("http://localhost:" + $Port + "/index.html") -UseBasicParsing -TimeoutSec 3).StatusCode
|
||||
Write-Output ("HTTP " + $code + " http://localhost:" + $Port)
|
||||
} catch { Write-Output ("HTTP 不可达 http://localhost:" + $Port) }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 启动前先清理已在跑的, 避免端口占用/多份实例
|
||||
$old = Get-Running
|
||||
if ($old) {
|
||||
Write-Output ("已有实例, 先停止: " + ($old.ProcessId -join ', '))
|
||||
foreach ($p in $old) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# -WindowStyle Hidden: 不弹 PowerShell 窗口; 进程随本脚本退出继续运行
|
||||
# 注: 变量名不用 $args (PowerShell 自动变量, 赋值有副作用)
|
||||
$srvArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $script, '-Root', $Root, '-Port', $Port)
|
||||
$p = Start-Process -FilePath 'powershell' -ArgumentList $srvArgs -WindowStyle Hidden -PassThru
|
||||
Set-Content -Path $pidFile -Value $p.Id -Encoding ascii
|
||||
|
||||
Start-Sleep -Seconds 4
|
||||
try {
|
||||
$code = (Invoke-WebRequest -Uri ("http://localhost:" + $Port + "/index.html") -UseBasicParsing -TimeoutSec 5).StatusCode
|
||||
Write-Output ("已后台启动 (PID " + $p.Id + ") HTTP " + $code + " http://localhost:" + $Port)
|
||||
Write-Output ("目录: " + $Root)
|
||||
Write-Output "停止: tools\serve-web-bg.ps1 -Stop"
|
||||
} catch {
|
||||
Write-Output ("启动后探测失败, 请检查目录/端口: " + $Root)
|
||||
exit 1
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
# UI 层诊断 (STA): 反射创建主窗口, 对每个系列执行 LoadSeries,
|
||||
# 检查下拉框数据源是否为空 / 是否抛异常。
|
||||
# 校验选项块/下拉的选项数、可选态、以及选中后零件号实时条的输出。
|
||||
# 用法: powershell -STA -NoProfile -ExecutionPolicy Bypass -File tools\ui-diagnose.ps1
|
||||
param(
|
||||
[string]$Exe = (Join-Path $PSScriptRoot '..\bin\OnebotCatalog.exe'),
|
||||
[string]$Opc = (Join-Path $PSScriptRoot '..\sample\OnebotCatalog_2026.08.opc')
|
||||
[string]$Opc = (Join-Path $PSScriptRoot '..\sample\OnebotCatalog_2026.08.opc'),
|
||||
[switch]$Verbose
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName PresentationFramework
|
||||
@@ -16,23 +17,84 @@ $pkg = $pkgType.GetMethod('Open').Invoke($null, @($Opc))
|
||||
|
||||
$mwType = $asm.GetType('Ounibo.Catalog.App.MainWindow')
|
||||
$mw = [Activator]::CreateInstance($mwType, @($pkg, $false))
|
||||
$ls = $mwType.GetMethod('LoadSeries', [Reflection.BindingFlags]'NonPublic,Instance')
|
||||
$boxesField = $mwType.GetField('_boxes', [Reflection.BindingFlags]'NonPublic,Instance')
|
||||
$np = [Reflection.BindingFlags]'NonPublic,Instance'
|
||||
$ls = $mwType.GetMethod('LoadSeries', $np)
|
||||
$boxesF = $mwType.GetField('_boxes', $np) # range 参数的可编辑下拉
|
||||
$tilesF = $mwType.GetField('_tiles', $np) # 枚举参数的选项块
|
||||
$selF = $mwType.GetField('_sel', $np)
|
||||
$refreshM = $mwType.GetMethod('RefreshOptions', $np)
|
||||
$skuCodeF = $mwType.GetField('_skuCode', $np)
|
||||
$skuHintF = $mwType.GetField('_skuHint', $np)
|
||||
|
||||
function Get-InlinesText($tb) {
|
||||
if ($tb -eq $null) { return '' }
|
||||
$s = ''
|
||||
foreach ($r in $tb.Inlines) { $s += $r.Text }
|
||||
return $s
|
||||
}
|
||||
|
||||
$fail = 0
|
||||
foreach ($s in $pkg.Catalog.series) {
|
||||
try {
|
||||
$ls.Invoke($mw, @($s)) | Out-Null
|
||||
$boxes = $boxesField.GetValue($mw)
|
||||
$tiles = $tilesF.GetValue($mw)
|
||||
$boxes = $boxesF.GetValue($mw)
|
||||
|
||||
# 每个枚举维度: 选项块总数 / 可选数 (与参数定义值域比对)
|
||||
$parts = @()
|
||||
foreach ($k in $boxes.Keys) {
|
||||
$cb = $boxes[$k]
|
||||
$count = 0
|
||||
if ($cb.ItemsSource -ne $null) { $count = $cb.Items.Count }
|
||||
$parts += ($k + '=' + $count)
|
||||
$bad = @()
|
||||
foreach ($p in $s.parameters) {
|
||||
if ($tiles.ContainsKey($p.code)) {
|
||||
$list = $tiles[$p.code]
|
||||
$usable = 0
|
||||
foreach ($t in $list) {
|
||||
$f = $t.GetType().GetField('_usable', $np)
|
||||
if ($f -ne $null -and $f.GetValue($t)) { $usable++ }
|
||||
}
|
||||
$parts += ($p.code + '=' + $list.Count)
|
||||
# 初始状态: 每个维度至少有一个可选值 (否则用户无从下手)
|
||||
if ($usable -eq 0) { $bad += ($p.code + ':无可选值') }
|
||||
if ($list.Count -eq 0) { $bad += ($p.code + ':选项为空') }
|
||||
} elseif ($boxes.ContainsKey($p.code)) {
|
||||
$cb = $boxes[$p.code]
|
||||
$c = 0
|
||||
if ($cb.ItemsSource -ne $null) { $c = $cb.Items.Count }
|
||||
$parts += ($p.code + '=range:' + $c)
|
||||
if ($c -eq 0) { $bad += ($p.code + ':range无档位') }
|
||||
}
|
||||
}
|
||||
|
||||
# 逐个参数按第一个可用值选满, 检查零件号条是否产出非空前缀
|
||||
$sel = $selF.GetValue($mw)
|
||||
$sel.Clear()
|
||||
$v0 = $s.variants[0]
|
||||
$sku = ''
|
||||
if ($v0 -ne $null) {
|
||||
foreach ($p in $s.parameters) {
|
||||
$val = $null
|
||||
if ($v0.params.ContainsKey($p.code)) { $val = $v0.params[$p.code] }
|
||||
if ($val -ne $null) { $sel[$p.code] = $val }
|
||||
}
|
||||
$refreshM.Invoke($mw, @()) | Out-Null
|
||||
$sku = Get-InlinesText ($skuCodeF.GetValue($mw))
|
||||
$hint = $skuHintF.GetValue($mw).Text
|
||||
if ([string]::IsNullOrWhiteSpace($sku)) { $bad += '零件号条为空' }
|
||||
}
|
||||
|
||||
$flag = if ($bad.Count -gt 0) { ' !! ' + ($bad -join '; ') } else { '' }
|
||||
if ($bad.Count -gt 0) { $fail++ }
|
||||
Write-Output ("系列 " + $s.code.PadRight(6) + " 选项块 " + ($parts -join ' ') + $flag)
|
||||
if ($Verbose -and $sku -ne '') {
|
||||
Write-Output (" 零件号: [" + $sku + "] " + $hint)
|
||||
}
|
||||
Write-Output ("系列 " + $s.code + " 下拉数据源: " + ($parts -join ' '))
|
||||
} catch {
|
||||
Write-Output ("系列 " + $s.code + " 异常: " + $_.Exception.InnerException.Message)
|
||||
$fail++
|
||||
$m = $_.Exception.Message
|
||||
if ($_.Exception.InnerException -ne $null) { $m += ' / ' + $_.Exception.InnerException.Message }
|
||||
Write-Output ("系列 " + $s.code + " 异常: " + $m)
|
||||
}
|
||||
}
|
||||
Write-Output ""
|
||||
Write-Output ("检查完成: " + $pkg.Catalog.series.Count + " 个系列, 问题 " + $fail + " 个")
|
||||
$pkg.Dispose()
|
||||
if ($fail -gt 0) { exit 1 }
|
||||
|
||||
262
web/app.js
262
web/app.js
@@ -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 = '×';
|
||||
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 = '×';
|
||||
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 () {
|
||||
|
||||
@@ -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,25 +43,26 @@
|
||||
<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>
|
||||
<select id="btnGenFmt" class="hidden" title="下载格式 (仅非标档位在线生成)">
|
||||
<option value="zip">全格式 zip</option>
|
||||
<option value="step">STEP</option>
|
||||
<option value="iges">IGES</option>
|
||||
</select>
|
||||
<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">
|
||||
<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="下载格式 (仅非标档位在线生成)">
|
||||
<option value="zip">全格式 zip</option>
|
||||
<option value="step">STEP</option>
|
||||
<option value="iges">IGES</option>
|
||||
</select>
|
||||
<a id="btnDatasheet" class="btn hidden">下载 PDF</a>
|
||||
<button id="btnAddCart" class="btn hidden">加入清单</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
117
web/style.css
117
web/style.css
@@ -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);
|
||||
|
||||
201
工作总结-2026-09.md
Normal file
201
工作总结-2026-09.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# 工作总结 — OnebotCatalog 数据完善与界面改版
|
||||
|
||||
**时间范围**:2026-09-13 ~ 09-15
|
||||
**版本**:v1.0 → v2.0 → v2.1(桌面版改动未提交,见文末)
|
||||
**代码仓库**:https://git.ruiguo.wang/wangruiguo/OnebotCatalog
|
||||
|
||||
---
|
||||
|
||||
## 一、总体成果
|
||||
|
||||
| 指标 | 改动前 | 改动后 |
|
||||
|---|---|---|
|
||||
| 系列数 | 40 | 40 |
|
||||
| **型号总数** | **54,136** | **57,293** |
|
||||
| 真实数据分类 | 3 个(气缸/迷你缸)| **7 个**(+夹紧缸/定位销/勾销缸/翻转缸/电缸)|
|
||||
| 手册附图 | 灰度、部分系列仅 1 页 | **彩色 346 页,覆盖全部 42 系列** |
|
||||
| 网页版选型交互 | 下拉菜单 | **DESTACO 风格(选项块 + 零件号实时拼装)** |
|
||||
| 数据包体积 | 1.21 GB | 2.14 GB |
|
||||
|
||||
各分类现状:
|
||||
|
||||
| 分类 | 系列 | 型号数 |
|
||||
|---|---|---|
|
||||
| AUT 汽车行业气缸 | 6 | 12,554 |
|
||||
| MIN 不锈钢迷你缸 | 3 | 25,200 |
|
||||
| ALU 铝合金迷你缸 | 2 | 14,400 |
|
||||
| CLM 气动夹紧缸 | 11 | 860 |
|
||||
| PIN 气动可伸缩定位销 | 9 | 3,455 |
|
||||
| HOK 气动勾销缸 | 5 | 72 |
|
||||
| ROT 气动翻转缸 | 2 | 741 |
|
||||
| ELEC 电动气缸 | 1 | 9 |
|
||||
| GRP 气动夹爪缸 | 1 | 2(仍为占位,X 盘无真实数模)|
|
||||
|
||||
---
|
||||
|
||||
## 二、按阶段的工作
|
||||
|
||||
### 阶段 1(v2.0):4 个分类的真实数据替换
|
||||
|
||||
**背景**:定位销 / 勾销缸 / 翻转缸 / 电缸 4 个分类此前是 demo 占位数据(每类仅 1-3 个型号)。
|
||||
|
||||
**做法**:
|
||||
1. 用 `pdftotext` 提取 **19 份 Univer 官方图册的 CODIFICATION KEY**,拿到每个系列的官方订购码结构(不靠推测)
|
||||
2. 扫描 X 盘 3D 数模,按「官方值域校验 + 左对齐 DFS 填空」解析文件名
|
||||
3. 生成 CSV / meta / 复制 STEP
|
||||
|
||||
**成果**:新增 19 系列 **1,124 个真实型号**(PIN 303 / HOK 73 / ROT 741 / ELEC 9)
|
||||
|
||||
**过程中解决的关键问题**:
|
||||
|
||||
| 问题 | 处理 |
|
||||
|---|---|
|
||||
| 解析器无法处理省略空位的型号(如 `ULP50040SK0`)| 改用官方值域 DFS 填空 |
|
||||
| 参数组合可能一对多(选型会静默错配)| 加**唯一性门禁**,1124 型号全部通过 |
|
||||
| 占位/测试文件(如 `USP50J_0___K0` 销径为空)| 解析阶段丢弃 |
|
||||
| catalog.json 分类系列表未同步 → 10 个系列没进包 | 更新 `CLM.series` 数组 |
|
||||
| 手册页全是灰度 | 脚本误写死 `colorspace=fitz.csGRAY`,去掉后重做**彩色 346 页** |
|
||||
| UCBT 附图只有 1 页 | 筛选条件写死 `UCBT40` 且漏 `CLAMPING ARMS`,已修正为 3 页 |
|
||||
| 重跑数据脚本会覆盖 meta 附件 | 改为**扫描 manual 目录自动生成**附件清单 |
|
||||
| 数据包 2.25GB 超 MemoryStream 2GB 上限 | `OpcPackage` 超大包改走临时文件(保留原有句柄释放语义)|
|
||||
|
||||
### 阶段 2(v2.0):网页版界面改版
|
||||
|
||||
- **首页**:全部产品缩略图网格,按大类分组
|
||||
- **左树**:手风琴(一次展开一个),父级带 **9 个内置 SVG 图标**,悬停放大动画,可收起为图标窄条
|
||||
- **缩略图**:40 张(27 张真实产品图从 `bom-viewer` 复用 + 13 张占位图)
|
||||
- 修复分隔条被拖出容器后无法找回(**容器感知上限 + resize 重夹取 + 双击复位**)
|
||||
- 修复搜索到型号后点结果内容区空白(`switchTab('dim')` 已失效)
|
||||
- 购物车打包改取 `.step.gz` 副档(传输量降至约 1/4.5)
|
||||
|
||||
### 阶段 3(v2.1):DESTACO 风格选型 + 参数组合补全
|
||||
|
||||
**3.1 零件号实时拼装**
|
||||
|
||||
DESTACO 配置器的核心体验。技术难点:**40 个系列中只有 KC 有编码模板**。
|
||||
|
||||
解法:用**「当前候选型号的最长公共前缀」** —— 选得越多候选越少、前缀越长,自然拼出完整零件号。
|
||||
已验证 **40/40 系列前缀单调增长**(不回退)。
|
||||
|
||||
**3.2 参数选择改为选项块**
|
||||
|
||||
下拉菜单 → 可视化卡片:悬停抬升+青色投影、选中青色填充+✓、
|
||||
**不可选取值置灰保留**(而非消失,让用户看到选项存在)、再点一次取消。
|
||||
|
||||
**3.3 参数组合补全(缺组合也能出零件号)**
|
||||
|
||||
原逻辑要求「必须有真实数模才能生成型号」,导致库里没有的组合选不出来、也出不了号。
|
||||
|
||||
1. **反推编码模板**:`infer-sku-template.py` 从已有型号反推出 **23 个系列 100% 可复现**的模板
|
||||
2. **加自检门禁**:`verify-templates.py` 用模板重渲染每个已有型号必须逐字一致。
|
||||
**这道检查抓出了真实错误** —— ULM/ULP 行程补零位数写错(2 位写成 3 位),
|
||||
若无此校验会**生成 3,000 个不存在的零件号发给客户**
|
||||
3. 补全 8 个系列共 **3,157 个型号**(全库 54,136 → 57,293)
|
||||
4. **占位数模**:3157 个型号共用 1 个 `PLACEHOLDER.step`(4.5KB),
|
||||
数据包仅从 2.10GB 增至 2.14GB
|
||||
5. 输出 `_pending_models.csv` 待补清单供 AI 补真实数模
|
||||
|
||||
### 阶段 4:桌面版同步(未提交)
|
||||
|
||||
将 3.1 / 3.2 同步到 C#/WPF 桌面版,保持两端语义一致:
|
||||
|
||||
- 新增 `OptionTile` 控件类(三态选项块)
|
||||
- 零件号实时条(复用同一套最长公共前缀算法)
|
||||
- range 参数保留可编辑下拉
|
||||
- **编译首次通过**,算法对拍 **40/40 系列通过**
|
||||
|
||||
---
|
||||
|
||||
## 三、发现但未处理的问题
|
||||
|
||||
按优先级排列:
|
||||
|
||||
### 1. ULP 生成的候选有 56% 超出依据范围 ⚠️
|
||||
|
||||
补全时用的是**系列级值域**(整个 ULP 系列各维度取值的并集),而非**按缸径分级**。
|
||||
|
||||
| 缸径 | 真实型号数 | 真实行程 | 真实传感器 |
|
||||
|---|---|---|---|
|
||||
| ULP32 | 14 | 20, 40 | 只有 K |
|
||||
| ULP50 | 83 | 15~110 | D, K |
|
||||
| ULP63 | 71 | 15~60 | D, K |
|
||||
|
||||
结果:给 ULP32 生成了 9 种行程、传感器 D、杆样式 D/F/H/I/L/M —— 这些在该缸径的真实数据中**从未出现**。
|
||||
|
||||
**风险量化**(`assess-combo-risk.py`):
|
||||
```
|
||||
低风险(取值在同缸径真实数据都出现过) 1,212 (43%)
|
||||
高风险(含该缸径从未出现的取值) 1,590 (56%)
|
||||
```
|
||||
|
||||
**建议**:收窄到低风险的 1,212 个;其余等 AI 补数模时先核对官方图册。
|
||||
|
||||
> 注:曾三次尝试从官方 PDF 提取分级值域,均因**图册双栏排版导致文本化后左右栏交错**而失败
|
||||
> (把真实数据都判成越界 = 解析错误)。故改用「同缸径真实数据」作为可靠代理。
|
||||
|
||||
### 2. 夹紧缸 9 个系列:一组参数对应多个型号
|
||||
|
||||
| 系列 | 变体数 | 重复组数 | 一组最多 |
|
||||
|---|---|---|---|
|
||||
| UCBP | 358 | 46 组 | 18 个 |
|
||||
| UCBM | 266 | 26 组 | 15 个 |
|
||||
| UCBH / UCBQ / UCDP / UCIM / UCIP / UFL / UCBT | — | 2-7 组 | 2-6 个 |
|
||||
|
||||
共 **310 个型号**受影响。原因是编码含**参数之外的尾缀**(版本号/变体标记),
|
||||
如 `UCBP25AK00` vs `UCBP25AK010`。界面已提示"该组合对应 N 个型号",但无法唯一确定。
|
||||
|
||||
### 3. UAGP / LAGP 参数顺序与官方编码结构不符
|
||||
|
||||
官方订购码顺序为 `规格-台位-传感器-转角-制动-接口`,当前 meta 里传感器排在最后。
|
||||
不影响选型正确性(参数按名索引),但**导致编码模板推不出来**,界面展示顺序也与官方不一致。
|
||||
|
||||
### 4. 17 个系列未补全组合
|
||||
|
||||
模板验证未通过的系列保持原状 —— 宁可少补,也不生成可能不存在的零件号。
|
||||
|
||||
### 5. 夹爪缸 UGP 仍为占位
|
||||
|
||||
X 盘无 UGP 系列真实数模(只有 GR8 抓手组装体),需确认 Univer 官方夹爪缸系列代号。
|
||||
|
||||
---
|
||||
|
||||
## 四、关键经验(避免重复踩坑)
|
||||
|
||||
| 坑 | 教训 |
|
||||
|---|---|
|
||||
| **模板必须先自检** | 用模板重渲染全部已有型号,逐字一致才能用于生成新号。省这道检查会造出不存在的零件号 |
|
||||
| **PowerShell 变量名大小写不敏感** | `$root` 与 `$Root` 是同一变量,会互相覆盖(启动脚本因此指向错误目录)|
|
||||
| **.ps1 必须带 UTF-8 BOM** | PS 5.1 对无 BOM 文件按 ANSI 读,中文注释会破坏解析(用 `tools/fix-bom.ps1`)|
|
||||
| **脚本重跑会覆盖手工结果** | meta 附件清单改为扫描目录自动生成,而非硬编码 |
|
||||
| **NTFS 硬链接上限 1024** | 超限部分退化为真实拷贝(曾白占 500MB)。改用共享单文件 |
|
||||
| **Join-Path 不做路径归约** | 需再 `GetFullPath` 规范化 |
|
||||
| **MemoryStream 上限 2GB** | 超大 .opc 改走临时文件副本 |
|
||||
|
||||
---
|
||||
|
||||
## 五、产出与交付
|
||||
|
||||
**新增脚本(11 个)**:`infer-sku-template` / `verify-templates` / `quantify-combo-gap` /
|
||||
`gen-missing-combos` / `gen-placeholder-step` / `check-combo-unique` / `assess-combo-risk` /
|
||||
`make-pending-list` / `gen-step-gz` / `gen-series-thumbs` / `serve-web-bg`
|
||||
|
||||
**新增文档**:`docs/更改说明-v2.1.md`、本文件
|
||||
|
||||
**发布产物(release/,未纳入 Git)**:
|
||||
- `OnebotCatalog_2026.09/` — 客户版目录(catalog.opc 2.14GB)
|
||||
- `update_2026.09/catalog.opc` — 增量更新包
|
||||
- `web_2026.09/` — 网页站(含 3,285 个 gz 副档)
|
||||
|
||||
---
|
||||
|
||||
## 六、待办
|
||||
|
||||
1. **决定 ULP 高危候选如何处置**(收窄 / 维持 / 由 AI 核对官方图册)
|
||||
2. **桌面版改动提交推送**(当前工作区有未提交改动,建议发 v2.2)
|
||||
3. AI 补真实数模:按 `_pending_models.csv` 逐个核对后替换 `step_file`
|
||||
4. 处理夹紧缸一对多问题(补维度或清理尾缀)
|
||||
5. 确认 UGP 夹爪缸官方系列代号
|
||||
|
||||
---
|
||||
|
||||
*本总结由 Claude Code 生成于 2026-09-15*
|
||||
Reference in New Issue
Block a user