首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)

This commit is contained in:
wangruiguo
2026-09-03 17:55:45 +08:00
commit fafa86d3a6
241 changed files with 78656 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-genserver.ps1"

34
tools/build-genserver.ps1 Normal file
View File

@@ -0,0 +1,34 @@
# GenServer 编译 (NX 按需生成服务 A 版, 独立控制台 exe, 零 WPF 依赖)
# 只编: src\GenServer\GenServer.cs + CatalogCore 的 Model/Configurator/ParametricEngine (共享规则引擎, 双端一致性红线)
param([string]$Out = (Join-Path $PSScriptRoot '..\bin\GenServer.exe'))
$fw = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319'
$csc = 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe'
if (-not (Test-Path $csc)) { Write-Output '未找到 csc.exe (VS Build Tools)'; exit 1 }
$refs = @(
(Join-Path $fw 'mscorlib.dll'),
(Join-Path $fw 'System.dll'),
(Join-Path $fw 'System.Core.dll'),
(Join-Path $fw 'System.IO.Compression.dll'),
(Join-Path $fw 'System.IO.Compression.FileSystem.dll'),
(Join-Path $fw 'System.Web.Extensions.dll')
)
$srcs = @(
(Join-Path $PSScriptRoot '..\src\GenServer\GenServer.cs'),
(Join-Path $PSScriptRoot '..\src\CatalogCore\Model.cs'),
(Join-Path $PSScriptRoot '..\src\CatalogCore\Configurator.cs'),
(Join-Path $PSScriptRoot '..\src\CatalogCore\ParametricEngine.cs')
)
$outDir = Split-Path $Out
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$cscArgs = @('/nologo', '/target:exe', '/platform:anycpu', '/optimize+', '/utf8output', ('/out:' + $Out))
foreach ($r in $refs) { if (Test-Path $r) { $cscArgs += ('/reference:' + $r) } }
$cscArgs += $srcs
& $csc @cscArgs
if ($LASTEXITCODE -eq 0) { Write-Output ("GenServer 编译成功: " + $Out) }
else { Write-Output ("GenServer 编译失败 (exit " + $LASTEXITCODE + ")"); exit 1 }

90
tools/build.ps1 Normal file
View File

@@ -0,0 +1,90 @@
# OnebotCatalog 编译脚本 (零安装: 使用 VS Build Tools 自带 Roslyn csc + Windows 自带 .NET Framework 4.8/WPF)
# 用法:
# 开发版: powershell -NoProfile -ExecutionPolicy Bypass -File tools\build.ps1
# 客户版: powershell -NoProfile -ExecutionPolicy Bypass -File tools\build.ps1 -Customer [-Opc 数据包.opc]
param(
[string]$Out = (Join-Path $PSScriptRoot '..\bin\OnebotCatalog.exe'),
[switch]$Customer,
[string]$Opc = (Join-Path $PSScriptRoot '..\sample\OnebotCatalog_2026.08.opc'),
[string]$Icon = (Join-Path $PSScriptRoot '..\assets\OneBot-logo.ico')
)
if ($Customer -and -not $PSBoundParameters.ContainsKey('Out')) {
$Out = Join-Path $PSScriptRoot '..\bin\OnebotCatalog-Customer.exe'
}
$root = $PSScriptRoot
$fw = 'C:\Windows\Microsoft.NET\Framework64\v4.0.30319'
$wpf = Join-Path $fw 'WPF'
$csc = 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe'
if (-not (Test-Path $csc)) { Write-Output '未找到 csc.exe (VS Build Tools)'; exit 1 }
$refs = @(
(Join-Path $fw 'mscorlib.dll'),
(Join-Path $fw 'System.dll'),
(Join-Path $fw 'System.Core.dll'),
(Join-Path $fw 'System.Data.dll'),
(Join-Path $fw 'System.Xaml.dll'),
(Join-Path $fw 'System.IO.Compression.dll'),
(Join-Path $fw 'System.IO.Compression.FileSystem.dll'),
(Join-Path $fw 'System.Web.Extensions.dll'),
(Join-Path $fw 'System.Windows.Forms.dll'),
(Join-Path $wpf 'WindowsBase.dll'),
(Join-Path $wpf 'PresentationCore.dll'),
(Join-Path $wpf 'PresentationFramework.dll')
)
# 开源 3D 渲染库 Helix Toolkit (MIT): 窗口内直渲 STL/OBJ 网格 (真实数模预览, 免浏览器)
$helixDir = Join-Path $PSScriptRoot '..\lib'
$helixWpf = Join-Path $helixDir 'HelixToolkit.Wpf.dll'
$helixCore = Join-Path $helixDir 'HelixToolkit.dll'
if ((Test-Path $helixWpf) -and (Test-Path $helixCore)) {
$refs += @($helixWpf, $helixCore)
} else {
Write-Output '警告: lib\ 缺 HelixToolkit DLL, 跳过 3D 窗口内渲染引用 (仅影响真实数模窗口内预览)'
}
# WebView2 控件 (微软, 窗口内嵌新迪查看器显示真实 STEP, 免弹浏览器; Win11 自带运行时)
$wv2Core = Join-Path $helixDir 'Microsoft.Web.WebView2.Core.dll'
$wv2Wpf = Join-Path $helixDir 'Microsoft.Web.WebView2.Wpf.dll'
$wv2Loader = Join-Path $helixDir 'WebView2Loader.dll'
if ((Test-Path $wv2Core) -and (Test-Path $wv2Wpf)) {
$refs += @($wv2Core, $wv2Wpf)
} else {
Write-Output '警告: lib\ 缺 WebView2 DLL, 跳过窗口内嵌 3D (真实数模将回退浏览器预览)'
}
# GenServer (NX 按需生成服务, 独立控制台 exe) 单独编译, 不进主程序
$srcs = Get-ChildItem (Join-Path $root '..\src') -Filter '*.cs' -Recurse |
Where-Object { $_.FullName -notmatch '\\GenServer\\' } | ForEach-Object { $_.FullName }
if (-not $srcs) { Write-Output 'src 下无 .cs 文件'; exit 1 }
$outDir = Split-Path $Out
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$cscArgs = @('/nologo', '/target:winexe', '/platform:anycpu', '/optimize+', '/utf8output', ('/out:' + $Out))
# OneBot logo 嵌入资源 (窗口图标 + 顶栏 logo)
$logoPng = Join-Path $PSScriptRoot '..\assets\OneBot-logo.png'
if (Test-Path $logoPng) { $cscArgs += ('/resource:' + $logoPng + ',OneBot-logo.png') }
if ($Customer) {
$cscArgs += '/define:CUSTOMER_BUILD'
if (Test-Path $Opc) { $cscArgs += ('/resource:' + $Opc + ',catalog.opc') }
else { Write-Output ("警告: 找不到数据包 " + $Opc + ", 客户版将无内置数据") }
if (Test-Path $Icon) { $cscArgs += ('/win32icon:' + $Icon) }
}
foreach ($r in $refs) { if (Test-Path $r) { $cscArgs += ('/reference:' + $r) } }
$cscArgs += $srcs
& $csc @cscArgs
if ($LASTEXITCODE -eq 0) {
# 第三方 DLL 与 exe 同目录, 运行时加载 (Helix + WebView2; WebView2Loader 为 native, 必须同目录)
if (Test-Path $helixWpf) { Copy-Item $helixWpf $outDir -Force }
if (Test-Path $helixCore) { Copy-Item $helixCore $outDir -Force }
if (Test-Path $wv2Core) { Copy-Item $wv2Core $outDir -Force }
if (Test-Path $wv2Wpf) { Copy-Item $wv2Wpf $outDir -Force }
if (Test-Path $wv2Loader) { Copy-Item $wv2Loader $outDir -Force }
Write-Output ("编译成功: " + $Out)
} else {
Write-Output ("编译失败 (exit " + $LASTEXITCODE + ")")
exit 1
}

