首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)
This commit is contained in:
2
tools/build-genserver.bat
Normal file
2
tools/build-genserver.bat
Normal file
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-genserver.ps1"
|
||||
34
tools/build-genserver.ps1
Normal file
34
tools/build-genserver.ps1
Normal 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
90
tools/build.ps1
Normal 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
63
tools/e2e-cart.js
Normal 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
90
tools/e2e-gen.js
Normal 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
130
tools/e2e-parametric.js
Normal 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
34
tools/extract-pdf.py
Normal 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
19
tools/fix-bom.ps1
Normal 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
38
tools/make-icon.ps1
Normal 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
86
tools/make-opc.ps1
Normal 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
89
tools/publish.ps1
Normal 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
111
tools/serve-web.ps1
Normal 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
38
tools/ui-diagnose.ps1
Normal 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
53
tools/ui-diagnose2.ps1
Normal 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()
|
||||
Reference in New Issue
Block a user