63
tools/e2e-cart.js Normal file
View File

@@ -0,0 +1,63 @@
// 无头回归: 网页购物车 — 选型 → 加入清单 → 角标计数 → 切系列清单不丢 (2026-08-24)
// 适配参数化引擎 (2026-08-25): range 参数为输入框, 选第一个标准档位
let chromium;
try { chromium = require('playwright-core').chromium; }
catch (e) { chromium = require('C:/Users/ruigu/AppData/Local/Temp/nds3dtest/node_modules/playwright-core').chromium; }
const edge = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
(async () => {
const url = process.argv[2] || 'http://localhost:8080/';
const browser = await chromium.launch({ executablePath: edge, headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
// 选满一个系列的全部参数 (select 取第一个非空值; input(range) 取第一个标准档位)
const pickAll = async () => {
const sels = await page.$$('#paramPanel select');
for (let i = 0; i < sels.length; i++) {
const opts = await sels[i].$$eval('option', os => os.map(o => o.value));
const pick = opts.find(v => v !== '' && v !== '__EMPTY__') || opts[opts.length - 1];
await sels[i].selectOption(pick);
await page.waitForTimeout(250);
}
const inputs = await page.$$('#paramPanel input');
for (let i = 0; i < inputs.length; i++) {
const dl = await page.$eval('#' + (await inputs[i].getAttribute('list')), dl => Array.from(dl.options).map(o => o.value));
await inputs[i].fill(dl[0] || '10');
await inputs[i].dispatchEvent('change');
await page.waitForTimeout(250);
}
await page.waitForTimeout(500);
};
await page.click('.tree .series');
await page.waitForTimeout(400);
await pickAll();
const dlVisible = await page.$eval('#btnDownload', el => !el.classList.contains('hidden') && !el.disabled);
const addVisible = await page.$eval('#btnAddCart', el => !el.classList.contains('hidden'));
await page.click('#btnAddCart');
await page.waitForTimeout(300);
const badge1 = await page.$eval('#btnCart', el => el.textContent);
// 换第二个系列再选再加入 (清单跨系列不丢)
const series = await page.$$('.tree .series');
if (series.length > 1) {
await series[1].click();
await page.waitForTimeout(400);
await pickAll();
await page.click('#btnAddCart');
await page.waitForTimeout(300);
}
const badge2 = await page.$eval('#btnCart', el => el.textContent);
// 打开抽屉看条目数
await page.click('#btnCart');
await page.waitForTimeout(300);
const items = await page.$$eval('#cartList .cart-item', els => els.length);
console.log('下载按钮可见:', dlVisible, '| 加入清单可见:', addVisible);
console.log('第一次加入后角标:', badge1, '| 第二次后:', badge2, '| 抽屉条目:', items);
console.log('页面错误:', errs.length ? errs.join(' | ') : '无');
await browser.close();
if (!dlVisible || !addVisible || badge1.indexOf('(1)') < 0 || badge2.indexOf('(2)') < 0 || items !== 2 || errs.length)
process.exit(1);
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });

90
tools/e2e-gen.js Normal file
View File

@@ -0,0 +1,90 @@
// 无头 E2E: 非标档位在线生成 (二期) — 按钮升级/立即命中下载/排队轮询下载
// 前置: 站点 config.js 的 api 已指向本地 GenServer (匿名模式); GenServer 已启动
// 用法: node e2e-gen.js <siteUrl> <jobsDir> <outputDir>
const fs = require('fs');
const path = require('path');
let chromium;
try { chromium = require('playwright-core').chromium; }
catch (e) { chromium = require('C:/Users/ruigu/AppData/Local/Temp/nds3dtest/node_modules/playwright-core').chromium; }
const edge = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
(async () => {
const url = process.argv[2] || 'http://localhost:8080/';
const jobsDir = process.argv[3];
const outputDir = process.argv[4];
const browser = await chromium.launch({ executablePath: edge, headless: true });
const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1600, height: 900 } });
const page = await context.newPage();
const errs = [];
page.on('pageerror', e => errs.push(e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
let pass = 0, fail = 0;
const out = [];
const check = (name, ok) => { out.push((ok ? 'PASS ' : 'FAIL ') + name); if (ok) pass++; else fail++; };
// KC 系列
const names = await page.$$eval('.tree .series', els => els.map(e => e.textContent.trim()));
await (await page.$$('.tree .series'))[names.findIndex(n => n.indexOf('KC') === 0)].click();
await page.waitForTimeout(500);
const setSel = async (code, val) => { await page.selectOption('#sel-' + code, val); await page.waitForTimeout(250); };
const setStroke = async (v) => { await page.fill('#sel-stroke', String(v)); await page.dispatchEvent('#sel-stroke', 'change'); await page.waitForTimeout(400); };
const btn = async () => page.$eval('#btnDownload', el => ({ text: el.textContent, disabled: el.disabled, hidden: el.classList.contains('hidden') }));
// 1) 非标 87 (输出目录已预置) → 按钮=在线生成数模; 点击 → immediate → 触发下载
await setSel('type', '00'); await setSel('bore', '32'); await setStroke(87);
await setSel('magnet', '__EMPTY__'); await setSel('mount', '__EMPTY__'); await page.waitForTimeout(400);
var b = await btn();
check('非标按钮 = 在线生成数模 且可用 (实际: ' + b.text + (b.disabled ? '/disabled' : '/enabled') + ')',
b.text.indexOf('在线生成') >= 0 && !b.disabled);
var dlEvent = null;
page.waitForEvent('download', { timeout: 10000 }).then(d => { dlEvent = d; }).catch(() => { });
await page.click('#btnDownload');
await page.waitForTimeout(2500);
check('立即命中 → 触发全格式 zip 下载', dlEvent !== null);
var st = await page.$eval('#status', el => el.textContent);
check('状态含 就绪/下载 (实际: ' + st.trim() + ')', st.indexOf('就绪') >= 0 || st.indexOf('下载') >= 0);
// 2) 非标 88 (无输出) → 点击 → 排队/生成中 → 外部模拟 worker 完成 → 触发下载
await setStroke(88); await page.waitForTimeout(400);
b = await btn();
check('非标 88 按钮可用', b.text.indexOf('在线生成') >= 0 && !b.disabled);
dlEvent = null;
page.waitForEvent('download', { timeout: 30000 }).then(d => { dlEvent = d; }).catch(() => { });
await page.click('#btnDownload');
await page.waitForTimeout(4000);
b = await btn();
check('点击后进入生成/排队态 (实际: ' + b.text + ')', b.text.indexOf('生成') >= 0 || b.text.indexOf('队列') >= 0);
// 模拟 worker: 认领 .json → .running → 造输出 → 写 .done
const jobs = fs.readdirSync(jobsDir).filter(f => f.startsWith('job_') && f.endsWith('.json'));
if (jobs.length) {
const jf = path.join(jobsDir, jobs[0]);
const rf = jf.replace(/\.json$/, '.running');
fs.renameSync(jf, rf);
const o = path.join(outputDir, 'KC', 'KC0032-88');
fs.mkdirSync(o, { recursive: true });
['KC0032-88.stp', 'KC0032-88.x_t', 'KC0032-88.igs', 'KC0032-88.stl'].forEach(f => fs.writeFileSync(path.join(o, f), 'FAKE ' + f));
fs.writeFileSync(jf.replace(/\.json$/, '.done'), JSON.stringify({
ok: true, error: '',
items: [{ series: 'KC', code: 'KC0032-88', ok: true, hit: false, files: ['KC0032-88.stp', 'KC0032-88.x_t', 'KC0032-88.igs', 'KC0032-88.stl'] }]
}));
fs.unlinkSync(rf);
check('模拟 worker 完成写入', true);
} else {
check('任务已入队 (jobs 目录可见)', jobs.length > 0);
}
await page.waitForTimeout(12000); // 轮询间隔 3s, 等状态刷新并触发下载
check('排队轮询 → 完成后触发下载', dlEvent !== null);
// 3) 标准档位回归: stroke=10 → 按钮回 下载 STEP 可用
await setStroke(10); await page.waitForTimeout(400);
b = await btn();
check('标准档位按钮回 下载 STEP (实际: ' + b.text + ')', b.text.indexOf('下载 STEP') >= 0 && !b.disabled && !b.hidden);
console.log(out.join('\n'));
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败 | 页面错误: ' + (errs.length ? errs.join(' | ') : '无'));
await browser.close();
process.exit(fail ? 1 : 0);
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });

130
tools/e2e-parametric.js Normal file
View File

@@ -0,0 +1,130 @@
// 无头对拍: 参数化引擎 (一期 2026-08-25) — 网页 UI 与桌面 selftest 读同一份 parametric-vectors.json
// 用法: node tools\e2e-parametric.js [siteUrl] [vectorsPath]
// 断言: 标准档位编码/下载可用; 非标档位编码+徽标+下载置灰+最近标准档位提示; 非法组合无型号
const fs = require('fs');
let chromium;
try { chromium = require('playwright-core').chromium; }
catch (e) { chromium = require('C:/Users/ruigu/AppData/Local/Temp/nds3dtest/node_modules/playwright-core').chromium; }
const edge = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
(async () => {
const url = process.argv[2] || 'http://localhost:8080/';
const vecPath = process.argv[3] ||
'w:/团队文件-TeamCenter/airtac_0602_2026_v11sp5_world_version/airtac_0602_2026_v11sp5_world_version/OnebotCatalog/onebot-data/catalog/parametric-vectors.json';
const vec = JSON.parse(fs.readFileSync(vecPath, 'utf8'));
const browser = await chromium.launch({ executablePath: edge, headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
const errs = [];
page.on('pageerror', e => errs.push(e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.tree .series', { timeout: 20000 }); // 等目录树渲染 (服务器冷启动首请求慢)
await page.waitForTimeout(800);
let pass = 0, fail = 0;
const out = [];
const check = (name, ok) => { out.push((ok ? 'PASS ' : 'FAIL ') + name); if (ok) pass++; else fail++; };
// 选中 KC 系列 (首个参数化系列)
const names = await page.$$eval('.tree .series', els => els.map(e => e.textContent.trim()));
const kcIdx = names.findIndex(n => n.indexOf('KC') === 0);
if (kcIdx < 0) { console.log('FAIL: 树中未找到 KC 系列'); await browser.close(); process.exit(1); }
const seriesEls = await page.$$('.tree .series');
await seriesEls[kcIdx].click();
await page.waitForTimeout(500);
for (let ci = 0; ci < vec.cases.length; ci++) {
const c = vec.cases[ci];
const tag = 'case' + (ci + 1) + ' ' + c.series;
const keys = Object.keys(c.params);
const setParam = async (p, v) => {
const sel = await page.$('#sel-' + p);
if (!sel) { check(tag + ' 参数控件缺失 ' + p, false); return; }
const isSelect = (await sel.evaluate(el => el.tagName)) === 'SELECT';
console.log(' [' + tag + '] 设置 ' + p + '=' + JSON.stringify(v) + ' (' + (isSelect ? 'select' : 'input') + ')');
if (isSelect) {
const val = String(v) === '' ? '__EMPTY__' : String(v);
await sel.selectOption(val);
} else {
await sel.fill(String(v));
await sel.dispatchEvent('change');
}
await page.waitForTimeout(200);
};
// 错误用例: 引擎层拒绝已由桌面向量对拍覆盖; 网页端断言 UI 联动拦截行为
if (c.expectError === 'rule') {
// 末参数 = 违背规则的那个: 先设置其余参数, 该选项应已被联动剔除
for (let k = 0; k < keys.length - 1; k++) await setParam(keys[k], c.params[keys[k]]);
await page.waitForTimeout(300);
const lastP = keys[keys.length - 1];
const lastV = String(c.params[lastP]);
const opts = await page.$$eval('#sel-' + lastP + ' option', os => os.map(o => o.value));
check(tag + ' 规则联动剔除非法选项 ' + lastP + '=' + lastV + ' (实际选项: ' + opts.join(',') + ')',
opts.indexOf(lastV) < 0);
continue;
}
if (c.expectError === 'domain') {
for (let k = 0; k < keys.length - 1; k++) await setParam(keys[k], c.params[keys[k]]);
await page.waitForTimeout(300);
const lastP = keys[keys.length - 1];
await setParam(lastP, c.params[lastP]); // 输入越界值 → 处理函数拒绝
await page.waitForTimeout(300);
const title = await page.$eval('#modelTitle', el => el.textContent);
const status = await page.$eval('#status', el => el.textContent);
check(tag + ' 域外输入被拒绝 (标题: ' + title.trim() + ' | 状态: ' + status.trim() + ')',
title.indexOf('还缺') >= 0 || status.indexOf('超出范围') >= 0);
continue;
}
// 依次设置各参数 (枚举用 select; range 用输入框 + change)
for (const p of keys) await setParam(p, c.params[p]);
await page.waitForTimeout(400);
const title = await page.$eval('#modelTitle', el => el.textContent);
if (c.expectError) {
const noModel = title.indexOf('KC') < 0 || title.indexOf('还缺') >= 0 || title.indexOf('无对应型号') >= 0;
check(tag + ' 非法组合不生成型号 (标题: ' + title.trim() + ')', noModel);
continue;
}
const hasCode = title.indexOf(c.expectCode) >= 0;
check(tag + ' 型号编码 ' + c.expectCode + ' (标题: ' + title.trim() + ')', hasCode);
if (c.expectStandard) {
const dlOk = await page.$eval('#btnDownload', el => !el.classList.contains('hidden') && !el.disabled);
check(tag + ' 标准档位下载可用', dlOk);
} else {
const badge = await page.$eval('#modelTitle', el => !!el.querySelector('.nonstd-badge'));
// 生成服务未配置 → 置灰(一期过渡态); 已配置 → 在线生成按钮可用 (两种都是正确状态)
const dl = await page.$eval('#btnDownload', el => ({ hidden: el.classList.contains('hidden'), disabled: el.disabled, text: el.textContent }));
const dlOk = !dl.hidden && ((dl.disabled && dl.text.indexOf('下载 STEP') >= 0) ||
(!dl.disabled && dl.text.indexOf('在线生成') >= 0));
check(tag + ' 非标徽标显示 + 下载状态正确 (按钮: ' + dl.text + (dl.disabled ? '/disabled' : '/enabled') + ')', badge && dlOk);
if (c.expectNearest) {
const hint = await page.$eval('#viewer3dHint', el => el.textContent);
check(tag + ' 最近标准档位提示 ' + c.expectNearest + ' (提示: ' + hint.trim() + ')',
hint.indexOf(c.expectNearest) >= 0);
}
}
}
// 非标加入购物车 → 角标 + 抽屉标注 (与桌面语义一致)
await page.fill('#sel-stroke', '87');
await page.dispatchEvent('#sel-stroke', 'change');
await page.waitForTimeout(400);
await page.click('#btnAddCart');
await page.waitForTimeout(300);
await page.click('#btnCart');
await page.waitForTimeout(300);
const sub = await page.$eval('#cartList .cart-item .ci-sub', el => el.textContent);
check('购物车非标条目标注 (内容: ' + sub.trim() + ')', sub.indexOf('非标') >= 0);
await page.click('#cartClose');
await page.waitForTimeout(200);
// 标准档位回归: 换回 stroke=75 → 下载恢复可用
await page.fill('#sel-stroke', '75');
await page.dispatchEvent('#sel-stroke', 'change');
await page.waitForTimeout(400);
const dlRe = await page.$eval('#btnDownload', el => !el.disabled && el.getAttribute('href').indexOf('.step') >= 0);
check('标准档位回归: 下载恢复可用 (href=' + (await page.$eval('#btnDownload', el => el.getAttribute('href'))) + ')', dlRe);
console.log(out.join('\n'));
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败 | 页面错误: ' + (errs.length ? errs.join(' | ') : '无'));
await browser.close();
process.exit(fail ? 1 : 0);
})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });

34
tools/extract-pdf.py Normal file
View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# 提取 PDF 文本到 UTF-8 文件 (PyMuPDF), 并解码内嵌字体私有区数字
# 用法: python extract-pdf.py <pdf> <outdir> <起始页> <结束页(含)>
import fitz, sys, os
path, outdir = sys.argv[1], sys.argv[2]
start = int(sys.argv[3]); end = int(sys.argv[4])
doc = fitz.open(path)
os.makedirs(outdir, exist_ok=True)
# ONEBOT 手册: 数字被编码为私有区字符 0xF6B1(=0) ~ 0xF6BA(=9)
DIGITS = {0xF6B1 + i: str(i) for i in range(10)}
# 常见符号误提取 (按上下文谨慎映射)
SYMBOLS = {'÷': 'x', '': 'x', '·': '-'}
def decode(t):
out = []
for ch in t:
o = ord(ch)
if o in DIGITS:
out.append(DIGITS[o])
elif ch in SYMBOLS:
out.append(SYMBOLS[ch])
else:
out.append(ch)
return ''.join(out)
for i in range(start - 1, min(end, len(doc))):
text = decode(doc[i].get_text())
fn = os.path.join(outdir, "page_%03d.txt" % (i + 1))
with open(fn, "w", encoding="utf-8") as f:
f.write(text)
print("total_pages", len(doc))
print("extracted", start, "to", min(end, len(doc)), "->", outdir)

19
tools/fix-bom.ps1 Normal file
View File

@@ -0,0 +1,19 @@
# 给 .ps1/.cs 文件补上唯一的 UTF-8 BOM (PS 5.1 与 csc 对无 BOM 文件按 ANSI 读取, 中文会乱)
# 幂等: 先剥离已有 BOM 再加一个。本脚本本身纯 ASCII。
param([string[]]$Files)
if (-not $Files) {
$Files = @()
$Files += Get-ChildItem (Join-Path $PSScriptRoot '*.ps1') | ForEach-Object { $_.FullName }
$Files += Get-ChildItem (Join-Path $PSScriptRoot '..\src') -Filter '*.cs' -Recurse | ForEach-Object { $_.FullName }
}
foreach ($f in $Files) {
$bytes = [System.IO.File]::ReadAllBytes($f)
$i = 0
while (($i + 2) -lt $bytes.Length -and $bytes[$i] -eq 0xEF -and $bytes[$i + 1] -eq 0xBB -and $bytes[$i + 2] -eq 0xBF) { $i += 3 }
if ($i -gt 0) { $bytes = $bytes[$i..($bytes.Length - 1)] }
$out = New-Object byte[] ($bytes.Length + 3)
$out[0] = 0xEF; $out[1] = 0xBB; $out[2] = 0xBF
[Array]::Copy($bytes, 0, $out, 3, $bytes.Length)
[System.IO.File]::WriteAllBytes($f, $out)
Write-Output ("BOM fixed: " + $f)
}

38
tools/make-icon.ps1 Normal file
View File

@@ -0,0 +1,38 @@
# 生成应用图标 assets\icon.ico (System.Drawing 绘制 64x64 气缸简图 → PNG 内嵌 ICO)
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File tools\make-icon.ps1
param(
[string]$Out = (Join-Path $PSScriptRoot '..\assets\icon.ico')
)
Add-Type -AssemblyName System.Drawing
$bmp = New-Object System.Drawing.Bitmap(64, 64)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.Clear([System.Drawing.Color]::FromArgb(0x1B, 0x4F, 0x8C)) # 深蓝底
$blue = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(0x4A, 0x90, 0xD9))
$white = [System.Drawing.Brushes]::White
$g.FillRectangle($blue, 16, 18, 32, 38) # 缸体
$g.FillEllipse($blue, 16, 10, 32, 16) # 顶盖椭圆
$g.FillEllipse($blue, 16, 48, 32, 16) # 底盖椭圆
$g.FillRectangle($white, 28, 2, 8, 14) # 活塞杆
$g.FillRectangle($white, 18, 24, 4, 26) # 高光
# PNG 字节
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$png = $ms.ToArray()
$g.Dispose(); $bmp.Dispose(); $ms.Dispose()
# ICO 包装: 6 字节头 + 16 字节目录项 + PNG 数据
$outDir = Split-Path $Out
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$fs = [System.IO.File]::Create($Out)
$w = New-Object System.IO.BinaryWriter($fs)
$w.Write([UInt16]0); $w.Write([UInt16]1); $w.Write([UInt16]1) # 保留/类型/数量
$w.Write([byte]64); $w.Write([byte]64); $w.Write([byte]0); $w.Write([byte]0) # 宽高
$w.Write([UInt16]1); $w.Write([UInt16]32) # 平面/位深
$w.Write([UInt32]$png.Length); $w.Write([UInt32]22) # 数据大小/偏移
$w.Write($png)
$w.Close()
Write-Output ("图标: " + $Out)

86
tools/make-opc.ps1 Normal file
View File

@@ -0,0 +1,86 @@
# 从 sample-data 生成 .opc 数据包 (zip: catalog.json + assets)
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File tools\make-opc.ps1
# 注: M2 起由 Builder 在维护入口内完成此步骤; 本脚本为 M1 的临时打包器。
# catalog.json 手工拼接 (PS 哈希表直接交给 .NET 序列化器会有 PSObject 循环引用问题)。
param(
[string]$DataDir = (Join-Path $PSScriptRoot '..\..\sample-data\onb-sc'),
[string]$Out = (Join-Path $PSScriptRoot '..\sample\SampleCatalog_2026.08.opc')
)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$csv = Join-Path $DataDir 'params.csv'
if (-not (Test-Path $csv)) { Write-Output "找不到 $csv"; exit 1 }
# 1) 变体 (来自参数表): SC + MAQ 两个系列
$varJson = @()
foreach ($row in (Get-Content $csv | Select-Object -Skip 1)) {
if ([string]::IsNullOrWhiteSpace($row)) { continue }
$c = $row.Split(',')
$varJson += ('{"modelCode":"' + $c[0] + '","params":{"bore":' + $c[1] + ',"stroke":' + $c[2] +
',"magnet":"' + $c[3] + '","mount":"' + $c[4] + '"},"step":"step/' + $c[5] + '"}')
}
$varJsonM = @()
foreach ($row in (Get-Content (Join-Path $DataDir 'params_maq.csv') | Select-Object -Skip 1)) {
if ([string]::IsNullOrWhiteSpace($row)) { continue }
$c = $row.Split(',')
$varJsonM += ('{"modelCode":"' + $c[0] + '","params":{"bore":' + $c[1] + ',"stroke":' + $c[2] +
',"magnet":"' + $c[3] + '","mount":"' + $c[4] + '"},"step":"step/' + $c[5] + '"}')
}
# 2) 系列元数据 — 与 sample-data\onb-sc\coding-rules.md 保持一致
$paramsJson = @(
'{"code":"bore","nameZh":"缸径","nameEn":"Bore","unit":"mm","type":"number","keywords":"gangjing qigang","values":[16,25,32,40],"display":{}}',
'{"code":"stroke","nameZh":"行程","nameEn":"Stroke","unit":"mm","type":"number","keywords":"xingcheng","values":[25,50,75,100],"display":{}}',
'{"code":"magnet","nameZh":"磁石","nameEn":"Magnet","unit":"","type":"enum","keywords":"cishi","values":["","S"],"display":{"":"无 None","S":"带磁石 S"}}',
'{"code":"mount","nameZh":"安装方式","nameEn":"Mounting","unit":"","type":"enum","keywords":"anzhuang","values":["FA","LB","CB"],"display":{"FA":"前法兰 FA","LB":"脚座 LB","CB":"中摆 CB"}}'
)
$rulesJson = @(
'{"If":{"Param":"bore","Op":"eq","Value":16},"Then":{"Param":"stroke","Op":"in","Value":[25,50]}}',
'{"If":{"Param":"magnet","Op":"eq","Value":"S"},"Then":{"Param":"bore","Op":"in","Value":[25,32,40]}}',
'{"If":{"Param":"mount","Op":"eq","Value":"CB"},"Then":{"Param":"stroke","Op":"in","Value":[25,50,75]}}'
)
$attJson = @(
'{"kind":"dimDrawing","lang":"zh","path":"docs/dim-drawing/SC32x50S-LB.png","model":"SC32x50S-LB"}',
'{"kind":"dimDrawing","lang":"zh","path":"docs/dim-drawing/SC16x25-FA.png","model":"SC16x25-FA"}',
'{"kind":"datasheet","lang":"zh","path":"docs/datasheet/SC_datasheet_zh.pdf"}',
'{"kind":"datasheet","lang":"en","path":"docs/datasheet/SC_datasheet_en.pdf"}'
)
# MAQ 系列元数据 (迷你气缸, 无附件)
$paramsJsonM = @(
'{"code":"bore","nameZh":"缸径","nameEn":"Bore","unit":"mm","type":"number","keywords":"gangjing","values":[6,10,16],"display":{}}',
'{"code":"stroke","nameZh":"行程","nameEn":"Stroke","unit":"mm","type":"number","keywords":"xingcheng","values":[10,20,30,40],"display":{}}',
'{"code":"magnet","nameZh":"磁石","nameEn":"Magnet","unit":"","type":"enum","keywords":"cishi","values":["","S"],"display":{"":"无 None","S":"带磁石 S"}}',
'{"code":"mount","nameZh":"安装方式","nameEn":"Mounting","unit":"","type":"enum","keywords":"anzhuang","values":["FA","LB"],"display":{"FA":"前法兰 FA","LB":"脚座 LB"}}'
)
$rulesJsonM = @(
'{"If":{"Param":"bore","Op":"eq","Value":6},"Then":{"Param":"stroke","Op":"in","Value":[10,20]}}',
'{"If":{"Param":"magnet","Op":"eq","Value":"S"},"Then":{"Param":"bore","Op":"in","Value":[10,16]}}'
)
$seriesJsonM = '{"code":"MAQ","nameZh":"MAQ 系列迷你气缸","nameEn":"MAQ Series Mini Cylinder","keywords":"mini minixing qigang","parameters":[' + ($paramsJsonM -join ',') + '],"rules":[' + ($rulesJsonM -join ',') + '],"naming":{"stepNameTemplate":"{model}.step"},"attachments":[],"variants":[' + ($varJsonM -join ',') + ']}'
$json = '{"schemaVersion":"1.0","catalogName":"欧霓博气动目录","catalogNameEn":"OUNIBO Pneumatic Catalog",' +
'"catalogVersion":"2026.08","defaultLang":"zh","langs":["zh","en"],' +
'"categories":[{"code":"ACT","nameZh":"气动执行元件","nameEn":"Pneumatic Actuators","series":["SC","MAQ"]}],' +
'"series":[{"code":"SC","nameZh":"SC 系列标准气缸","nameEn":"SC Series Standard Cylinder",' +
'"parameters":[' + ($paramsJson -join ',') + '],' +
'"rules":[' + ($rulesJson -join ',') + '],' +
'"naming":{"stepNameTemplate":"{model}.step"},' +
'"attachments":[' + ($attJson -join ',') + '],' +
'"variants":[' + ($varJson -join ',') + ']},' +
$seriesJsonM + ']}'
# 3) 组装临时目录 → zip
$tmp = Join-Path $env:TEMP ('opc_' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
Copy-Item -Recurse -Force (Join-Path $DataDir '*') $tmp | Out-Null
[System.IO.File]::WriteAllText((Join-Path $tmp 'catalog.json'), $json, (New-Object System.Text.UTF8Encoding($true)))
$outDir = Split-Path $Out
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
if (Test-Path $Out) { Remove-Item $Out -Force }
[System.IO.Compression.ZipFile]::CreateFromDirectory($tmp, $Out)
Remove-Item -Recurse -Force $tmp
Write-Output ("数据包: " + $Out + " (" + $varJson.Count + " 个变体, catalog.json " + $json.Length + " 字节)")

89
tools/publish.ps1 Normal file
View File

@@ -0,0 +1,89 @@
# 一键编译发布 (M3): 校验数据包 → 编译客户版 exe (数据内置) → 生成光盘/USB 目录 + 增量更新包
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File tools\publish.ps1 [-Opc 数据包.opc] [-Version 2026.08]
param(
[string]$Opc = (Join-Path $PSScriptRoot '..\sample\OnebotCatalog_2026.08.opc'),
[string]$Version = '2026.08'
)
$root = Split-Path $PSScriptRoot -Parent
$exeDev = Join-Path $root 'bin\OnebotCatalog.exe'
$exeCus = Join-Path $root 'bin\OnebotCatalog-Customer.exe'
$selftestLog = Join-Path (Split-Path $Opc -Parent) 'selftest.log'
# 1) 数据包校验 (校验不过不允许发布)
if (-not (Test-Path $Opc)) { Write-Output "数据包不存在: $Opc"; exit 1 }
if (-not (Test-Path $exeDev)) { Write-Output "开发版不存在, 请先 build.ps1"; exit 1 }
Start-Process -FilePath $exeDev -ArgumentList @('--selftest', $Opc) -Wait | Out-Null
if (-not (Test-Path $selftestLog)) { Write-Output "自测日志缺失, 发布中止"; exit 1 }
$log = Get-Content $selftestLog -Raw -Encoding UTF8
if ($log -match '结果: (\d+) 通过, (\d+) 失败') {
$failCount = [int]$Matches[2]
if ($failCount -gt 0) { Write-Output "自测未通过 ($failCount 失败), 发布中止"; exit 1 }
Write-Output ("[1/4] 数据包自测通过: " + $Matches[1] + "")
} else { Write-Output "自测日志格式异常, 发布中止"; exit 1 }
# 2) 编译客户版 (CUSTOMER_BUILD + 嵌入数据 + 图标)
& powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'build.ps1') -Customer -Opc $Opc
if (-not (Test-Path $exeCus)) { Write-Output "客户版编译失败"; exit 1 }
Write-Output "[2/4] 客户版编译完成"
# 3) 发布目录 (光盘/USB 结构)
$relDir = Join-Path $root ("release\OnebotCatalog_" + $Version)
New-Item -ItemType Directory -Force -Path $relDir | Out-Null
Copy-Item $exeCus (Join-Path $relDir 'OnebotCatalog.exe') -Force
Copy-Item $Opc (Join-Path $relDir 'catalog.opc') -Force
# 开源 3D 渲染库 Helix Toolkit DLL (窗口内直渲 STL 网格用)
Get-ChildItem (Join-Path $root 'lib\*.dll') -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName $relDir -Force }
if (Test-Path (Join-Path $root 'assets\icon.ico')) { Copy-Item (Join-Path $root 'assets\icon.ico') $relDir -Force }
[System.IO.File]::WriteAllText((Join-Path $relDir 'autorun.inf'),
"[autorun]`r`nopen=OnebotCatalog.exe`r`nicon=OnebotCatalog.exe,0`r`n", [System.Text.Encoding]::ASCII)
[System.IO.File]::WriteAllText((Join-Path $relDir 'README_zh.txt'),
"欧霓博气动元件电子目录 " + $Version + "`r`n`r`n双击 OnebotCatalog.exe 运行 (Win10/11 免安装)。`r`nWin10/11 默认禁用自动播放, 请手动双击运行。`r`n`r`n目录更新: 用新版本 catalog.opc 替换本目录中的同名文件即可。`r`n`r`n常见问题: 若杀毒软件拦截, 请将程序加入白名单 (本软件无签名)。`r`n", (New-Object System.Text.UTF8Encoding($true)))
[System.IO.File]::WriteAllText((Join-Path $relDir 'README_en.txt'),
"ONEBOT Pneumatic Catalog " + $Version + "`r`n`r`nDouble-click OnebotCatalog.exe to run (no installation, Win10/11).`r`nAutoplay is disabled by default on Win10/11 - run the exe manually.`r`n`r`nUpdate: replace catalog.opc with the new version.`r`n`r`nNote: unsigned software may trigger antivirus warnings - add to allow list if needed.`r`n", (New-Object System.Text.UTF8Encoding($true)))
Write-Output "[3/4] 发布目录: $relDir"
# 3.5) 附带本机 3D 查看器组件 (真实数模浏览器预览用; 剔除 samples/uploads/step 非必要目录, ~8MB)
$vSrc = Join-Path $root 'viewer\STEPViewer'
if (Test-Path $vSrc) {
$vDst = Join-Path $relDir 'viewer\STEPViewer'
New-Item -ItemType Directory -Force -Path $vDst | Out-Null
Copy-Item (Join-Path $vSrc '*.html'), (Join-Path $vSrc '*.js'), (Join-Path $vSrc '*.css'), (Join-Path $vSrc '*.ico'), (Join-Path $vSrc '*.txt') $vDst -Force -ErrorAction SilentlyContinue
foreach ($sub in @('icons', 'libs', 'api')) {
Copy-Item (Join-Path $vSrc $sub) $vDst -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Output " 查看器组件已附带 (~$([Math]::Round((Get-ChildItem $vDst -Recurse -File | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)"
}
# 4) 增量更新包 (仅数据包)
$updDir = Join-Path $root ("release\update_" + $Version)
New-Item -ItemType Directory -Force -Path $updDir | Out-Null
Copy-Item $Opc (Join-Path $updDir 'catalog.opc') -Force
Write-Output "[4/5] 增量更新包: $updDir (仅替换 catalog.opc 即可升级)"
# 5) 在线发布 (静态站点): 解包 .opc 数据 + 前端页面 → 整目录上传即上线
# 发布前必须先停 serve-web (占用站点目录文件句柄); 删除带重试, 防句柄未释放的竞态
Add-Type -AssemblyName System.IO.Compression.FileSystem
$webDir = Join-Path $root ("release\web_" + $Version)
for ($try = 0; $try -lt 6 -and (Test-Path $webDir); $try++) {
try { Remove-Item -Recurse -Force $webDir -ErrorAction Stop }
catch { Start-Sleep -Seconds 2 }
}
if (Test-Path $webDir) { Write-Output "站点目录删除失败 (被占用), 请关闭预览服务器/浏览器后重试"; exit 1 }
New-Item -ItemType Directory -Force -Path $webDir | Out-Null
[System.IO.Compression.ZipFile]::ExtractToDirectory($Opc, $webDir)
Copy-Item (Join-Path $root 'web\*') $webDir -Recurse -Force
Write-Output "[5/5] 在线站点: $webDir (整目录上传到网站根目录即上线; 本地预览: tools\serve-web.ps1)"
# 6) 网格预览侧车 (窗口内 3D 开源 Helix 渲染用): 源 onebot-data\catalog\mesh → 开发包旁 sample\mesh + 客户目录 mesh\
$meshSrc = Join-Path $root 'onebot-data\catalog\mesh'
if (Test-Path $meshSrc) {
$meshDev = Join-Path (Split-Path $Opc -Parent) 'mesh'
robocopy $meshSrc $meshDev /MIR /NJH /NJS /NFL /NDL | Out-Null
$meshRel = Join-Path $relDir 'mesh'
robocopy $meshSrc $meshRel /MIR /NJH /NJS /NFL /NDL | Out-Null
Write-Output ("[6/6] 网格预览侧车已同步 (窗口内 3D): mesh\ " + (Get-ChildItem $meshSrc -File).Count + " 文件")
}
Get-ChildItem $relDir | ForEach-Object { Write-Output (" " + $_.Name + " " + [Math]::Round($_.Length / 1KB) + " KB") }
Write-Output "发布完成 ✔ (离线 + 增量 + 在线三份产物)"

111
tools/serve-web.ps1 Normal file
View File

@@ -0,0 +1,111 @@
# 本地预览服务器 (零依赖 TcpListener + Runspace 池并发): 服务在线站点静态目录
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File tools\serve-web.ps1 [-Root release\web_2026.08] [-Port 8080]
# 并发: 每个请求一个 Runspace (池上限 16), 大文件 (wasm/图片) 不再阻塞小文件 (step 下载/页面)
param(
[string]$Root = (Join-Path $PSScriptRoot '..\release\web_2026.08'),
[int]$Port = 8080,
[string]$ViewerRoot = (Join-Path $PSScriptRoot '..\viewer') # /viewer/ 前缀 → 新迪查看器本地副本 (可选; 原项目 Z:\...\上海新迪3D\dev 只读)
)
if (-not (Test-Path $Root)) { Write-Output "目录不存在: $Root"; exit 1 }
$viewerRootFull = ''
if ($ViewerRoot -and (Test-Path $ViewerRoot)) {
$viewerRootFull = [System.IO.Path]::GetFullPath($ViewerRoot)
Write-Output ("查看器目录: " + $viewerRootFull)
}
$mime = @{
'.html' = 'text/html; charset=utf-8'; '.js' = 'application/javascript; charset=utf-8'
'.css' = 'text/css; charset=utf-8'; '.json' = 'application/json; charset=utf-8'
'.png' = 'image/png'; '.jpg' = 'image/jpeg'; '.pdf' = 'application/pdf'
'.step' = 'application/octet-stream'; '.ico' = 'image/x-icon'; '.txt' = 'text/plain; charset=utf-8'
'.wasm' = 'application/wasm'; '.stp' = 'application/octet-stream'
}
$rootFull = [System.IO.Path]::GetFullPath($Root)
# 单请求处理逻辑 (自包含, 在 Runspace 中执行)
$handlerText = @'
param($client, $rootFull, $viewerRootFull, $mime)
try {
$stream = $client.GetStream()
$stream.ReadTimeout = 2000 # 预连接/空连接快速失败, 不阻塞后续请求
$stream.WriteTimeout = 30000
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII, $false, 4096, $true)
$reqLine = $reader.ReadLine()
if ($null -eq $reqLine) { $client.Close(); return } # 客户端连上不发数据, 直接断开
# 读掉请求头 (EOF 即 null 时必须退出, 否则死循环)
$line = $reader.ReadLine()
$guard = 0
while ($line -ne $null -and $line -ne '') {
$line = $reader.ReadLine()
$guard++
if ($guard -gt 100) { break }
}
$path = '/'
if ($reqLine) {
$parts = $reqLine -split ' '
if ($parts.Count -ge 2) { $path = $parts[1] }
}
# 去掉查询串 (?v= / ?file= 等), 否则会进文件路径导致非法字符异常
$qi = $path.IndexOf('?')
if ($qi -ge 0) { $path = $path.Substring(0, $qi) }
if ($path -eq '/') { $path = '/index.html' }
$full = [System.IO.Path]::GetFullPath((Join-Path $rootFull ($path -replace '/', '\').TrimStart('\')))
$bytes = $null; $status = '200 OK'; $ctype = 'application/octet-stream'
# /viewer/ 前缀 → 新迪 3D 查看器根目录 (各自防目录穿越)
$rootNow = $rootFull
$rel = ($path -replace '/', '\').TrimStart('\')
if ($viewerRootFull -and $rel.StartsWith('viewer\', [System.StringComparison]::OrdinalIgnoreCase)) {
$rootNow = $viewerRootFull
$rel = $rel.Substring('viewer\'.Length)
$full = [System.IO.Path]::GetFullPath((Join-Path $rootNow $rel))
}
if ($full.StartsWith($rootNow, [System.StringComparison]::OrdinalIgnoreCase) -and (Test-Path $full -PathType Leaf)) {
$bytes = [System.IO.File]::ReadAllBytes($full)
$ext = [System.IO.Path]::GetExtension($full).ToLowerInvariant()
if ($mime.ContainsKey($ext)) { $ctype = $mime[$ext] }
} else {
$status = '404 Not Found'
$bytes = [System.Text.Encoding]::UTF8.GetBytes('404 Not Found')
$ctype = 'text/plain; charset=utf-8'
}
$head = "HTTP/1.1 $status`r`nContent-Type: $ctype`r`nCache-Control: no-cache`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n"
$headBytes = [System.Text.Encoding]::ASCII.GetBytes($head)
$stream.Write($headBytes, 0, $headBytes.Length)
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush()
} catch { }
$client.Close()
'@
# Runspace 池 (并发上限 16)
$pool = [runspacefactory]::CreateRunspacePool(1, 16)
$pool.Open()
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
$listener.Start()
Write-Output ("服务已启动: http://localhost:" + $Port + " (停止: 关闭本窗口)")
while ($true) {
$client = $null
try {
$client = $listener.AcceptTcpClient()
} catch {
# 监听异常不退出进程 (端口占用等), 2 秒后重试
Start-Sleep -Seconds 2
continue
}
try {
$ps = [powershell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript($handlerText)
[void]$ps.AddArgument($client)
[void]$ps.AddArgument($rootFull)
[void]$ps.AddArgument($viewerRootFull)
[void]$ps.AddArgument($mime)
[void]$ps.BeginInvoke()
} catch {
$client.Close()
}
}

38
tools/ui-diagnose.ps1 Normal file
View File

@@ -0,0 +1,38 @@
# 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')
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
$asm = [Reflection.Assembly]::LoadFrom($Exe)
$pkgType = $asm.GetType('Ounibo.Catalog.Core.OpcPackage')
$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')
foreach ($s in $pkg.Catalog.series) {
try {
$ls.Invoke($mw, @($s)) | Out-Null
$boxes = $boxesField.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)
}
Write-Output ("系列 " + $s.code + " 下拉数据源: " + ($parts -join ' '))
} catch {
Write-Output ("系列 " + $s.code + " 异常: " + $_.Exception.InnerException.Message)
}
}
$pkg.Dispose()

53
tools/ui-diagnose2.ps1 Normal file
View File

@@ -0,0 +1,53 @@
# UI 诊断2: 模拟用户点击下拉框 (设置 SelectedItem → 触发 SelectionChanged → RefreshOptions)
# 检查选中值是否保持、_sel 是否记录、状态栏是否更新。
# 用法: powershell -STA -NoProfile -ExecutionPolicy Bypass -File tools\ui-diagnose2.ps1
param(
[string]$Exe = (Join-Path $PSScriptRoot '..\bin\OnebotCatalog.exe'),
[string]$Opc = (Join-Path $PSScriptRoot '..\sample\OnebotCatalog_2026.08.opc')
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName WindowsBase
$asm = [Reflection.Assembly]::LoadFrom($Exe)
$pkgType = $asm.GetType('Ounibo.Catalog.Core.OpcPackage')
$pkg = $pkgType.GetMethod('Open').Invoke($null, @($Opc))
$mwType = $asm.GetType('Ounibo.Catalog.App.MainWindow')
$mw = [Activator]::CreateInstance($mwType, @($pkg, $false))
$flags = [Reflection.BindingFlags]'NonPublic,Instance'
$ls = $mwType.GetMethod('LoadSeries', $flags)
$boxesField = $mwType.GetField('_boxes', $flags)
$selField = $mwType.GetField('_sel', $flags)
$statusField = $mwType.GetField('_status', $flags)
$titleField = $mwType.GetField('_modelTitle', $flags)
$kc = $null
foreach ($sx in $pkg.Catalog.series) { if ($sx.code -eq 'KC') { $kc = $sx; break } }
$ls.Invoke($mw, @($kc)) | Out-Null
$boxes = $boxesField.GetValue($mw)
$sel = $selField.GetValue($mw)
Write-Output ("KC 参数框: " + (($boxes.Keys | ForEach-Object { $_ }) -join ' '))
# 模拟点击第一个参数框 (型号) 的第一项
$cbType = $boxes['type']
Write-Output ("点击前 type SelectedItem: " + $(if ($cbType.SelectedItem) { $cbType.SelectedItem.ToString() } else { 'null' }))
$firstItem = $cbType.Items[0]
$cbType.SelectedItem = $firstItem
Write-Output ("点击后 type SelectedItem: " + $(if ($cbType.SelectedItem) { $cbType.SelectedItem.ToString() } else { 'null' }))
Write-Output ("_sel 内容: " + (($sel.Keys | ForEach-Object { $_ + '=' + $sel[$_] }) -join ' '))
# 再点第二个框 (缸径) 的第一项
$cbBore = $boxes['bore']
$cbBore.SelectedItem = $cbBore.Items[0]
Write-Output ("bore SelectedItem: " + $(if ($cbBore.SelectedItem) { $cbBore.SelectedItem.ToString() } else { 'null' }))
Write-Output ("_sel 内容: " + (($sel.Keys | ForEach-Object { $_ + '=' + $sel[$_] }) -join ' '))
# 状态栏与标题
$status = $statusField.GetValue($mw)
$title = $titleField.GetValue($mw)
Write-Output ("状态栏: " + $status.Text)
Write-Output ("标题: " + $title.Text)
$pkg.Dispose()