Compare commits

...

14 Commits

100 changed files with 9555 additions and 1885 deletions

View File

@@ -0,0 +1,39 @@
---
name: package-source-sha1
description: 为 massive-cloud-v3 生成可复现的纯源码 ZIP 包、内容清单和 40 位 SHA-1。用户要求打包整个项目、交付源码包、排除点云/CAD/数据库/构建产物/密钥,或计算项目 ZIP 哈希时使用。
---
# 打包源码并计算 SHA-1
使用 `scripts/package_source.py`,不要临时拼装 `find`、`tar` 或 `zip` 命令。
## 工作流
1. 根据项目 `AGENTS.md` 查看开发服务器 `/home/lipan/massive-cloud-v3` 的实时工作树和 `git status`。
2. 先运行 `--dry-run` 检查纳入、排除数量和排除原因;发现异常大文件或新的数据格式时,先更新脚本规则。
3. 运行正式打包。脚本只从 `git ls-files --cached --others --exclude-standard` 取候选文件,因此包含已跟踪文件和未忽略的新文件,也包含尚未提交的实际代码改动。
4. 将 ZIP、`.sha1` 和 `.manifest.json` 一起交付。明确说明包是否包含未提交改动,并报告基线提交。
5. 用 `unzip -t` 验证 ZIP;再独立运行 `sha1sum` 或 `shasum -a 1`,确认与 `.sha1` 一致且为 40 位小写十六进制。
## 固定规则
- 保留源码、测试、SQL、配置模板、依赖清单/锁文件、容器配置、脚本和文档。
- 排除 Git 历史、被 `.gitignore` 忽略的内容、真实 `.env`、密钥、日志、缓存、依赖目录和构建产物。
- 排除点云、CAD/网格、数据库、表格结果、归档包、模型权重等非源码文件。
- 不要使用 `git archive`:它会遗漏工作区中尚未提交的实际代码。
- ZIP 使用固定时间戳、固定顺序和规范权限;相同文件内容应生成相同哈希。
## 命令
```bash
python3 scripts/package_source.py \
--source /home/lipan/massive-cloud-v3 \
--output /tmp/massive-cloud-v3-source.zip \
--dry-run
python3 scripts/package_source.py \
--source /home/lipan/massive-cloud-v3 \
--output /tmp/massive-cloud-v3-source.zip
```
不要把产物写进被打包的源码目录;优先使用 `/tmp`,再复制到交付位置。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "源码包 SHA-1"
short_description: "可复现地打包项目纯源码并自动生成 SHA-1 校验值"
default_prompt: "使用 $package-source-sha1 打包当前项目纯源码并返回 ZIP 的 SHA-1。"

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Create a reproducible source-only ZIP and its SHA-1/manifest files."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import stat
import subprocess
import sys
import zipfile
from collections import Counter
from pathlib import Path, PurePosixPath
EXCLUDED_SUFFIXES = {
# Point clouds, CAD and meshes
".ply", ".pcd", ".las", ".laz", ".e57", ".xyz", ".pts",
".stp", ".step", ".iges", ".igs", ".obj", ".stl", ".glb", ".gltf",
# Databases and generated tabular results
".db", ".sqlite", ".sqlite3", ".mdb", ".accdb", ".xlsx", ".xls",
# Logs, caches, backups and generated archives
".log", ".pyc", ".pyo", ".bak", ".tmp", ".zip", ".7z", ".rar",
".tar", ".gz", ".bz2", ".xz",
# Model weights / binary artifacts
".pt", ".pth", ".onnx", ".ckpt", ".safetensors", ".bin",
# Private-key/container formats
".pem", ".key", ".p12", ".pfx", ".jks",
}
EXCLUDED_DIRS = {
".git", ".manus-logs", ".pnpm-store", "node_modules", "dist", "build",
"coverage", "results", "result", "outputs", "output", "__pycache__",
".pytest_cache", ".mypy_cache", ".ruff_cache", ".cache", "data", "datasets",
}
EXCLUDED_NAMES = {".env", "credentials.json", "secrets.json", "=1.10", "=3.1"}
FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0)
ZIP_ROOT = "massive-cloud-v3-source"
def git_output(root: Path, *args: str) -> bytes:
result = subprocess.run(
["git", "-C", str(root), *args], check=True, stdout=subprocess.PIPE
)
return result.stdout
def candidates(root: Path) -> list[str]:
raw = git_output(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z")
return sorted(os.fsdecode(item) for item in raw.split(b"\0") if item)
def exclusion_reason(relative: str) -> str | None:
path = PurePosixPath(relative)
lowered_parts = tuple(part.lower() for part in path.parts)
name = path.name.lower()
if any(part in EXCLUDED_DIRS for part in lowered_parts[:-1]):
return "excluded-directory"
if name in EXCLUDED_NAMES:
return "secret-or-artifact-name"
if name.startswith(".env.") and name not in {".env.example", ".env.template", ".env.sample"}:
return "environment-secret"
if path.suffix.lower() in EXCLUDED_SUFFIXES:
return "non-source-suffix"
return None
def sha1_file(path: Path) -> str:
digest = hashlib.sha1()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def git_text(root: Path, *args: str) -> str:
return git_output(root, *args).decode("utf-8", "replace").strip()
def build_manifest(root: Path, included: list[str], excluded: list[dict[str, str]]) -> dict:
return {
"format": "massive-cloud-source-package-v1",
"source_root": str(root),
"git_branch": git_text(root, "branch", "--show-current"),
"git_commit": git_text(root, "rev-parse", "HEAD"),
"git_status": git_text(root, "status", "--short"),
"included_count": len(included),
"included_files": included,
"excluded_count": len(excluded),
"excluded_files": excluded,
"rules": {
"candidate_source": "git ls-files --cached --others --exclude-standard",
"excluded_directories": sorted(EXCLUDED_DIRS),
"excluded_names": sorted(EXCLUDED_NAMES),
"excluded_suffixes": sorted(EXCLUDED_SUFFIXES),
},
}
def write_zip(root: Path, output: Path, included: list[str]) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
for relative in included:
source = root / relative
if not source.is_file() or source.is_symlink():
continue
info = zipfile.ZipInfo(f"{ZIP_ROOT}/{PurePosixPath(relative)}", FIXED_ZIP_TIME)
mode = source.stat().st_mode
normalized_mode = 0o755 if mode & stat.S_IXUSR else 0o644
info.create_system = 3
info.external_attr = (stat.S_IFREG | normalized_mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
with source.open("rb") as handle:
archive.writestr(info, handle.read(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True, help="Git working tree")
parser.add_argument("--output", type=Path, required=True, help="Output .zip path")
parser.add_argument("--dry-run", action="store_true", help="Print selection summary only")
args = parser.parse_args()
root = args.source.resolve()
output = args.output.resolve()
if output.suffix.lower() != ".zip":
parser.error("--output must end with .zip")
if not (root / ".git").exists():
parser.error(f"not a Git working tree: {root}")
try:
output.relative_to(root)
except ValueError:
pass
else:
parser.error("write the package outside the source tree")
selected: list[str] = []
excluded: list[dict[str, str]] = []
for relative in candidates(root):
reason = exclusion_reason(relative)
if reason:
excluded.append({"path": relative, "reason": reason})
elif (root / relative).is_file() and not (root / relative).is_symlink():
selected.append(relative)
reasons = Counter(item["reason"] for item in excluded)
print(f"source: {root}")
print(f"included: {len(selected)} files")
print(f"excluded: {len(excluded)} files ({dict(sorted(reasons.items()))})")
print(f"git commit: {git_text(root, 'rev-parse', 'HEAD')}")
status = git_text(root, "status", "--short")
print("working tree: dirty" if status else "working tree: clean")
if args.dry_run:
return 0
manifest = build_manifest(root, selected, excluded)
write_zip(root, output, selected)
digest = sha1_file(output)
manifest.update({"zip_file": output.name, "zip_size": output.stat().st_size, "sha1": digest})
sha_path = output.with_suffix(output.suffix + ".sha1")
manifest_path = output.with_suffix(output.suffix + ".manifest.json")
sha_path.write_text(f"{digest} {output.name}\n", encoding="ascii")
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"zip: {output}")
print(f"sha1: {digest}")
print(f"manifest: {manifest_path}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -59,7 +59,7 @@ MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
# 应用配置
VITE_APP_TITLE=宏量点云处理系统
VITE_APP_TITLE=宏量点云处理软件
EOF
# 或者手动编辑

View File

@@ -33,12 +33,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 设置构建时环境变量
ARG VITE_APP_TITLE="宏量点云处理系统"
ARG VITE_OAUTH_PORTAL_URL="disabled"
ARG VITE_APP_ID=""
ARG VITE_APP_TITLE="宏量点云处理软件"
ENV VITE_APP_TITLE=$VITE_APP_TITLE
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
ENV VITE_APP_ID=$VITE_APP_ID
# 构建应用
RUN pnpm build

View File

@@ -0,0 +1,259 @@
# 算法交付与测量结果输出规范
版本:1.0.0
状态:自 2026-08-13 起,新交付算法必须遵循;存量算法逐步迁移。
适用范围:`alg/` 下所有向宏量点云处理平台输出测量结果的算法。
## 1. 目的
本规范统一算法 `result.json` 中正式测量结果的表达,解决以下问题:
- 任务号被写在名称字符串里,例如 `任务46_车体横断面对角线差值`;
- 同一任务的多个测量位置无法分组,例如任务 5 的左梁和右梁;
- 头车、中车使用不同任务号,但结果中没有检测对象上下文;
- `dx_y`、`dx_z` 等计算中间量混入正式测量结果;
- 前端只能解析中文名称,无法稳定排序、筛选、统计和生成报告。
平台进程、进度事件、产物文件等接入方式仍参考
[`docs/algorithm-integration-spec.md`](../../docs/algorithm-integration-spec.md)。涉及测量结果字段时,**以本规范为准**。
## 2. 基本原则
1. **任务号结构化**:正式测量项必须提供 `taskNo`,不得只写进 `name`。
2. **名称不编码**:`name` 只写项目名称,不包含 `任务XX_`、左右位置或车型前缀。
3. **指标编码稳定**:每项结果必须有稳定的 `metricCode`,程序关联以它为准,不能依赖中文名称。
4. **一个测量值一条记录**:同一任务存在左/右、前/后等多个结果时,保留多条记录并使用 `position` 区分,`taskNo` 相同。
5. **正式结果与中间量隔离**:只有可交付、可判定、可进入报告的值才能进入 `measurements`;中间量放入 `extra.intermediateMetrics`。
6. **不猜任务号**:任务号必须来自经确认的业务任务清单。无法确定时应阻止正式交付并请求确认,不能按数组序号或名称相似度推断。
7. **值和单位分离**:`value` 必须是 JSON 数值或 `null`;禁止输出 `"0.073/700mm"` 这样的混合字符串。
8. **JSON 必须标准合法**:禁止 `NaN`、`Infinity`;失败且无有效值时使用 `null`。
## 3. 职责边界
### 3.1 业务/产品负责人
- 提供并确认正式任务清单:`taskNo`、项目名称、适用检测对象、理论值、公差、单位;
- 审批任务号变更,算法工程师不得自行重新编号。
### 3.2 平台/集成人员
- 在算法输入或适配层提供 `inspectionObject`,例如 `head`(头车)、`middle`(中车);
- 将经确认的业务任务清单配置为映射表;
- 校验最终 `result.json`,不让缺少任务号的正式结果进入业务表格。
### 3.3 算法工程师
- 为算法指标定义稳定的 `metricCode` 和准确的 `name`;
- 输出 `position` 等算法能够确定的测量位置;
- 在已获得检测对象和任务映射时输出正确的 `taskNo`;
- 只把最终可交付结果放进 `measurements`,中间量放进 `extra.intermediateMetrics`;
- 交付前运行本目录的校验脚本。
> 最终写入平台的 `result.json` 必须满足本规范。若算法核心包不负责业务任务号,平台薄适配层必须在写出 `result.json` 前补齐,不能交给前端猜测。
## 4. 顶层结构
```json
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"message": "测量完成",
"context": {
"inspectionObject": "head",
"inspectionObjectName": "头车"
},
"measurements": [],
"extra": {
"intermediateMetrics": []
}
}
```
字段说明:
| 字段 | 必填 | 说明 |
|---|---:|---|
| `schemaVersion` | 是 | 平台插件结果版本;当前平台存量插件仍使用 `1` |
| `measurementContractVersion` | 是 | 本测量结果规范版本,当前固定为 `"1.0"` |
| `status` | 是 | `completed` 或 `error` |
| `message` | 否 | 面向用户的简短说明 |
| `context` | 有对象差异时必填 | 本次测量的业务上下文 |
| `measurements` | 完成时必填 | 正式测量结果;允许空数组,不允许混入调试指标 |
| `extra` | 否 | 算法报告、质量信息、中间量等非正式结果 |
`schemaVersion` 与 `measurementContractVersion` 含义不同,不得互相替代。
## 5. 正式测量项 `measurements[]`
推荐完整结构:
```json
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "左梁",
"value": 0.265,
"unit": "mm",
"tolerance": 7.0,
"toleranceLabel": "±7 mm",
"status": "pass"
}
```
| 字段 | 必填 | 类型/取值 | 说明 |
|---|---:|---|---|
| `taskNo` | 是 | 字符串 | 正式业务任务号;即使全是数字也必须用字符串 |
| `metricCode` | 是 | 字符串 | 稳定指标编码,建议 `<域>.<部件>.<指标>`,仅用小写字母、数字、点、下划线和短横线 |
| `name` | 是 | 字符串 | 纯项目名称,不带 `任务XX_` 和位置前缀 |
| `position` | 多位置时必填 | 字符串 | 如 `左梁`、`右梁`、`一位端`;不适用时省略 |
| `value` | 是 | 数值或 `null` | 测量值;无有效结果时为 `null` |
| `unit` | 有量纲时必填 | 字符串 | 如 `mm`、`°`、`%`;无量纲使用 `"1"` |
| `theoreticalValue` | 否 | 数值 | 理论值,与 `unit` 相同 |
| `baseline` | 斜率形式时必填 | 正数 | 基准长度,单位为 mm |
| `tolerance` | 简单对称公差时使用 | 非负数 | `±tolerance`,与 `unit` 相同;斜率形式表示分子公差 |
| `toleranceLower` | 非对称公差时使用 | 数值 | 相对理论值的下偏差 |
| `toleranceUpper` | 非对称公差时使用 | 数值 | 相对理论值的上偏差 |
| `toleranceLabel` | 复杂公差时使用 | 字符串 | 报告展示原文,例如 `+8.5/-3 mm` |
| `status` | 是 | `pass` / `warning` / `fail` | 正式结果不得使用 `info` |
| `qualityFlags` | 否 | 字符串数组 | 质量告警代码,不替代 `status` |
约束:
- 同一 `taskNo + metricCode + position` 在一次结果中必须唯一;
- `name` 不得匹配 `^任务\s*\d+`;
- `status=pass|warning|fail` 必须基于已确认的公差规则;
- `value=null` 时不得判为 `pass`;
- 若尚未获得正式任务号,该项不得进入最终 `measurements`。
## 6. 检测对象与任务号映射
当同一指标在不同检测对象上对应不同任务号时,必须先根据 `context.inspectionObject` 选择任务号。
本次已确认的映射如下:
| 检测对象 | 指标 | `taskNo` |
|---|---|---:|
| 任意 | 纵向梁到直线的垂直偏差(左、右挠度) | 5 |
| 头车 `head` | 车钩面板到参考面的右偏转角 | 8 |
| 中车 `middle` | 车钩面板到参考面的右偏转角 | 9 |
| 头车 `head` | 车钩面板到纵向中心的右偏转角 | 10 |
| 中车 `middle` | 车钩面板到纵向中心的右偏转角 | 12 |
任务 5 的左右梁应输出两条记录:两条的 `taskNo` 和 `metricCode` 相同,`position` 分别为 `左梁`、`右梁`。
如果输入中没有头车/中车信息,车钩算法不得自行默认成头车;应报契约错误,或由平台适配层在具备检测对象信息后完成映射。
## 7. 斜率形式与角度值
车钩“右偏转角”目前存在两种可能的交付口径,必须由业务任务清单明确:
### 7.1 以角度交付
```json
{
"value": 0.0063,
"unit": "°",
"tolerance": 0.08
}
```
### 7.2 以偏差值/基准长度交付
```json
{
"value": 0.073,
"unit": "mm",
"baseline": 700,
"tolerance": 1.0,
"toleranceLabel": "1/700 mm"
}
```
禁止把 `value` 写成字符串 `"0.073/700mm"`。角度值和换算后的偏差值不能共用同一个 `metricCode`;如两者都需保留,一个作为正式结果,另一个放入中间量并使用不同编码。
## 8. 中间数据与诊断数据
以下数据通常不属于正式测量项:拟合系数、法向量、点数、残差、`dx_y`、`dx_z`、`D_y`、`D_z`、ICP fitness、RMSE 等。
统一放入:
```json
{
"extra": {
"intermediateMetrics": [
{
"metricCode": "coupler.panel.dx_y_max",
"name": "Y 方向计算用最大 X 偏差",
"value": 0.0101,
"unit": "mm"
}
]
}
}
```
中间量:
- 不需要 `taskNo`;
- 不设置业务 `status` 和公差;
- 不进入“算法结果”项数、合格率和正式报告;
- 可用于日志、排障和算法复核。
## 9. 禁止的旧写法
```json
{
"measurements": [
{ "name": "任务46_车体横断面对角线差值", "value": 0.592 },
{ "name": "左梁 · 纵向梁到直线的垂直偏差(挠度)", "value": 0.265 },
{ "name": "最大 x 偏差 dx_y", "value": 0.010, "status": "pass" }
]
}
```
问题分别是:任务号嵌入名称、位置嵌入名称、中间量冒充正式结果。
正确写法见 [`examples/head-car-coupler-and-deflection.result.json`](examples/head-car-coupler-and-deflection.result.json)。
## 10. 兼容与迁移
- 本规范是**目标输出契约**。当前平台前端尚未使用所有新增字段时,也必须保留这些字段,不能因此退回名称拼接方案;
- 存量 `任务46_xxx` 等结果允许历史回放,但算法下一版本应拆为 `taskNo="46"` 和纯 `name`;
- 平台适配层可以暂时从经确认的旧配置中迁移任务号,但不得长期正则解析中文名称;
- 修改任务号、单位、换算口径、公差或 `metricCode` 均属于接口变更,必须在算法版本说明中记录;
- 核心计算方法未变、只调整输出结构时,也应至少提升算法补丁版本并注明“输出契约迁移”。
## 11. 交付检查清单
算法工程师提交前逐项确认:
- [ ] `result.json` 是 UTF-8 标准 JSON,无 `NaN`/`Infinity`
- [ ] 顶层包含 `measurementContractVersion: "1.0"`
- [ ] 每个正式测量项都有 `taskNo`、`metricCode`、纯 `name`、`value`、`status`
- [ ] 多位置结果使用相同任务号并通过 `position` 区分
- [ ] 头车/中车任务号来自 `context.inspectionObject`,没有默认猜测
- [ ] `measurements` 中没有 `info` 状态和算法中间量
- [ ] 单位、公差、理论值和换算口径与正式任务清单一致
- [ ] `extra.intermediateMetrics` 中的指标不参与合格判定
- [ ] 已执行:`python3 validate_result.py result.json`
- [ ] 版本说明记录了输出字段变化
## 12. 校验方式
本目录提供两种校验:
```bash
# 零第三方依赖,执行本规范的关键业务规则校验
python3 validate_result.py /path/to/result.json
# 如本机安装了 jsonschema,可进行完整 JSON Schema 校验
python3 -m jsonschema -i /path/to/result.json result.schema.json
```
示例自检:
```bash
python3 validate_result.py examples/head-car-coupler-and-deflection.result.json
```

View File

@@ -0,0 +1,72 @@
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"message": "头车车钩偏转与左右梁挠度测量完成",
"context": {
"inspectionObject": "head",
"inspectionObjectName": "头车"
},
"measurements": [
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "左梁",
"value": 0.265,
"unit": "mm",
"tolerance": 7.0,
"toleranceLabel": "±7 mm",
"status": "pass"
},
{
"taskNo": "5",
"metricCode": "underframe.longitudinal_beam.vertical_deflection",
"name": "纵向梁到直线的垂直偏差(挠度)",
"position": "右梁",
"value": 0.270,
"unit": "mm",
"tolerance": 12.0,
"toleranceLabel": "±12 mm",
"status": "pass"
},
{
"taskNo": "8",
"metricCode": "coupler.panel.reference_plane.right_deflection",
"name": "车钩面板到参考面的右偏转角",
"value": 0.022,
"unit": "mm",
"baseline": 375.0,
"tolerance": 0.8,
"toleranceLabel": "0.8/375 mm",
"status": "pass"
},
{
"taskNo": "10",
"metricCode": "coupler.panel.longitudinal_center.right_deflection",
"name": "车钩面板到纵向中心的右偏转角",
"value": 0.073,
"unit": "mm",
"baseline": 700.0,
"tolerance": 1.0,
"toleranceLabel": "1/700 mm",
"status": "pass"
}
],
"extra": {
"intermediateMetrics": [
{
"metricCode": "coupler.panel.dx_y_max",
"name": "Y 方向计算用最大 X 偏差",
"value": 0.010,
"unit": "mm"
},
{
"metricCode": "coupler.panel.dx_z_max",
"name": "Z 方向计算用最大 X 偏差",
"value": 0.010,
"unit": "mm"
}
]
}
}

View File

@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"measurementContractVersion": "1.0",
"status": "completed",
"measurements": [
{
"name": "任务46_车体横断面对角线差值",
"metricCode": "vehicle.diagonal.delta",
"value": 0.592,
"unit": "mm",
"status": "info"
}
],
"extra": {
"intermediateMetrics": [
{
"taskNo": "debug-1",
"metricCode": "coupler.panel.dx_y_max",
"name": "最大 x 偏差 dx_y",
"value": 0.01,
"unit": "mm",
"status": "pass",
"tolerance": 1.0
}
]
}
}

View File

@@ -0,0 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://massive-cloud.local/schemas/algorithm-measurement-result-1.0.json",
"title": "宏量点云算法测量结果输出契约 1.0",
"type": "object",
"required": ["schemaVersion", "measurementContractVersion", "status"],
"properties": {
"schemaVersion": { "type": "integer", "minimum": 1 },
"measurementContractVersion": { "const": "1.0" },
"status": { "enum": ["completed", "error"] },
"message": { "type": "string" },
"context": {
"type": "object",
"properties": {
"inspectionObject": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]*$"
},
"inspectionObjectName": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
},
"measurements": {
"type": "array",
"items": { "$ref": "#/$defs/measurement" }
},
"errorStats": { "type": "object" },
"overlay3d": { "type": "object" },
"images": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"files": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"derivedPointClouds": { "type": "array" },
"extra": {
"type": "object",
"properties": {
"intermediateMetrics": {
"type": "array",
"items": { "$ref": "#/$defs/intermediateMetric" }
}
},
"additionalProperties": true
}
},
"allOf": [
{
"if": {
"properties": { "status": { "const": "completed" } },
"required": ["status"]
},
"then": { "required": ["measurements"] }
}
],
"additionalProperties": true,
"$defs": {
"metricCode": {
"type": "string",
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
},
"measurement": {
"type": "object",
"required": ["taskNo", "metricCode", "name", "value", "status"],
"properties": {
"taskNo": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
},
"metricCode": { "$ref": "#/$defs/metricCode" },
"name": {
"type": "string",
"minLength": 1,
"not": { "pattern": "^任务\\s*[0-9]+" }
},
"position": { "type": "string", "minLength": 1 },
"value": { "type": ["number", "null"] },
"unit": { "type": "string", "minLength": 1 },
"theoreticalValue": { "type": "number" },
"baseline": { "type": "number", "exclusiveMinimum": 0 },
"tolerance": { "type": "number", "minimum": 0 },
"toleranceLower": { "type": "number" },
"toleranceUpper": { "type": "number" },
"toleranceLabel": { "type": "string", "minLength": 1 },
"status": { "enum": ["pass", "warning", "fail"] },
"qualityFlags": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
}
},
"additionalProperties": true
},
"intermediateMetric": {
"type": "object",
"required": ["metricCode", "name", "value"],
"properties": {
"metricCode": { "$ref": "#/$defs/metricCode" },
"name": { "type": "string", "minLength": 1 },
"value": { "type": ["number", "null"] },
"unit": { "type": "string", "minLength": 1 }
},
"not": {
"anyOf": [
{ "required": ["taskNo"] },
{ "required": ["status"] },
{ "required": ["tolerance"] }
]
},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""算法测量结果契约 1.0 的零第三方依赖校验器。"""
from __future__ import annotations
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
METRIC_CODE_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
TASK_NO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
TASK_NAME_PREFIX_RE = re.compile(r"^任务\s*\d+")
FINAL_STATUSES = {"pass", "warning", "fail"}
def is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def validate(payload: Any) -> list[str]:
errors: list[str] = []
if not isinstance(payload, dict):
return ["根节点必须是 JSON 对象"]
if not isinstance(payload.get("schemaVersion"), int) or payload["schemaVersion"] < 1:
errors.append("schemaVersion 必须是大于等于 1 的整数")
if payload.get("measurementContractVersion") != "1.0":
errors.append('measurementContractVersion 必须为 "1.0"')
if payload.get("status") not in {"completed", "error"}:
errors.append("status 必须为 completed 或 error")
measurements = payload.get("measurements")
if payload.get("status") == "completed" and not isinstance(measurements, list):
errors.append("completed 结果必须包含 measurements 数组")
measurements = []
elif measurements is None:
measurements = []
elif not isinstance(measurements, list):
errors.append("measurements 必须是数组")
measurements = []
unique_keys: set[tuple[str, str, str]] = set()
for index, item in enumerate(measurements):
path = f"measurements[{index}]"
if not isinstance(item, dict):
errors.append(f"{path} 必须是对象")
continue
task_no = item.get("taskNo")
metric_code = item.get("metricCode")
name = item.get("name")
position = item.get("position", "")
value = item.get("value", ...)
status = item.get("status")
if not isinstance(task_no, str) or not TASK_NO_RE.fullmatch(task_no):
errors.append(f"{path}.taskNo 必须是非空规范字符串,数字任务号也要加引号")
if not isinstance(metric_code, str) or not METRIC_CODE_RE.fullmatch(metric_code):
errors.append(f"{path}.metricCode 格式无效")
if not isinstance(name, str) or not name.strip():
errors.append(f"{path}.name 必须是非空字符串")
elif TASK_NAME_PREFIX_RE.search(name):
errors.append(f"{path}.name 不得包含任务号前缀,请使用 taskNo 字段")
if position and not isinstance(position, str):
errors.append(f"{path}.position 必须是字符串")
if value is ... or (value is not None and not is_number(value)):
errors.append(f"{path}.value 必须是有限数值或 null")
if status not in FINAL_STATUSES:
errors.append(f"{path}.status 必须为 pass、warning 或 fail,正式结果不得使用 info")
if value is None and status == "pass":
errors.append(f"{path} 的 value 为 null 时不能判定为 pass")
if "baseline" in item and (not is_number(item["baseline"]) or item["baseline"] <= 0):
errors.append(f"{path}.baseline 必须是正数")
if "tolerance" in item and (not is_number(item["tolerance"]) or item["tolerance"] < 0):
errors.append(f"{path}.tolerance 必须是非负有限数值")
if isinstance(task_no, str) and isinstance(metric_code, str) and isinstance(position, str):
key = (task_no, metric_code, position)
if key in unique_keys:
errors.append(f"{path} 与其他正式测量项重复:taskNo + metricCode + position 必须唯一")
unique_keys.add(key)
extra = payload.get("extra", {})
if extra is not None and not isinstance(extra, dict):
errors.append("extra 必须是对象")
extra = {}
intermediate = extra.get("intermediateMetrics", []) if isinstance(extra, dict) else []
if not isinstance(intermediate, list):
errors.append("extra.intermediateMetrics 必须是数组")
intermediate = []
for index, item in enumerate(intermediate):
path = f"extra.intermediateMetrics[{index}]"
if not isinstance(item, dict):
errors.append(f"{path} 必须是对象")
continue
if any(field in item for field in ("taskNo", "status", "tolerance")):
errors.append(f"{path} 不得包含 taskNo、status 或 tolerance")
code = item.get("metricCode")
if not isinstance(code, str) or not METRIC_CODE_RE.fullmatch(code):
errors.append(f"{path}.metricCode 格式无效")
if not isinstance(item.get("name"), str) or not item["name"].strip():
errors.append(f"{path}.name 必须是非空字符串")
value = item.get("value", ...)
if value is ... or (value is not None and not is_number(value)):
errors.append(f"{path}.value 必须是有限数值或 null")
return errors
def main() -> int:
if len(sys.argv) != 2:
print(f"用法: {Path(sys.argv[0]).name} /path/to/result.json", file=sys.stderr)
return 2
path = Path(sys.argv[1])
try:
payload = json.loads(path.read_text(encoding="utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"非法数值 {value}")))
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"校验失败: 无法读取标准 JSON:{exc}", file=sys.stderr)
return 1
errors = validate(payload)
if errors:
print(f"校验失败,共 {len(errors)} 个问题:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"校验通过:{path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,45 @@
# 算法输出规范执行通知(转发模板)
各位算法同事:
为统一平台“算法结果”中的任务号、测量位置、单位、公差和中间数据,从现在起请新交付算法以及存量算法的后续版本遵循:
`alg/00_算法交付与输出规范/README.md`
目录内同时提供:
- `result.schema.json`:完整 JSON Schema;
- `validate_result.py`:零第三方依赖校验器;
- `examples/head-car-coupler-and-deflection.result.json`:正确示例;
- `examples/invalid-legacy-result.json`:禁止的旧写法反例。
本次最关键的变化:
1. 正式测量结果必须单独返回 `taskNo`,不再把任务号写在 `name` 中;
2. `name` 只保留纯项目名称;同一任务的左/右等多个结果使用 `position` 区分;
3. 每个指标必须提供稳定的 `metricCode`;
4. 头车/中车必须依据输入的检测对象选择任务号,不得默认猜测;
5. `dx_y`、`dx_z`、拟合残差、法向量等中间量不得进入正式 `measurements`,统一放入 `extra.intermediateMetrics`;
6. 正式结果只允许 `pass`、`warning`、`fail`,不再用 `info` 表示可交付项;
7. 交付前请执行:
```bash
python3 alg/00_算法交付与输出规范/validate_result.py /path/to/result.json
```
请各算法负责人先检查自己的输出,并回复以下信息:
- 指标与正式业务任务号的映射是否完整;
- 哪些输出是正式测量值,哪些只是计算中间量;
- 同一指标是否会因头车/中车等检测对象对应不同任务号;
- 单位、公差、理论值和换算口径是否与正式任务清单一致;
- 预计从哪个算法版本开始按新规范交付。
车钩与挠度算法请重点确认:
- 任务 5 的左梁、右梁作为同一任务的两个 `position` 输出;
- 头车车钩对应任务 8、10,中车对应任务 9、12;
- `dx_y`、`dx_z` 仅保留为中间量;
- “右偏转角”最终交付口径是角度 `°`,还是偏差值/基准长度(例如 `0.073/700 mm`)。
平台插件的运行、输入、进度和产物接入仍参考 `docs/algorithm-integration-spec.md`;测量结果字段发生冲突时,以新规范为准。

View File

@@ -322,6 +322,7 @@ class MeasurementPipeline:
excel_output_path: str | None = None,
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
save_combined_visualization: bool = True,
no_visualize: bool = False,
) -> dict[str, Any]:
batch_start_time = time.perf_counter()
@@ -443,7 +444,7 @@ class MeasurementPipeline:
combined_visualization_output = None
combined_pointclouds = []
if combined_visualizations:
if save_combined_visualization and combined_visualizations:
combined_visualization_output = self._contour_combined_save_path(save_path)
self._save_contour_combined_pointcloud_stream(
combined_visualizations,
@@ -627,6 +628,7 @@ class MeasurementPipeline:
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
result_col = self._find_header_index(headers, ["测量结果"])
error_col = self._find_header_index(headers, ["误差结果"])
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
result_by_task = {
@@ -648,6 +650,7 @@ class MeasurementPipeline:
continue
output_row[result_col] = self._row_measurement_mean(result_row)
output_row[error_col] = result_row.get("max")
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
table.append(output_row)
used_task_ids.add(task_id)
@@ -659,6 +662,7 @@ class MeasurementPipeline:
output_row = [""] * len(headers)
output_row[task_col] = task_id
output_row[result_col] = self._row_measurement_mean(result_row)
output_row[error_col] = result_row.get("max")
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
table.append(output_row)
@@ -729,7 +733,9 @@ class MeasurementPipeline:
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
status = str(row.get("status", "")).strip()
if status and status != "成功":
if status in {"成功", "部分成功", "失败"}:
return status
if status:
return "失败"
value = self._row_measurement_mean(row)

View File

@@ -0,0 +1,62 @@
from pathlib import Path
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import Mock
import tempfile
import numpy as np
from pipeline import MeasurementPipeline
class CombinedContourRetentionTest(TestCase):
def make_pipeline(self, root: Path) -> MeasurementPipeline:
mesh = root / "mesh.ply"
pointcloud = root / "points.ply"
mesh.touch()
pointcloud.touch()
pipeline = MeasurementPipeline.__new__(MeasurementPipeline)
pipeline._resolve_contour_task_inputs = Mock(return_value=[{
"task_id": "1",
"sample_id": "sample",
"cad_path": None,
"mesh_path": mesh,
"pointcloud_path": pointcloud,
}])
pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply")
pipeline.run_contour = Mock(return_value={
"distance_stats": SimpleNamespace(max=0.5),
"distances": np.array([0.1, 0.2]),
"pointcloud": object(),
})
pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply")
pipeline._save_contour_combined_pointcloud_stream = Mock()
pipeline._should_visualize = Mock(return_value=False)
return pipeline
def test_skips_combined_file_when_disabled(self) -> None:
with tempfile.TemporaryDirectory() as temp:
pipeline = self.make_pipeline(Path(temp))
result = pipeline.run_contour_batch(
all_tasks=True,
save_path=temp,
save_combined_visualization=False,
no_visualize=True,
)
self.assertIsNone(result["combined_visualization_output"])
pipeline._save_contour_combined_pointcloud_stream.assert_not_called()
def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None:
with tempfile.TemporaryDirectory() as temp:
pipeline = self.make_pipeline(Path(temp))
result = pipeline.run_contour_batch(
all_tasks=True,
save_path=temp,
save_combined_visualization=True,
no_visualize=True,
)
self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply")
pipeline._save_contour_combined_pointcloud_stream.assert_called_once()

View File

@@ -173,6 +173,33 @@ def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
return mesh
def load_cad_mesh(cad_path, mesh_deflection=0.1):
"""根据后缀加载 CAD mesh:STEP 走 OCC 离散化,PLY 直接读取三角网格。"""
ext = os.path.splitext(cad_path)[1].lower()
if ext in (".step", ".stp"):
return step_to_open3d_mesh(cad_path, mesh_deflection=mesh_deflection)
if ext == ".ply":
print(f"[INFO] Loading CAD mesh from PLY: {cad_path}")
mesh = o3d.io.read_triangle_mesh(cad_path)
if len(mesh.vertices) == 0 or len(mesh.triangles) == 0:
raise RuntimeError(f"PLY CAD mesh 为空或读取失败: {cad_path}")
mesh.remove_duplicated_vertices()
mesh.remove_duplicated_triangles()
mesh.remove_degenerate_triangles()
mesh.compute_vertex_normals()
print(
f"[OK] PLY CAD mesh loaded: {len(mesh.vertices)} vertices, "
f"{len(mesh.triangles)} triangles"
)
return mesh
raise RuntimeError(f"不支持的 CAD 格式: {cad_path}(仅支持 .step/.stp/.ply)")
# ============================================================
# 工具函数
# ============================================================
@@ -529,7 +556,7 @@ def translate_align_by_front_tail(
cad_pcd = None
if cad_pcd is None:
cad_mesh = step_to_open3d_mesh(
cad_mesh = load_cad_mesh(
cad_path,
mesh_deflection=mesh_deflection
)

View File

@@ -1,16 +1,33 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import { Redirect, Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import ChangePassword from "./pages/ChangePassword";
import Users from "./pages/Users";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
const { user, loading } = useAuth();
if (loading) return <DashboardLayoutSkeleton />;
if (!user) return <Redirect to="/login" />;
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
return <>{children}</>;
}
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path="/login" component={Login} />
<Route path="/change-password" component={ChangePassword} />
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
},
});
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
utils.auth.me.setData(undefined, undefined);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
isSuperAdmin: meQuery.data?.role === "super_admin",
};
}, [
meQuery.data,
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
window.location.href = redirectPath;
}, [
redirectOnUnauthenticated,
redirectPath,

View File

@@ -11,9 +11,10 @@
*/
import { useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, ListChecks, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { toast } from 'sonner';
import TrainMeasurementPanel from './TrainMeasurementPanel';
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
@@ -130,6 +131,19 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
},
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
});
const runDemoSelected = trpc.algorithm.runDemoSelected.useMutation({
onSuccess: ({ registrationJob, queuedMeasurementCount, selectedTaskNos }) => {
if (registrationJob) {
toast.success(`已提交自选 ${selectedTaskNos.length} 项;配准完成后将自动提交 ${queuedMeasurementCount} 个测量算法`);
}
if (taskId != null) {
void utils.analysisResult.list.invalidate({ taskId });
void utils.pointcloudFile.list.invalidate({ taskId });
}
if (modelId != null) void utils.inspectionTask.listByModel.invalidate({ modelId });
},
onError: (e) => toast.error(e.message || '选择任务运行提交失败'),
});
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
onSuccess: ({ cancelledIds }) => {
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
@@ -151,10 +165,15 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
const { data: demoTaskCatalog, isLoading: demoCatalogLoading } = trpc.algorithm.demoTaskCatalog.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, staleTime: 60_000 },
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
// 首次拉到 plugins 后用默认值初始化每个表单
useEffect(() => {
@@ -202,13 +221,13 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
...demoMeasurementPluginIds,
];
const demoAlgorithmCount = demoAllPluginIds.length;
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
const demoTaskCount = demoTaskCatalog?.total ?? 0;
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
const demoPluginIds = new Set(demoAllPluginIds);
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
const demoFlowRunning = runningDemoResults.length > 0;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending || runDemoSelected.isPending;
const selectedPcCount = selectedPcFileIds?.length ?? 0;
const selectedCadCount = selectedCadFileIds?.length ?? 0;
@@ -232,7 +251,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: null;
const registrationBlockReason =
baseRunBlockReason
@@ -257,6 +276,8 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
: demoCatalogLoading ? '正在加载任务目录'
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
: null);
const canRunDemoAll = demoBlockReason == null;
const demoRouteHint =
@@ -337,6 +358,20 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
});
};
const handleRunDemoSelected = (taskNos: number[]) => {
if (demoBlockReason) {
toast.error(demoBlockReason);
return;
}
if (taskId == null || !selectedPcFileIds?.[0] || !selectedCadFileIds?.[0]) return;
runDemoSelected.mutate({
taskId,
sourcePointCloudFileId: selectedPcFileIds[0],
cadFileId: selectedCadFileIds[0],
taskNos,
});
};
const handleCancelDemoAll = () => {
if (taskId == null || cancelDemoAll.isPending) return;
cancelDemoAll.mutate({ taskId });
@@ -353,7 +388,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
<Cpu className="w-3.5 h-3.5" />
一键全流程演示
</span>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{demoFlowRunning && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
运行中 {runningDemoResults.length}
@@ -373,18 +408,32 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
停止全流程
</button>
) : (
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRunDemoAll}
onClick={handleRunDemoAll}
title={demoBlockReason ?? demoRunTitle}
>
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
运行全部 {demoAlgorithmCount} 个算法
</button>
<>
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRunDemoAll}
onClick={handleRunDemoAll}
title={demoBlockReason ?? demoRunTitle}
>
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
运行全部任务({demoTaskCount}项)
</button>
<button
type="button"
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${
taskId != null && !demoCatalogLoading && demoTaskCount > 0 ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={taskId == null || demoCatalogLoading || demoTaskCount === 0}
onClick={() => setDemoTaskDialogOpen(true)}
title="选择正式任务并运行最小测量算法集合"
>
<ListChecks className="w-3 h-3" />
选择任务运行
</button>
</>
)}
</div>
</div>
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
/>
))}
</div>
<DemoTaskSelectionDialog
open={demoTaskDialogOpen}
onOpenChange={setDemoTaskDialogOpen}
items={demoTaskCatalog?.items ?? []}
total={demoTaskCount}
loading={demoCatalogLoading}
pending={runDemoSelected.isPending}
runBlockReason={demoBlockReason}
onRun={handleRunDemoSelected}
/>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView';
import { useAuth } from '@/_core/hooks/useAuth';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, sortMeasurements, statusClass, type Overlay3D } from './GenericResultView';
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -64,7 +66,19 @@ interface Props {
}) => void;
}
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
type Measurement = {
taskNo?: string;
metricCode?: string;
name: string;
position?: string;
value: number;
unit: string;
tolerance: number;
status: string;
baseline?: number;
legacyUnmapped?: boolean;
legacyOriginalName?: string;
};
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
@@ -108,23 +122,41 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
);
}
function MeasurementTable({ items }: { items: Measurement[] }) {
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
const showDetails = displayMode === 'maximized';
const sortedItems = sortMeasurements(items);
return (
<table className="w-full text-[10px] border-collapse">
<div className="overflow-x-auto">
<table className="w-full min-w-[360px] text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
{showDetails && <th className="text-right py-1 font-normal">值</th>}
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
<th className="text-right py-1 font-normal">结果</th>
</tr>
</thead>
<tbody>
{items.map((m, i) => (
{sortedItems.map((m, i) => (
<tr key={i} className="border-t border-border/40">
<td className="py-1">{m.name}</td>
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
<td className="py-1 pr-2 whitespace-nowrap">
{m.legacyUnmapped || !m.taskNo ? (
<span className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help" title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}>
待确认
</span>
) : m.taskNo}
</td>
<td className="py-1">
{m.name}
{m.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{m.position}
</span>
)}
</td>
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
@@ -138,6 +170,7 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
))}
</tbody>
</table>
</div>
);
}
@@ -230,9 +263,13 @@ type DemoFlowMeta = {
role?: string;
index?: number;
total?: number;
mode?: 'all' | 'selected';
selectedTaskNos?: number[];
selectedTaskCount?: number;
};
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
const SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
type AnalysisListItem =
| { kind: 'result'; result: AnalysisResultRow }
@@ -259,6 +296,11 @@ function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
index: numberFromUnknown(data.demoFlowIndex),
total: numberFromUnknown(data.demoFlowTotal),
mode: data.demoFlowMode === 'selected' ? 'selected' : data.demoFlowMode === 'all' ? 'all' : undefined,
selectedTaskNos: Array.isArray(data.demoFlowSelectedTaskNos)
? data.demoFlowSelectedTaskNos.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
: undefined,
selectedTaskCount: numberFromUnknown(data.demoFlowSelectedTaskCount),
};
}
@@ -316,9 +358,14 @@ function getDemoFlowTotal(results: AnalysisResultRow[]) {
}
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState<Set<string>>(new Set());
const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState<Set<string>>(new Set());
const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState<Set<string>>(new Set());
const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState<Set<string>>(new Set());
const [maximizedOpen, setMaximizedOpen] = useState(false);
const initializedDemoResultIds = useRef<Set<number>>(new Set());
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
@@ -401,10 +448,42 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
});
}
function renderResultCard(r: AnalysisResultRow, extraClassName = '') {
function toggleFlowResultCard(key: string) {
setCollapsedFlowResultCards((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
}
function toggleFlowArtifactCard(key: string) {
setExpandedFlowArtifactCards((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
}
function toggleFlowArtifactGroup(key: string) {
setExpandedFlowArtifactGroups((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
}
function toggleFlowArtifactExtra(key: string) {
setExpandedFlowArtifactExtras((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
}
function renderResultCard(r: AnalysisResultRow, variant: ResultListVariant, extraClassName = '') {
const expanded = expandedIds.has(r.id);
const errorStats = r.errorStats as ErrorStats | null;
const measurements = r.measurements as Measurement[] | null;
const measurements = filterVisibleMeasurements((r.measurements as Measurement[] | null) ?? []);
const resultTitle = getResultTitle(r, pluginNameById);
return (
@@ -445,7 +524,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<Play className="w-3 h-3 text-primary" />
</button>
)}
{isCancellableStatus(r.status) && (
{canWrite && isCancellableStatus(r.status) && (
<button
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
title="停止"
@@ -455,13 +534,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<Square className="w-3 h-3" />
</button>
)}
<button
{canWrite && <button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
>
<Trash2 className="w-3 h-3" />
</button>
</button>}
</span>
</div>
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
@@ -486,10 +565,10 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
{errorStats && <ErrorStatCard stats={errorStats} />}
{measurements && measurements.length > 0 && (
{measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
<MeasurementTable items={measurements} />
<MeasurementTable items={measurements} displayMode={variant} />
</div>
)}
</div>
@@ -677,6 +756,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
pluginId={r.pluginId ?? undefined}
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
displayMode={variant}
onViewOverlay={onViewPluginOverlay}
/>
</div>
@@ -685,20 +765,154 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
);
}
function renderFlowStatusNotice(notice: DemoFlowStatusNotice) {
const progress = notice.progress != null ? ` ${Math.round(notice.progress * 100)}%` : '';
return (
<div
key={notice.resultId}
className={`rounded px-2 py-1 text-[10px] ${notice.status === 'error' ? 'bg-red-950/30 text-red-300' : notice.status === 'cancelled' ? 'bg-amber-950/30 text-amber-300' : 'bg-secondary/50 text-muted-foreground'}`}
>
<span className="font-medium">{notice.algorithmName}</span>
<span> · {getStatusLabel(notice.status)}{progress}</span>
{notice.detail && <span> · {notice.detail}</span>}
</div>
);
}
function renderFlowArtifactGroup(artifact: DemoFlowArtifactGroup, cardKey: string) {
const overlay = normalizeOverlay3D(artifact.overlay);
const inputPointCloudFileIds = numberArrayFromUnknown(artifact.inputPointCloudFileIds);
const inputCadFileIds = numberArrayFromUnknown(artifact.inputCadFileIds);
const groupKey = `${cardKey}:${artifact.resultId}`;
const groupExpanded = expandedFlowArtifactGroups.has(groupKey);
const extraExpanded = expandedFlowArtifactExtras.has(groupKey);
const artifactCount = artifact.files.length + artifact.images.length
+ (overlay.kind !== 'none' ? 1 : 0)
+ (artifact.extra && Object.keys(artifact.extra).length > 0 ? 1 : 0);
return (
<section key={artifact.resultId} className="industrial-card rounded overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
onClick={() => toggleFlowArtifactGroup(groupKey)}
>
{groupExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<Cpu className="w-3.5 h-3.5 text-violet-400" />
<span className="text-[11px] font-medium text-foreground flex-1">{artifact.algorithmName}</span>
<span className="text-[9px] text-muted-foreground">{artifactCount} 项</span>
</button>
{groupExpanded && <div className="border-t border-border/40 p-2 space-y-2">
{artifact.files.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">报表下载</div>
<div className="space-y-1">
{artifact.files.map((file) => (
<a
key={file.path}
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(file.path)}`}
download
className="industrial-card p-1.5 flex items-center gap-1.5 hover:bg-secondary/40 text-[10px]"
title={file.path}
>
<FileSpreadsheet className="w-3 h-3 flex-shrink-0 text-green-500" />
<span className="truncate">{file.label}</span>
</a>
))}
</div>
</div>
)}
{artifact.images.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>
<div className="grid grid-cols-2 gap-1.5">
{artifact.images.map((image) => (
<a
key={image}
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(image)}`}
target="_blank"
rel="noreferrer"
className="industrial-card p-1 flex items-center gap-1 hover:bg-secondary/40 text-[10px]"
title={image}
>
<ImageIcon className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{image}</span>
</a>
))}
</div>
</div>
)}
{overlay.kind !== 'none' && onViewPluginOverlay && (
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={() => onViewPluginOverlay({
taskId,
resultId: artifact.resultId,
overlay,
inputPointCloudFileIds,
inputCadFileIds,
})}
>
<Box className="w-3 h-3" />
在 3D 中查看叠加层
</button>
)}
{artifact.extra && Object.keys(artifact.extra).length > 0 && (
<div>
<button
type="button"
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => toggleFlowArtifactExtra(groupKey)}
>
{extraExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
原始字段
</button>
{extraExpanded && (
<pre className="industrial-card p-2 mt-1 text-[9px] font-mono whitespace-pre-wrap break-all">
{JSON.stringify(artifact.extra, null, 2)}
</pre>
)}
</div>
)}
</div>}
</section>
);
}
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
const expanded = expandedFlowIds.has(group.runId);
const status = getDemoFlowStatus(group.results);
const total = getDemoFlowTotal(group.results);
const completed = group.results.filter((r) => r.status === 'completed').length;
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
const runningItems = group.results
.filter((r) => r.status === 'processing' || r.status === 'pending')
.slice(0, 2)
.map((r) => {
const data = asRecord(r.resultData);
const progress = numberFromUnknown(data?.progress);
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
});
const cardKey = `${variant}:${group.runId}`;
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
const flowMeta = group.results.map(readDemoFlowMeta).find((meta) => meta?.mode) ?? null;
const selectedTaskNos = flowMeta?.mode === 'selected' ? flowMeta.selectedTaskNos : undefined;
const selectedTaskCount = flowMeta?.selectedTaskCount ?? selectedTaskNos?.length ?? 0;
const flowTitle = flowMeta?.mode === 'selected'
? `全流程算法结果 · 自选 ${selectedTaskCount} 项`
: '全流程算法结果 · 全部任务';
const aggregated = aggregateDemoFlowResults(
group.results,
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS, selectedTaskNos },
);
const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS
? aggregated.tableRows
: sortMeasurements(aggregated.measurementRows.map((row) => ({
...row,
taskNo: row.measurement.taskNo,
position: row.measurement.position,
})));
const showMeasurementDetails = variant === 'maximized';
return (
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
@@ -708,7 +922,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
<span className="text-xs flex-1 min-w-0 truncate">全流程算法结果</span>
<span className="text-xs flex-1 min-w-0 truncate">{flowTitle}</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{completed}/{total} 完成
</span>
@@ -719,13 +933,110 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</div>
{expanded && (
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
{runningItems.length > 0 && (
<div className="text-[10px] text-muted-foreground truncate">
运行中:{runningItems.join('、')}
</div>
)}
<div className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
{group.results.map((result) => renderResultCard(result))}
<div className="industrial-card rounded overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
onClick={() => toggleFlowResultCard(cardKey)}
>
{resultsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<Ruler className="w-3.5 h-3.5 text-green-400" />
<span className="text-xs flex-1">算法结果</span>
<span className="text-[10px] text-muted-foreground">{aggregated.measurementRows.length} 项</span>
</button>
{resultsExpanded && (
<div className="border-t border-border/40">
{aggregated.statusNotices.length > 0 && (
<div className="space-y-1 p-2 pb-1">
{aggregated.statusNotices.map(renderFlowStatusNotice)}
</div>
)}
{aggregated.tableRows.length === 0 ? (
<div className="text-[10px] text-muted-foreground text-center py-4">暂无测量结果</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground border-b border-border/60">
<th className="text-left px-2 py-1.5 font-normal whitespace-nowrap">任务号</th>
<th className="text-left px-2 py-1.5 font-normal">项目</th>
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
<th className="text-right px-2 py-1.5 font-normal">结果</th>
</tr>
</thead>
<tbody>
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
<td colSpan={showMeasurementDetails ? 5 : 3} className="px-2 py-1 font-medium">{row.algorithmName}</td>
</tr>
) : (
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
<td className="px-2 py-1 whitespace-nowrap">
{row.measurement.legacyUnmapped || !row.measurement.taskNo ? (
<span
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
title={`旧算法项目尚未确认任务号:${row.measurement.legacyOriginalName ?? row.measurement.name}`}
>
待确认
</span>
) : row.measurement.taskNo}
</td>
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>
{row.measurement.name}
{row.measurement.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{row.measurement.position}
</span>
)}
</td>
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
{row.measurement.baseline != null
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
: `${formatValue(row.measurement.value)} ${row.measurement.unit ?? ''}`}
</td>}
{showMeasurementDetails && <td className="px-2 text-right text-muted-foreground whitespace-nowrap">
{row.measurement.toleranceLabel != null
? row.measurement.toleranceLabel
: row.measurement.tolerance != null
? row.measurement.baseline != null
? `${row.measurement.tolerance}/${row.measurement.baseline}mm`
: `±${row.measurement.tolerance}`
: '—'}
</td>}
<td className="px-2 text-right whitespace-nowrap">
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
{demoFlowStatusLabel(row.measurement)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
<div className="industrial-card rounded overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
onClick={() => toggleFlowArtifactCard(cardKey)}
>
{artifactsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<FileSpreadsheet className="w-3.5 h-3.5 text-cyan-400" />
<span className="text-xs flex-1">算法产物</span>
<span className="text-[10px] text-muted-foreground">{aggregated.artifactGroups.length} 组</span>
</button>
{artifactsExpanded && (
<div className="border-t border-border/40 p-2 space-y-3">
{aggregated.artifactGroups.length > 0
? aggregated.artifactGroups.map((artifact) => renderFlowArtifactGroup(artifact, cardKey))
: <div className="text-[10px] text-muted-foreground text-center py-3">暂无算法产物</div>}
</div>
)}
</div>
</div>
)}
@@ -739,7 +1050,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{listItems.map((item) => (
item.kind === 'demoFlow'
? renderDemoFlowCard(item, variant)
: renderResultCard(item.result)
: renderResultCard(item.result, variant)
))}
</div>
);
@@ -764,7 +1075,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
<>
<button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -808,7 +1119,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { Skeleton } from './ui/skeleton';
import { Skeleton } from "./ui/skeleton";
export function DashboardLayoutSkeleton() {
return (
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
<Skeleton className="bg-muted h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
<Skeleton className="bg-muted h-3 w-20" />
<Skeleton className="bg-muted h-2 w-32" />
</div>
</div>
</div>
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="bg-muted h-64 rounded-xl" />
</div>
</div>
);

View File

@@ -0,0 +1,211 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
import {
formatElapsedTime,
type DemoFlowState,
type DemoFlowStep,
type DemoFlowStepStatus,
} from '@/hooks/useDemoFlowState';
interface DemoFlowOverlayProps {
state: DemoFlowState;
pluginNameById?: Map<string, string>;
}
function statusIcon(status: DemoFlowStepStatus) {
switch (status) {
case 'completed':
return (<div className="w-5 h-5 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
<Check className="w-3 h-3" />
</div>);
case 'processing':
return (<div className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center">
<Loader2 className="w-3 h-3 animate-spin" />
</div>);
case 'pending':
return (<div className="w-5 h-5 rounded-full bg-slate-500/20 text-slate-400 flex items-center justify-center">
<Circle className="w-3 h-3" />
</div>);
case 'error':
return (<div className="w-5 h-5 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
<X className="w-3 h-3" />
</div>);
case 'cancelled':
return (<div className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center">
<AlertTriangle className="w-3 h-3" />
</div>);
default:
return null;
}
}
function statusBorder(status: DemoFlowStepStatus) {
switch (status) {
case 'completed': return 'border-green-500/30 bg-green-500/5';
case 'processing': return 'border-blue-500/40 bg-blue-500/10 animate-pulse';
case 'pending': return 'border-slate-500/20 bg-slate-500/5';
case 'error': return 'border-red-500/30 bg-red-500/5';
case 'cancelled': return 'border-amber-500/30 bg-amber-500/5';
default: return 'border-border/40 bg-card/30';
}
}
function stepLabel(step: DemoFlowStep, pluginNameById?: Map<string, string>): string {
if (step.role === 'registration') return '整车配准';
const name = step.pluginId ? (pluginNameById?.get(step.pluginId) ?? step.pluginId) : '测量算法';
return name;
}
function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
switch (status) {
case 'processing': return '运行中';
case 'pending': return '排队中';
case 'completed': return '已完成';
case 'error': return '失败';
case 'cancelled': return '已停止';
default: return '';
}
}
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
const title = useMemo(
() => state.completedPointCount == null
? '点云数量暂不可用'
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
[state.completedPointCount],
);
const elapsedTime = formatElapsedTime(state.elapsedMs);
if (!state.active && state.overallStatus === 'idle') return null;
const isCollapsed = state.runId != null && collapsedRunIds.has(state.runId);
const setCurrentRunCollapsed = (collapsed: boolean) => {
if (state.runId == null) return;
setCollapsedRunIds((previous) => {
const next = new Set(previous);
if (collapsed) next.add(state.runId!);
else next.delete(state.runId!);
return next;
});
};
if (isCollapsed) {
return (
<div className="absolute inset-0 z-30 pointer-events-none">
<div className="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-72 max-w-[calc(100%-1.5rem)] items-center gap-3 border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-3 py-2.5 shadow-2xl backdrop-blur-sm">
<div className="min-w-0 flex-1">
<div className="truncate text-[10px] font-medium" title={title}>{title}</div>
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
style={{ width: `${state.overallProgress}%` }}
/>
</div>
</div>
<button
type="button"
title="展开进度"
aria-label="展开进度"
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setCurrentRunCollapsed(false)}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
return (
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
<div className="industrial-panel pointer-events-auto mx-4 w-full max-w-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-5 shadow-2xl backdrop-blur-sm sm:px-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
{state.overallStatus === 'processing' ? (
<Scan className="w-4 h-4 text-primary animate-pulse" />
) : (
<Cpu className="w-4 h-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="break-words text-sm font-medium">{title}</h3>
<p className="text-[10px] text-muted-foreground">
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
</p>
</div>
<button
type="button"
title="收起进度"
aria-label="收起进度"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setCurrentRunCollapsed(true)}
>
<Minimize2 className="h-4 w-4" />
</button>
</div>
{/* 总进度条 */}
<div className="h-1.5 w-full bg-secondary rounded-full overflow-hidden mb-5">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
style={{ width: `${state.overallProgress}%` }}
/>
</div>
{/* 步骤条 */}
<div className="space-y-3">
{state.steps.map((step, idx) => {
const isCurrent = state.currentStep?.id === step.id;
const label = stepLabel(step, pluginNameById);
const showProgress = step.status === 'processing' && step.progress != null;
return (
<div key={step.id} className="relative flex items-start gap-3">
{idx !== state.steps.length - 1 && (
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
)}
<div className="relative z-10 mt-0.5">{statusIcon(step.status)}</div>
<div className={`min-w-0 flex-1 rounded border p-2.5 ${statusBorder(step.status)} ${isCurrent ? 'ring-1 ring-primary/20' : ''}`}>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
<span className="text-[9px] text-muted-foreground flex-shrink-0">
{step.status === 'processing' ? '运行中'
: step.status === 'pending' ? '排队中'
: step.status === 'completed' ? '已完成'
: step.status === 'error' ? '失败'
: step.status === 'cancelled' ? '已停止'
: step.status}
</span>
</div>
{showProgress && (
<div className="mt-1.5">
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
/>
</div>
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
{Math.round((step.progress ?? 0) * 100)}%
</div>
</div>
)}
</div>
</div>
);
})}
</div>
{state.overallStatus === 'processing' && state.currentStep && (
<div className="mt-4 text-[10px] text-muted-foreground text-center">
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from 'react';
import { Layers, Play, Search, XCircle } from 'lucide-react';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
export interface DemoTaskCatalogItem {
taskNo: number;
name: string;
category: string;
pluginId: string;
pluginName: string;
enabled: boolean;
disabledReason?: string;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
items: DemoTaskCatalogItem[];
total: number;
loading: boolean;
pending: boolean;
runBlockReason: string | null;
onRun: (taskNos: number[]) => void;
}
export default function DemoTaskSelectionDialog({
open,
onOpenChange,
items,
total,
loading,
pending,
runBlockReason,
onRun,
}: Props) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<number>>(new Set());
useEffect(() => {
if (!open) return;
setSearch('');
setSelected(new Set());
}, [open]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return items;
return items.filter((item) => (
String(item.taskNo).includes(keyword)
|| `任务${String(item.taskNo).padStart(2, '0')}`.toLocaleLowerCase('zh-CN').includes(keyword)
|| item.name.toLocaleLowerCase('zh-CN').includes(keyword)
));
}, [items, search]);
const selectableResults = filteredItems.filter((item) => item.enabled);
const selectedCountInResults = selectableResults.filter((item) => selected.has(item.taskNo)).length;
const selectAllState = selectableResults.length > 0 && selectedCountInResults === selectableResults.length
? true
: selectedCountInResults > 0
? 'indeterminate'
: false;
const selectedPluginCount = new Set(
items.filter((item) => selected.has(item.taskNo)).map((item) => item.pluginId),
).size;
const startBlockReason = selected.size === 0 ? '请至少选择一个任务' : runBlockReason;
const toggleTask = (taskNo: number) => {
setSelected((previous) => {
const next = new Set(previous);
if (next.has(taskNo)) next.delete(taskNo);
else next.add(taskNo);
return next;
});
};
const toggleAllResults = () => {
setSelected((previous) => {
const next = new Set(previous);
if (selectAllState === true) {
selectableResults.forEach((item) => next.delete(item.taskNo));
} else {
selectableResults.forEach((item) => next.add(item.taskNo));
}
return next;
});
};
const handleRun = () => {
if (startBlockReason) return;
onRun(Array.from(selected).sort((a, b) => a - b));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[min(960px,calc(100vw-1rem))] max-w-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="border-b border-border/60 px-4 py-4 pr-12 sm:px-6">
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Layers className="size-4 text-cyan-400" />
选择任务运行
</DialogTitle>
<DialogDescription>
当前检测对象可运行 {total} 项;系统仍会先执行 1 次整车配准。
</DialogDescription>
<div className="relative pt-2">
<Search className="pointer-events-none absolute left-3 top-[18px] size-4 text-muted-foreground" />
<Input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索任务号或任务名称"
aria-label="搜索任务号或任务名称"
className="pl-9"
/>
</div>
<div className="flex min-h-8 flex-wrap items-center gap-3 pt-1 text-xs">
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 focus-within:ring-2 focus-within:ring-ring">
<Checkbox
checked={selectAllState}
onCheckedChange={toggleAllResults}
disabled={selectableResults.length === 0}
aria-label="全选当前搜索结果"
/>
全选当前结果({selectableResults.length})
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setSelected(new Set())}
disabled={selected.size === 0}
>
<XCircle className="size-3.5" /> 清空
</Button>
</div>
</DialogHeader>
<div className="min-h-0 overflow-y-auto px-3 py-3 sm:px-6 sm:py-4">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">正在加载任务目录…</div>
) : filteredItems.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的任务</div>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2" role="group" aria-label="可选任务">
{filteredItems.map((item) => {
const checkboxId = `demo-task-${item.taskNo}`;
return (
<label
key={item.taskNo}
htmlFor={checkboxId}
className={`flex min-h-14 items-start gap-3 rounded-md border px-3 py-2.5 transition-colors motion-reduce:transition-none ${
item.enabled
? selected.has(item.taskNo)
? 'cursor-pointer border-primary/70 bg-primary/10 hover:bg-primary/15'
: 'cursor-pointer border-border/70 hover:border-primary/40 hover:bg-secondary/50'
: 'cursor-not-allowed border-border/40 bg-muted/20 opacity-55'
}`}
title={item.disabledReason}
>
<Checkbox
id={checkboxId}
checked={selected.has(item.taskNo)}
disabled={!item.enabled}
onCheckedChange={() => toggleTask(item.taskNo)}
aria-describedby={!item.enabled ? `${checkboxId}-reason` : undefined}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="rounded bg-cyan-950/70 px-1.5 py-0.5 font-mono text-[10px] font-medium text-cyan-300">
任务 {String(item.taskNo).padStart(2, '0')}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-muted-foreground">
{item.category}
</span>
</span>
<span className="mt-1 block text-xs leading-5 text-foreground">{item.name}</span>
{!item.enabled && item.disabledReason && (
<span id={`${checkboxId}-reason`} className="mt-0.5 block text-[10px] text-amber-400">
{item.disabledReason}
</span>
)}
</span>
</label>
);
})}
</div>
)}
</div>
<DialogFooter className="border-t border-border/60 bg-background/95 px-4 py-3 backdrop-blur sm:items-center sm:justify-between sm:px-6">
<div className="text-left" aria-live="polite" aria-atomic="true">
<div className="text-xs font-medium">
已选 {selected.size} 项 · 将执行 {selectedPluginCount} 个测量算法 + 1 次整车配准
</div>
{startBlockReason && <div className="mt-0.5 text-[10px] text-amber-400">{startBlockReason}</div>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="button" size="sm" onClick={handleRun} disabled={Boolean(startBlockReason) || pending}>
<Play className="size-3.5" />
{pending ? '提交中…' : '开始运行'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,8 +11,11 @@ import { useState } from 'react';
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
interface Measurement {
taskNo?: string;
metricCode?: string;
name: string;
value: number;
position?: string;
value: number | null;
unit?: string;
tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
@@ -22,7 +25,10 @@ interface Measurement {
status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
warningLabel?: string;
failLabel?: string;
legacyUnmapped?: boolean;
legacyOriginalName?: string;
}
interface ErrorStats {
@@ -66,6 +72,8 @@ interface Props {
pluginId?: string;
pluginName?: string;
data: PluginResultLike;
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
displayMode?: 'panel' | 'maximized';
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
onViewOverlay?: (overlay: {
taskId: number;
@@ -76,7 +84,7 @@ interface Props {
}) => void;
}
function statusClass(s?: Measurement['status']) {
export function statusClass(s?: Measurement['status']) {
switch (s) {
case 'pass':
return 'bg-green-900/40 text-green-400';
@@ -89,12 +97,12 @@ function statusClass(s?: Measurement['status']) {
}
}
function statusLabel(m: Measurement) {
export function statusLabel(m: Measurement) {
switch (m.status) {
case 'pass':
return m.passLabel ?? '合格';
case 'warning':
return '警告';
return m.warningLabel ?? '警告';
case 'fail':
return m.failLabel ?? '超差';
default:
@@ -102,20 +110,47 @@ function statusLabel(m: Measurement) {
}
}
function formatValue(v: number): string {
export function isReferenceMeasurement(measurement: { status?: unknown }): boolean {
return measurement.status === 'info';
}
export function filterVisibleMeasurements<T extends { status?: unknown }>(measurements: readonly T[]): T[] {
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
}
export function sortMeasurements<T extends { taskNo?: unknown; position?: unknown }>(measurements: readonly T[]): T[] {
const positionOrder = (position: unknown) => position === '左梁' ? 0 : position === '右梁' ? 1 : 2;
return measurements
.map((measurement, index) => ({ measurement, index }))
.sort((a, b) => {
const aNo = typeof a.measurement.taskNo === 'string' && /^\d+$/.test(a.measurement.taskNo)
? Number(a.measurement.taskNo)
: Number.POSITIVE_INFINITY;
const bNo = typeof b.measurement.taskNo === 'string' && /^\d+$/.test(b.measurement.taskNo)
? Number(b.measurement.taskNo)
: Number.POSITIVE_INFINITY;
if (aNo !== bNo) return aNo - bNo;
const positionDelta = positionOrder(a.measurement.position) - positionOrder(b.measurement.position);
return positionDelta || a.index - b.index;
})
.map(({ measurement }) => measurement);
}
export function formatValue(v: number | null): string {
if (v == null) return '—';
if (!Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
return v.toFixed(3);
}
function numberArrayFromUnknown(value: unknown): number[] | undefined {
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
if (overlay.kind === 'ply') {
const files = Array.isArray(overlay.files)
@@ -157,11 +192,13 @@ export default function GenericResultView({
pluginId,
pluginName,
data,
displayMode = 'panel',
onViewOverlay,
}: Props) {
const [showExtra, setShowExtra] = useState(false);
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
const showMeasurementDetails = displayMode === 'maximized';
const images = data.images ?? [];
const files = (data.files ?? []).map((f) =>
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
@@ -202,27 +239,44 @@ export default function GenericResultView({
{measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">测量值</div>
<table className="w-full text-[10px] border-collapse">
<div className="overflow-x-auto">
<table className="w-full min-w-[360px] text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 pr-2 font-normal whitespace-nowrap">任务号</th>
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
<th className="text-right py-1 font-normal">结果</th>
</tr>
</thead>
<tbody>
{measurements.map((m, i) => (
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
<td className="py-1 pr-2 whitespace-nowrap">
{m.legacyUnmapped || !m.taskNo ? (
<span
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}
>
待确认
</span>
) : m.taskNo}
</td>
<td className="py-1 truncate max-w-[140px]" title={m.name}>
{m.name}
{m.position && (
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
{m.position}
</span>
)}
</td>
<td className="text-right font-mono">
{showMeasurementDetails && <td className="text-right font-mono">
{m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td>
<td className="text-right text-muted-foreground">
</td>}
{showMeasurementDetails && <td className="text-right text-muted-foreground">
{m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
@@ -230,7 +284,7 @@ export default function GenericResultView({
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td>
</td>}
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
{m.status === 'info' ? '参考' : statusLabel(m)}
@@ -240,6 +294,7 @@ export default function GenericResultView({
))}
</tbody>
</table>
</div>
</div>
)}

View File

@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from './ui/button';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from './ui/alert-dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { toast } from 'sonner';
type Props = { modelId: number };
const stateLabels = {
missing_pointcloud: '未配置点云',
missing_cad: '未配置 CAD',
ready_draft: '配置完整,未启用',
active: '已启用',
} as const;
type ReferenceCounts = {
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
};
function referenceReason(object: ReferenceCounts) {
return [
object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null,
object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null,
object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null,
].filter(Boolean).join('、');
}
export default function InspectionObjectConfig({ modelId }: Props) {
const utils = trpc.useUtils();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [pointcloudId, setPointcloudId] = useState<number | null>(null);
const [cadId, setCadId] = useState<number | null>(null);
const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open });
const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open });
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open });
const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]);
useEffect(() => {
if (!selected && objects.length > 0) setSelectedId(objects[0].id);
}, [objects, selected]);
useEffect(() => {
setPointcloudId(selected?.defaultModelPointcloudId ?? null);
setCadId(selected?.defaultModelCadFileId ?? null);
}, [selected]);
const invalidate = async () => {
await Promise.all([
utils.inspectionObject.list.invalidate({ modelId }),
utils.modelPointcloudFile.list.invalidate({ modelId }),
utils.cadFile.list.invalidate({ modelId }),
]);
};
const create = trpc.inspectionObject.create.useMutation({
onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); },
onError: error => toast.error(error.message),
});
const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({
onSuccess: async (_file, variables) => {
await invalidate();
const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name;
toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新');
},
onError: error => toast.error(error.message),
});
const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({
onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); },
onError: error => toast.error(error.message),
});
const configure = trpc.inspectionObject.configureDefaults.useMutation({
onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); },
onError: error => toast.error(error.message),
});
const setStatus = trpc.inspectionObject.setStatus.useMutation({
onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); },
onError: error => toast.error(error.message),
});
const deleteObject = trpc.inspectionObject.delete.useMutation({
onSuccess: async () => {
setSelectedId(null);
await invalidate();
toast.success('检测对象已删除');
},
onError: error => toast.error(error.message),
});
const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available');
const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available');
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button className="industrial-btn flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-0.5 text-[10px]"><Settings2 className="h-3 w-3" /> 检测对象配置</button>
</DialogTrigger>
<DialogContent className="flex max-h-[92vh] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-4 sm:max-w-4xl sm:p-6">
<DialogHeader className="shrink-0">
<DialogTitle>检测对象配置</DialogTitle>
<DialogDescription>资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。</DialogDescription>
</DialogHeader>
<Tabs defaultValue="objects" className="min-h-0 flex-1 overflow-hidden">
<TabsList className="grid w-full shrink-0 grid-cols-2 sm:w-80">
<TabsTrigger value="objects">对象配置</TabsTrigger>
<TabsTrigger value="resources">资源归属</TabsTrigger>
</TabsList>
<TabsContent value="objects" className="min-h-0 overflow-y-auto pr-1">
<div className="grid min-w-0 gap-4 md:grid-cols-[15rem_minmax(0,1fr)]">
<aside className="min-w-0 space-y-3 rounded-lg border bg-secondary/20 p-3">
<div className="flex min-w-0 gap-1">
<input aria-label="新检测对象名称" className="min-w-0 flex-1 rounded border bg-background px-2 py-1.5 text-xs" placeholder="如:头车" value={name} onChange={event => setName(event.target.value)} />
<Button aria-label="创建检测对象" size="icon" className="h-7 w-7 shrink-0" disabled={!name.trim() || create.isPending} onClick={() => create.mutate({ modelId, name: name.trim() })}><Plus className="h-3.5 w-3.5" /></Button>
</div>
<div className="max-h-56 space-y-1 overflow-y-auto md:max-h-[25rem]">
{objects.map(object => <button key={object.id} onClick={() => setSelectedId(object.id)} className={`w-full rounded border px-2 py-2 text-left text-xs ${selectedId === object.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-secondary'}`}>
<span className="flex items-center justify-between gap-2"><strong className="min-w-0 truncate" title={object.name}>{object.name}</strong>{object.configurationState === 'active' ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}</span>
<span className="mt-1 block text-[10px] text-muted-foreground">{stateLabels[object.configurationState]}</span>
</button>)}
{objects.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">尚未创建检测对象</p>}
</div>
</aside>
{selected ? <main className="min-w-0 space-y-4">
<section className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0"><h3 className="truncate text-sm font-medium" title={selected.name}>{selected.name}</h3><p className="text-xs text-muted-foreground">{stateLabels[selected.configurationState]}</p></div>
<span className={`rounded-full px-2 py-1 text-[10px] ${selected.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-amber-500/15 text-amber-400'}`}>{selected.status === 'active' ? '已启用' : '草稿'}</span>
</div>
<div><h3 className="text-sm font-medium">默认输入方案</h3><p className="text-xs text-muted-foreground">新建“{selected.name}”任务时自动关联并勾选以下资源。</p></div>
<label className="grid min-w-0 gap-1 text-xs"><span>预置点云</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={pointcloudId ?? ''} onChange={event => setPointcloudId(Number(event.target.value) || null)}><option value="">请选择</option>{compatiblePointclouds.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
<label className="grid min-w-0 gap-1 text-xs"><span>默认 CAD</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={cadId ?? ''} onChange={event => setCadId(Number(event.target.value) || null)}><option value="">请选择</option>{compatibleCadFiles.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
<div className="flex flex-wrap gap-2"><Button size="sm" disabled={!pointcloudId || !cadId || configure.isPending} onClick={() => configure.mutate({ id: selected.id, defaultModelPointcloudId: pointcloudId!, defaultModelCadFileId: cadId!, active: true })}>保存并启用</Button><Button size="sm" variant="outline" disabled={selected.status === 'draft' || setStatus.isPending} onClick={() => setStatus.mutate({ id: selected.id, status: 'draft' })}>停用</Button></div>
</section>
<section className="space-y-2 rounded-lg border border-destructive/30 p-4">
<div><h3 className="text-sm font-medium text-destructive">删除检测对象</h3><p className="text-xs text-muted-foreground">仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。</p></div>
{selected.canDelete ? <AlertDialog>
<AlertDialogTrigger asChild><Button size="sm" variant="destructive"><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>确认删除“{selected.name}”?</AlertDialogTitle><AlertDialogDescription>删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90" disabled={deleteObject.isPending} onClick={() => deleteObject.mutate({ id: selected.id })}>确认删除</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog> : <div className="space-y-1"><Button size="sm" variant="destructive" disabled><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button><p className="text-xs text-amber-500">需先处理:{referenceReason(selected)}。</p></div>}
</section>
</main> : <div className="grid min-h-48 place-items-center rounded-lg border border-dashed text-sm text-muted-foreground">请先创建检测对象</div>}
</div>
</TabsContent>
<TabsContent value="resources" className="min-h-0 overflow-y-auto pr-1">
<section className="min-w-0 space-y-3">
<div><h3 className="text-sm font-medium">车型级资源归属</h3><p className="text-xs text-muted-foreground">每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。</p></div>
{objects.length === 0 && <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">请先在“对象配置”中创建检测对象</div>}
{objects.length > 0 && <div className="grid min-w-0 gap-3 lg:grid-cols-2">
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库点云归属</h4><div className="max-h-[50vh] space-y-1 overflow-y-auto overflow-x-hidden">
{pointclouds.map(file => <div key={file.id} className="flex min-w-0 flex-col gap-1 rounded px-1.5 py-1.5 text-xs hover:bg-secondary sm:flex-row sm:items-center sm:gap-2"><span className="min-w-0 flex-1 truncate" title={file.name}>{file.name}</span><select className={`min-w-0 rounded border bg-background px-1.5 py-1 text-[10px] outline-none focus:border-primary sm:w-32 sm:shrink-0 ${file.inspectionObjectId == null ? 'border-amber-500/50 text-amber-400' : 'border-border'}`} aria-label={`${file.name} 的归属检测对象`} value={file.inspectionObjectId ?? ''} disabled={assignPointcloud.isPending} onChange={event => { const inspectionObjectId = Number(event.target.value); if (inspectionObjectId && inspectionObjectId !== file.inspectionObjectId) assignPointcloud.mutate({ id: file.id, inspectionObjectId }); }}><option value="" disabled>未分类</option>{objects.map(object => <option key={object.id} value={object.id}>{object.name}</option>)}</select></div>)}
{pointclouds.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无点云</p>}
</div></div>
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库 CAD · 适用对象</h4><div className="max-h-[50vh] space-y-2 overflow-y-auto overflow-x-hidden">
{cadFiles.map(file => <fieldset key={file.id} className="min-w-0 rounded border p-2"><legend className="max-w-full truncate px-1 text-xs font-medium" title={file.name}>{file.name}</legend><div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return <label key={object.id} className="flex min-w-0 items-center gap-1.5 text-xs"><input type="checkbox" checked={checked} disabled={setCadObjects.isPending} onChange={() => { const next = checked ? file.inspectionObjectIds.filter(id => id !== object.id) : [...file.inspectionObjectIds, object.id]; if (next.length === 0) return toast.error('CAD 至少需要一个适用检测对象'); setCadObjects.mutate({ id: file.id, inspectionObjectIds: next }); }} /><span className="max-w-32 truncate" title={object.name}>{object.name}</span></label>; })}</div></fieldset>)}
{cadFiles.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无 CAD</p>}
</div></div>
</div>}
</section>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>;
}

View File

@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
function getHeatmapLegendBounds(
points: Point[],
mode: 'points' | 'smoothHeatmap',
): { min: number; max: number; midTick: string } {
): { min: number; max: number; midTick: string } | null {
if (mode === 'smoothHeatmap') {
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
if (pointsWithError.length === 0) return null;
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
const midTick =
minError < 0 && maxError > 0
? '0'
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
let minE = Infinity;
let maxE = -Infinity;
for (let i = 0; i < points.length; i++) {
const e = points[i].error ?? 0;
const e = points[i].error;
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
if (e < minE) minE = e;
if (e > maxE) maxE = e;
}
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
if (!(maxE > minE)) {
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
}
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
const positions = new Float32Array(data.points.length * 3);
const colors = new Float32Array(data.points.length * 3);
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
const hasErrorValues = pointsWithError.length > 0;
let minError: number;
let maxError: number;
if (errorRangeMode === 'percentile') {
const r = displayErrorRangeSmoothHeatmap(data.points);
if (errorRangeMode === 'percentile' && hasErrorValues) {
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
minError = r.minError;
maxError = r.maxError;
} else {
minError = Infinity;
maxError = -Infinity;
for (let i = 0; i < data.points.length; i++) {
const err = data.points[i].error ?? 0;
const err = data.points[i].error;
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
if (err < minError) minError = err;
if (err > maxError) maxError = err;
}
@@ -420,8 +427,8 @@ const PointCloudMesh = memo(function PointCloudMesh({
colors[i3] = point.r;
colors[i3 + 1] = point.g;
colors[i3 + 2] = point.b;
} else if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError);
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
const color = errorToColor(point.error!, minError, maxError);
colors[i3] = color.r;
colors[i3 + 1] = color.g;
colors[i3 + 2] = color.b;
@@ -1165,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
);
});
// 一键全流程演示扫描动画
interface DemoFlowScannerProps {
active: boolean;
}
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
const ringRef = useRef<THREE.Mesh>(null);
const sweepRef = useRef<THREE.Mesh>(null);
const particlesRef = useRef<THREE.Points>(null);
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
const { positions, colors } = useMemo(() => {
const count = 120;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
positions[i * 3] = Math.cos(angle) * radius;
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
positions[i * 3 + 2] = Math.sin(angle) * radius;
colors[i * 3] = 0.2;
colors[i * 3 + 1] = 0.75;
colors[i * 3 + 2] = 1.0;
}
return { positions, colors };
}, []);
useFrame((state) => {
if (!active) return;
const t = state.clock.elapsedTime;
if (ringRef.current) {
ringRef.current.rotation.z = -t * 0.3;
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
}
if (sweepRef.current) {
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
}
if (particlesRef.current) {
particlesRef.current.rotation.y = t * 0.15;
}
});
if (!active) return null;
return (
<group>
{/* 旋转圆环 */}
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<ringGeometry args={[3.5, 3.7, 96]} />
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
{/* 扫描平面 */}
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[7.6, 7.6, 48, 48]} />
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
{/* 粒子环 */}
<points ref={particlesRef}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
</points>
</group>
);
});
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
@@ -1202,6 +1285,8 @@ interface SceneContentProps {
/** ROI 坐标拾取模式 */
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:展示扫描动画 */
demoFlowActive?: boolean;
}
const SceneContent = memo(function SceneContent({
@@ -1228,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
plyOverlayData = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: SceneContentProps) {
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
@@ -1378,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
{/* Grid */}
<IndustrialGrid />
{/* 一键全流程演示扫描动画 */}
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
{/* PLY 叠加层(kind=ply 插件结果) */}
{plyOverlayData && (
<points>
@@ -1418,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
prevProps.dispRange === nextProps.dispRange &&
prevProps.plyOverlayData === nextProps.plyOverlayData &&
prevProps.roiPickMode === nextProps.roiPickMode;
prevProps.roiPickMode === nextProps.roiPickMode &&
prevProps.demoFlowActive === nextProps.demoFlowActive;
});
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
@@ -1535,6 +1625,7 @@ async function fetchBatchSampled(
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
const hasColor = resp.headers.get('X-Has-Color') === '1';
const hasError = resp.headers.get('X-Has-Error') === '1';
const stride = hasColor ? 7 : 4;
const arrayBuffer = await resp.arrayBuffer();
const floats = new Float32Array(arrayBuffer);
@@ -1551,7 +1642,8 @@ async function fetchBatchSampled(
for (let i = 0; i < count; i++) {
const base = i * stride;
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
const pt: Point = { x, y, z, error };
const pt: Point = { x, y, z };
if (hasError && Number.isFinite(error)) pt.error = error;
if (hasColor) {
pt.r = floats[base + 4];
pt.g = floats[base + 5];
@@ -1622,6 +1714,8 @@ interface PointCloudViewerProps {
*/
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
demoFlowActive?: boolean;
}
export default function PointCloudViewer({
@@ -1651,6 +1745,7 @@ export default function PointCloudViewer({
pluginOverlay = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: PointCloudViewerProps) {
// 点云数据状态
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
@@ -1795,21 +1890,32 @@ export default function PointCloudViewer({
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
(async () => {
try {
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点
// 支持多文件(各面板 PLY),合并采样
// 通过受限的分析结果接口加载相对 PLY 路径,支持多文件合并采样
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
? overlay.files
: [overlay.file];
const resp = await fetch('/api/files/sample/batch', {
const resp = await fetch('/api/files/sample/analysis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
taskId,
resultId,
files: plyFiles,
totalPoints: 300_000,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (!resp.ok) {
const payload = await resp.json().catch(() => null) as { error?: string } | null;
const detail = payload?.error;
if (resp.status === 404) {
throw new Error(detail ?? '分析结果或 PLY 文件不存在');
}
if (resp.status === 400 || resp.status === 403) {
throw new Error(detail ?? 'PLY 文件参数或访问权限无效');
}
throw new Error(detail ?? `服务端采样失败(HTTP ${resp.status})`);
}
const hasColor = resp.headers.get('X-Has-Color') === '1';
const stride = hasColor ? 7 : 4;
const buf = await resp.arrayBuffer();
@@ -1855,7 +1961,10 @@ export default function PointCloudViewer({
if (e instanceof DOMException && e.name === 'AbortError') return;
console.warn('[PlyOverlay] load failed:', e);
setPlyOverlayData(null);
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
setPlyOverlayStatus({
status: 'error',
message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试',
});
}
})();
return () => ac.abort();
@@ -1964,6 +2073,7 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null;
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
const hasVisibleCadHeatmap =
pointCloudRenderMode === 'smoothHeatmap' &&
serverCadHeatmap.status === 'ready' &&
@@ -2120,6 +2230,7 @@ export default function PointCloudViewer({
plyOverlayData={plyOverlayData}
roiPickMode={roiPickMode}
onRoiPointPicked={onRoiPointPicked}
demoFlowActive={demoFlowActive}
/>
</Canvas>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import {
Check,
X,
} from 'lucide-react';
import { useAuth } from '@/_core/hooks/useAuth';
export type TreeSelection = {
seriesId: number | null;
@@ -25,9 +26,11 @@ interface Props {
function EditableText({
value,
onSave,
canEdit,
}: {
value: string;
onSave: (v: string) => void;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -35,7 +38,7 @@ function EditableText({
return (
<span
className="flex-1 cursor-pointer hover:text-foreground truncate"
onDoubleClick={() => { setDraft(value); setEditing(true); }}
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
>
{value}
</span>
@@ -60,6 +63,7 @@ function EditableText({
}
export default function VehicleTree({ selection, onSelect }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
const [newSeriesName, setNewSeriesName] = useState('');
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<div className="industrial-panel p-2 space-y-1">
<div className="flex items-center justify-between mb-2">
<span className="industrial-label">车系 / 车型 / 任务</span>
<button
{canWrite && <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => setAddingSeries(true)}
>
<Plus className="w-3 h-3" /> 车系
</button>
</button>}
</div>
{addingSeries && (
{canWrite && addingSeries && (
<div className="flex items-center gap-1 pl-1">
<input
autoFocus
@@ -167,18 +171,16 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<div
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
onClick={() => {
toggleSeries(series.id);
onSelect({ seriesId: series.id, modelId: null, taskId: null });
}}
onClick={() => toggleSeries(series.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 flex-shrink-0 text-muted-foreground" />}
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
<EditableText
value={series.name}
canEdit={canWrite}
onSave={name => updateSeries.mutate({ id: series.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary"
title="新增车型"
@@ -196,11 +198,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
{/* 新增车型输入 */}
{addingModelFor === series.id && (
{canWrite && addingModelFor === series.id && (
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
<input
autoFocus
@@ -258,9 +260,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
<EditableText
value={model.name}
canEdit={canWrite}
onSave={name => updateModel.mutate({ id: model.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除车型"
@@ -271,7 +274,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
</div>
@@ -283,7 +286,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
{seriesList.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">
暂无车系,点击右上角新增
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
</div>
)}
</div>

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
}
const nameOf = (item: DemoFlowSourceResult) => `算法${item.id}`;
describe('aggregateDemoFlowResults', () => {
it('按全流程顺序合并测量项,并保留来源和算法内顺序', () => {
const model = aggregateDemoFlowResults([
result(20, 2, 'completed', { measurements: [{ name: 'B1', value: 2 }, { name: 'B2', value: 3 }] }),
result(10, 1, 'completed', { measurements: [{ name: 'A1', value: 1 }] }),
], nameOf);
expect(model.measurementRows.map((row) => [row.measurement.name, row.resultId, row.flowIndex, row.measurementIndex])).toEqual([
['A1', 10, 1, 0],
['B1', 20, 2, 0],
['B2', 20, 2, 1],
]);
});
it('原样保留空值、公差文本、基准长度和自定义结论文案,并忽略空测量', () => {
const measurement = {
name: '斜率', value: null, unit: 'mm', tolerance: 0.5, toleranceLabel: '+8.5/-3', baseline: 1000,
status: 'warning' as const, passLabel: '达标', warningLabel: '复核', failLabel: '未达标',
};
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', { measurements: [] }),
result(20, 2, 'completed', { measurements: [measurement] }),
], nameOf);
expect(model.measurementRows).toHaveLength(1);
expect(model.measurementRows[0].measurement).toEqual(measurement);
});
it('默认不生成算法分组行,开启后按算法生成分组', () => {
const inputs = [
result(10, 1, 'completed', { measurements: [{ name: 'A', value: 1 }] }),
result(20, 2, 'completed', { measurements: [{ name: 'B', value: 2 }] }),
];
expect(aggregateDemoFlowResults(inputs, nameOf).tableRows.every((row) => row.kind === 'measurement')).toBe(true);
expect(aggregateDemoFlowResults(inputs, nameOf, { includeAlgorithmGroups: true }).tableRows.map((row) => row.kind)).toEqual([
'algorithm', 'measurement', 'algorithm', 'measurement',
]);
});
it('过滤参考项,同时保留其他状态和未设置状态的测量项', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '参考项', value: 0.1, status: 'info' },
{ name: '合格项', value: 1, status: 'pass' },
{ name: '警告项', value: 2, status: 'warning' },
{ name: '失败项', value: 3, status: 'fail' },
{ name: '无状态项', value: 4 },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
'合格项', '警告项', '失败项', '无状态项',
]);
expect(model.tableRows).toHaveLength(4);
});
it('过滤全流程指定的中间指标,同时保持其他有效测量项的原顺序', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '车体长度', value: 100, status: 'pass' },
{ name: '前后端平移差', value: 0.1, status: 'pass' },
{ name: '车体宽度', value: 20, status: 'warning' },
{ name: 'ICP fitness', value: 0.98, status: 'pass' },
{ name: '配准 RMSE', value: 0.02, status: 'pass' },
{ name: '车体高度', value: 30, status: 'fail' },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
'车体长度', '车体宽度', '车体高度',
]);
});
it('自选模式只聚合选中的正式任务,任务 5 保留左右梁两个位置', () => {
const results = [result(1, 1, 'completed', {
measurements: [
{ taskNo: '2', name: '车体长度', value: 1 },
{ taskNo: '5', name: '挠度', position: '左梁', value: 2 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 3 },
{ taskNo: '10', name: '纵向中心右偏转', value: 4 },
],
})];
const model = aggregateDemoFlowResults(results, () => '算法', { selectedTaskNos: [5, 10] });
expect(model.measurementRows.map((row) => [row.measurement.taskNo, row.measurement.position])).toEqual([
['5', '左梁'],
['5', '右梁'],
['10', undefined],
]);
});
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
expect(demoFlowStatusLabel({ status: 'fail' })).toBe('超差');
expect(demoFlowStatusLabel({ status: 'info' })).toBe('参考');
expect(demoFlowStatusLabel({})).toBe('参考');
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '通过项', value: 1, status: 'pass', passLabel: '通过' },
{ name: '警告项', value: 2, status: 'warning', warningLabel: '部分成功' },
{ name: '失败项', value: 3, status: 'fail', failLabel: '失败' },
],
}),
], nameOf);
expect(model.measurementRows.map((row) => demoFlowStatusLabel(row.measurement))).toEqual([
'合格', '警告', '超差',
]);
});
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
measurements: [
{ name: '配准误差 p95', value: 0.1, status: 'info' },
{ name: '配准误差均值', value: 0.05, status: 'info' },
],
}),
], nameOf, { includeAlgorithmGroups: true });
expect(model.measurementRows).toEqual([]);
expect(model.tableRows).toEqual([]);
});
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
}),
result(20, 2, 'completed', {
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
}),
], nameOf);
expect(model.artifactGroups).toMatchObject([
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
]);
});
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
result(30, 3, 'pending', { stage: '等待资源' }),
result(40, 4, 'error', { error: '输入无效' }),
result(50, 5, 'cancelled', {}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
expect(model.statusNotices).toMatchObject([
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
{ resultId: 30, status: 'pending', detail: '等待资源' },
{ resultId: 40, status: 'error', detail: '输入无效' },
{ resultId: 50, status: 'cancelled' },
]);
});
});

View File

@@ -0,0 +1,197 @@
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
'前后端平移差',
'ICP fitness',
'配准 RMSE',
]);
export type DemoFlowSourceResult = {
id: number;
type: string;
pluginId?: string | null;
status: string;
resultData: unknown;
};
export type DemoFlowMeasurementRow = {
kind: 'measurement';
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
measurementIndex: number;
measurement: Measurement;
};
export type DemoFlowMeasurementGroupRow = {
kind: 'algorithm';
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
};
export type DemoFlowTableRow = DemoFlowMeasurementRow | DemoFlowMeasurementGroupRow;
export type DemoFlowArtifactGroup = {
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
flowIndex: number;
files: { label: string; path: string }[];
images: string[];
overlay: PluginResultLike['overlay3d'];
inputPointCloudFileIds: unknown;
inputCadFileIds: unknown;
extra?: Record<string, unknown>;
};
export type DemoFlowStatusNotice = {
sourceAlgorithmId: string;
algorithmName: string;
resultId: number;
status: string;
progress?: number;
detail?: string;
};
export type DemoFlowAggregation = {
tableRows: DemoFlowTableRow[];
measurementRows: DemoFlowMeasurementRow[];
artifactGroups: DemoFlowArtifactGroup[];
statusNotices: DemoFlowStatusNotice[];
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function flowIndex(result: DemoFlowSourceResult, fallback: number) {
return finiteNumber(asRecord(result.resultData)?.demoFlowIndex) ?? fallback;
}
function normalizeFiles(value: unknown): { label: string; path: string }[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (typeof item === 'string' && item.length > 0) return [{ label: item, path: item }];
const record = asRecord(item);
if (!record) return [];
const path = typeof record.path === 'string'
? record.path
: typeof record.name === 'string'
? record.name
: undefined;
if (!path) return [];
const label = typeof record.name === 'string' ? record.name : path;
return [{ label, path }];
});
}
function normalizeImages(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
function hasOverlay(value: PluginResultLike['overlay3d']) {
return Boolean(value && value.kind !== 'none');
}
export function filterDemoFlowMeasurements(measurements: readonly Measurement[]): Measurement[] {
return filterVisibleMeasurements(measurements)
.filter((measurement) => !DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES.has(measurement.name));
}
export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): string {
switch (measurement.status) {
case 'pass':
return '合格';
case 'warning':
return '警告';
case 'fail':
return '超差';
default:
return '参考';
}
}
export function aggregateDemoFlowResults(
results: DemoFlowSourceResult[],
getAlgorithmName: (result: DemoFlowSourceResult) => string,
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
): DemoFlowAggregation {
const sorted = results
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
.sort((a, b) => a.index - b.index || a.originalIndex - b.originalIndex);
const measurementRows: DemoFlowMeasurementRow[] = [];
const tableRows: DemoFlowTableRow[] = [];
const artifactGroups: DemoFlowArtifactGroup[] = [];
const statusNotices: DemoFlowStatusNotice[] = [];
const selectedTaskNos = options.selectedTaskNos
? new Set(options.selectedTaskNos.map(String))
: null;
for (const { result, index } of sorted) {
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
const algorithmName = getAlgorithmName(result);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : [])
.filter((measurement) => !selectedTaskNos || (
typeof measurement.taskNo === 'string' && selectedTaskNos.has(measurement.taskNo)
));
if (measurements.length > 0 && options.includeAlgorithmGroups) {
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
}
measurements.forEach((measurement, measurementIndex) => {
const row: DemoFlowMeasurementRow = {
kind: 'measurement',
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
measurementIndex,
measurement,
};
measurementRows.push(row);
tableRows.push(row);
});
const files = normalizeFiles(data.files);
const images = normalizeImages(data.images);
const extra = asRecord(data.extra) ?? undefined;
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
artifactGroups.push({
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
files,
images,
overlay: data.overlay3d,
inputPointCloudFileIds: data.inputPointCloudFileIds,
inputCadFileIds: data.inputCadFileIds,
extra,
});
}
if (result.status !== 'completed') {
const progress = finiteNumber(data.progress);
const detail = result.status === 'error'
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
: typeof data.stage === 'string'
? data.stage
: typeof data.error === 'string'
? data.error
: undefined;
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
}
}
return { tableRows, measurementRows, artifactGroups, statusNotices };
}

View File

@@ -1,23 +1,2 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
return '/';
}
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId || '');
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
};
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
export const getLoginUrl = () => "/login";

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
aggregateDemoFlowState,
formatElapsedTime,
type AnalysisResultRow,
} from './useDemoFlowState';
const STARTED_AT = '2026-07-24T00:00:10.000Z';
function result(
overrides: Partial<AnalysisResultRow> & Pick<AnalysisResultRow, 'id' | 'status'>,
): AnalysisResultRow {
return {
taskId: 1,
type: 'plugin',
pluginId: 'vehicle-registration',
createdAt: STARTED_AT,
updatedAt: STARTED_AT,
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 2,
demoFlowSourcePointCloudFileId: 42,
progress: 0.7,
},
...overrides,
};
}
describe('aggregateDemoFlowState', () => {
it('按源点云总点数和整体进度估算已完成点云量', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[{ id: 42, metadataStatus: 'ready', pointCount: 1_000_000_000 }],
new Date('2026-07-24T00:00:20.000Z').getTime(),
);
expect(state.overallProgress).toBe(35);
expect(state.completedPointCount).toBe(350_000_000);
});
it('元数据缺失时不生成虚假的点云数量', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[{ id: 42, metadataStatus: 'pending', pointCount: null }],
new Date('2026-07-24T00:00:20.000Z').getTime(),
);
expect(state.sourcePointCount).toBeNull();
expect(state.completedPointCount).toBeNull();
});
it('运行中使用当前时间计时,结束后冻结在最后更新时间', () => {
const running = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'processing' })],
[],
new Date('2026-07-24T00:00:40.000Z').getTime(),
);
const completed = aggregateDemoFlowState(
1,
[result({
id: 1,
status: 'completed',
updatedAt: '2026-07-24T00:00:25.000Z',
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 1,
demoFlowSourcePointCloudFileId: 42,
progress: 1,
},
})],
[],
new Date('2026-07-24T00:01:40.000Z').getTime(),
);
expect(running.elapsedMs).toBe(30_000);
expect(completed.elapsedMs).toBe(15_000);
expect(completed.endedAt).toBe(new Date('2026-07-24T00:00:25.000Z').getTime());
});
it('配准失败后把未创建步骤标为停止,不再聚合成排队中', () => {
const state = aggregateDemoFlowState(
1,
[result({ id: 1, status: 'error', updatedAt: '2026-07-24T00:00:25.000Z' })],
[],
new Date('2026-07-24T00:00:40.000Z').getTime(),
);
expect(state.active).toBe(false);
expect(state.overallStatus).toBe('error');
expect(state.steps[1]?.status).toBe('cancelled');
});
it('停止标记可让已完成配准后的占位步骤和计时正确冻结', () => {
const cancelledAt = '2026-07-24T00:00:28.000Z';
const state = aggregateDemoFlowState(
1,
[result({
id: 1,
status: 'completed',
updatedAt: cancelledAt,
resultData: {
demoFlowRunId: 'run-1',
demoFlowRole: 'registration',
demoFlowIndex: 1,
demoFlowTotal: 2,
demoFlowSourcePointCloudFileId: 42,
progress: 1,
demoFlowCancelledAt: cancelledAt,
},
})],
[],
new Date('2026-07-24T00:01:40.000Z').getTime(),
);
expect(state.overallStatus).toBe('cancelled');
expect(state.steps[1]?.status).toBe('cancelled');
expect(state.elapsedMs).toBe(18_000);
});
});
describe('formatElapsedTime', () => {
it('小时超过 24 后继续累计', () => {
expect(formatElapsedTime((27 * 3600 + 2 * 60 + 3) * 1000)).toBe('27:02:03');
});
});

View File

@@ -0,0 +1,273 @@
import { useEffect, useMemo, useState } from 'react';
import { trpc } from '@/lib/trpc';
export type AnalysisResultRow = {
id: number;
taskId?: number | null;
type: string;
pluginId?: string | null;
status: string;
createdAt: string | Date;
updatedAt: string | Date;
resultData: unknown;
};
export type PointcloudFileRow = {
id: number;
metadataStatus?: string | null;
pointCount?: number | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function numberFromUnknown(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function timestampFromUnknown(value: unknown): number | null {
if (typeof value !== 'string' && !(value instanceof Date)) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
export function formatElapsedTime(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':');
}
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
export interface DemoFlowStep {
id: number;
index: number;
role: 'registration' | 'measurement';
pluginId: string | null;
status: DemoFlowStepStatus;
progress?: number;
createdAt: string | Date;
updatedAt: string | Date;
}
export interface DemoFlowState {
active: boolean;
runId: string | null;
total: number;
completed: number;
steps: DemoFlowStep[];
overallStatus: DemoFlowStepStatus | 'idle';
currentStep: DemoFlowStep | null;
overallProgress: number;
sourcePointCloudFileId: number | null;
sourcePointCount: number | null;
completedPointCount: number | null;
startedAt: number | null;
endedAt: number | null;
elapsedMs: number;
}
const EMPTY_STATE: DemoFlowState = {
active: false,
runId: null,
total: 0,
completed: 0,
steps: [],
overallStatus: 'idle',
currentStep: null,
overallProgress: 0,
sourcePointCloudFileId: null,
sourcePointCount: null,
completedPointCount: null,
startedAt: null,
endedAt: null,
elapsedMs: 0,
};
function isActiveStatus(step: DemoFlowStep): boolean {
return step.status === 'processing' || step.status === 'pending';
}
export function aggregateDemoFlowState(
taskId: number | null,
results: AnalysisResultRow[],
pointcloudFiles: PointcloudFileRow[],
nowMs: number,
): DemoFlowState {
if (taskId == null || results.length === 0) return EMPTY_STATE;
const steps: DemoFlowStep[] = [];
let activeRunId: string | null = null;
let declaredTotal = 0;
let sourcePointCloudFileId: number | null = null;
let cancelledAt: number | null = null;
for (const r of results) {
const data = asRecord(r.resultData);
if (!data) continue;
const runId = data.demoFlowRunId;
if (typeof runId !== 'string' || runId.length === 0) continue;
if (activeRunId == null) {
activeRunId = runId;
} else if (activeRunId !== runId) {
// 分析结果按 createdAt 倒序返回,只聚合最新的一次全流程。
continue;
}
const index = numberFromUnknown(data.demoFlowIndex) ?? steps.length + 1;
const roleRaw = data.demoFlowRole;
const role = roleRaw === 'registration' ? 'registration' : 'measurement';
const progress = numberFromUnknown(data.progress);
const stepTotal = numberFromUnknown(data.demoFlowTotal) ?? 0;
const sourceId = numberFromUnknown(data.demoFlowSourcePointCloudFileId);
const rowCancelledAt = timestampFromUnknown(data.demoFlowCancelledAt);
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
if (sourcePointCloudFileId == null && sourceId != null) sourcePointCloudFileId = sourceId;
if (rowCancelledAt != null && (cancelledAt == null || rowCancelledAt > cancelledAt)) {
cancelledAt = rowCancelledAt;
}
steps.push({
id: r.id,
index,
role,
pluginId: r.pluginId ?? null,
status: r.status as DemoFlowStepStatus,
progress,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
});
}
if (steps.length === 0 || activeRunId == null) return EMPTY_STATE;
steps.sort((a, b) => a.index - b.index);
const actualSteps = [...steps];
const total = Math.max(actualSteps.length, declaredTotal);
const hasActiveActualStep = actualSteps.some(isActiveStatus);
const hasError = actualSteps.some((step) => step.status === 'error');
const hasCancelled = cancelledAt != null || actualSteps.some((step) => step.status === 'cancelled');
const terminalStatus: 'error' | 'cancelled' | null = !hasActiveActualStep
? hasError
? 'error'
: hasCancelled
? 'cancelled'
: null
: null;
// 链路已失败或停止时,尚未创建的步骤不会再执行,不能继续显示为“排队中”。
for (let index = actualSteps.length + 1; index <= total; index += 1) {
steps.push({
id: -index,
index,
role: index === 1 ? 'registration' : 'measurement',
pluginId: null,
status: terminalStatus == null ? 'pending' : 'cancelled',
createdAt: steps[0]?.createdAt ?? new Date(nowMs),
updatedAt: steps[0]?.updatedAt ?? new Date(nowMs),
});
}
const completed = steps.filter((step) => step.status === 'completed').length;
let overallStatus: DemoFlowState['overallStatus'];
if (actualSteps.some((step) => step.status === 'processing')) overallStatus = 'processing';
else if (actualSteps.some((step) => step.status === 'pending')) overallStatus = 'pending';
else if (terminalStatus != null) overallStatus = terminalStatus;
else if (actualSteps.length === total && actualSteps.every((step) => step.status === 'completed')) {
overallStatus = 'completed';
} else {
overallStatus = 'pending';
}
const active = overallStatus === 'processing' || overallStatus === 'pending';
const currentStep =
steps.find((step) => step.status === 'processing') ??
steps.find((step) => step.status === 'pending') ??
actualSteps[actualSteps.length - 1] ??
null;
const doneProgress = total > 0 ? completed / total : 0;
const currentProgress = currentStep && isActiveStatus(currentStep) && total > 0
? Math.min(1, Math.max(0, currentStep.progress ?? 0)) / total
: 0;
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
const sourcePointcloud = sourcePointCloudFileId == null
? undefined
: pointcloudFiles.find((file) => file.id === sourcePointCloudFileId);
const sourcePointCount =
sourcePointcloud?.metadataStatus === 'ready'
&& typeof sourcePointcloud.pointCount === 'number'
&& Number.isFinite(sourcePointcloud.pointCount)
&& sourcePointcloud.pointCount >= 0
? sourcePointcloud.pointCount
: null;
const completedPointCount =
sourcePointCount == null ? null : sourcePointCount * (overallProgress / 100);
const startTimestamps = actualSteps
.map((step) => timestampFromUnknown(step.createdAt))
.filter((value): value is number => value != null);
const updateTimestamps = actualSteps
.map((step) => timestampFromUnknown(step.updatedAt))
.filter((value): value is number => value != null);
const startedAt = startTimestamps.length > 0 ? Math.min(...startTimestamps) : null;
const endedAt = active
? null
: cancelledAt ?? (updateTimestamps.length > 0 ? Math.max(...updateTimestamps) : startedAt);
const elapsedMs = startedAt == null
? 0
: Math.max(0, (endedAt ?? nowMs) - startedAt);
return {
active,
runId: activeRunId,
total,
completed,
steps,
overallStatus,
currentStep,
overallProgress,
sourcePointCloudFileId,
sourcePointCount,
completedPointCount,
startedAt,
endedAt,
elapsedMs,
};
}
export function useDemoFlowState(taskId: number | null): DemoFlowState {
const { data: results = [] } = trpc.analysisResult.list.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
const { data: pointcloudFiles = [] } = trpc.pointcloudFile.list.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null },
);
const [nowMs, setNowMs] = useState(() => Date.now());
const state = useMemo(
() => aggregateDemoFlowState(
taskId,
results as AnalysisResultRow[],
pointcloudFiles as PointcloudFileRow[],
nowMs,
),
[nowMs, pointcloudFiles, results, taskId],
);
useEffect(() => {
setNowMs(Date.now());
if (!state.active) return;
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [state.active, state.runId]);
return state;
}

View File

@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
import { createRoot } from "react-dom/client";
import superjson from "superjson";
import { toast } from "sonner";
import App from "./App";
import { getLoginUrl } from "./const";
import "./index.css";
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!isUnauthorized) return;
window.location.href = getLoginUrl();
if (window.location.pathname !== "/login") window.location.href = "/login";
};
queryClient.getQueryCache().subscribe(event => {
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.mutation.state.error;
redirectToLoginIfUnauthorized(error);
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
toast.error(error.message || "无权执行此操作");
}
console.error("[API Mutation Error]", error);
}
});

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function ChangePassword() {
const { user, loading, logout } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const change = trpc.auth.changePassword.useMutation({
onSuccess: async () => {
await utils.auth.me.invalidate();
setLocation("/");
},
});
if (!loading && !user) return <Redirect to="/login" />;
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
event.preventDefault();
if (!mismatch) change.mutate({ currentPassword, newPassword });
}}>
<div>
<h1 className="text-xl font-semibold">修改密码</h1>
<p className="text-sm text-muted-foreground mt-1">
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
</p>
</div>
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
</form>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useAuth } from '@/_core/hooks/useAuth';
import { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer';
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
@@ -9,8 +10,8 @@ import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
import type { Overlay3D } from '@/components/GenericResultView';
import { trpc } from '@/lib/trpc';
import {
LogIn,
Box,
LogOut,
Users,
Cloud,
Eye,
EyeOff,
@@ -22,8 +23,23 @@ import {
import { toast } from 'sonner';
export default function Home() {
const { loading, isAuthenticated } = useAuth();
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
const utils = trpc.useUtils();
const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
// 本次使用时间只属于当前页面会话;刷新或重新登录后从零开始。
useEffect(() => {
if (!user) {
setSessionElapsedMs(0);
return;
}
const startedAt = Date.now();
setSessionElapsedMs(0);
const intervalId = window.setInterval(() => {
setSessionElapsedMs(Date.now() - startedAt);
}, 1000);
return () => window.clearInterval(intervalId);
}, [user?.id]);
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
const [selection, setSelection] = useState<TreeSelection>({
@@ -66,8 +82,17 @@ export default function Home() {
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
const demoFlowState = useDemoFlowState(selection.taskId);
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
const pluginNameById = useMemo(() => {
const map = new Map<string, string>();
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
return map;
}, [pluginList]);
// ── 视图控制 ───────────────────────────────────────────────────────────────
const [showHeatmap, setShowHeatmap] = useState(true);
const [showHeatmap, setShowHeatmap] = useState(false);
const [showPointCloud, setShowPointCloud] = useState(true);
const [showCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false);
@@ -133,6 +158,7 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setShowHeatmap(false);
setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]);
setRoiPickMode(false);
@@ -217,6 +243,10 @@ export default function Home() {
);
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setShowHeatmap(false);
setPointCloudTaskId(taskId);
setPointCloudUrls([`__task:${taskId}`]);
setPcLoading(true);
@@ -247,25 +277,24 @@ export default function Home() {
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
const renderLeftPanel = () => {
if (selection.modelId == null) {
return (
<VehicleTree selection={selection} onSelect={setSelection} />
);
}
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<VehicleTree selection={selection} onSelect={setSelection} />
<div className="h-px bg-border my-1" />
<TaskManager
modelId={selection.modelId}
activeTaskId={selection.taskId}
onSelectTask={taskId => setSelection(s => ({ ...s, taskId }))}
onLoadPointClouds={handleLoadPointClouds}
onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
/>
</>
{selection.modelId != null && (
<>
<div className="h-px bg-border my-1" />
<TaskManager
modelId={selection.modelId}
activeTaskId={selection.taskId}
onSelectTask={taskId => setSelection(s => ({ ...s, taskId }))}
onLoadPointClouds={handleLoadPointClouds}
onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
/>
</>
)}
</div>
);
};
@@ -281,7 +310,7 @@ export default function Home() {
}
return (
<div className="space-y-4">
<AlgorithmRunPanel
{canWrite && <AlgorithmRunPanel
taskId={selection.taskId}
modelId={selection.modelId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
@@ -297,8 +326,8 @@ export default function Home() {
setRoiPickedPoint(null);
}}
activeRoiPickTarget={activeRoiPickTarget}
/>
<div className="h-px bg-border" />
/>}
{canWrite && <div className="h-px bg-border" />}
<AnalysisPanel
taskId={selection.taskId}
onReplay={resultId => console.log('[Replay] resultId', resultId)}
@@ -306,6 +335,7 @@ export default function Home() {
setPluginOverlay(null);
setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap');
setShowHeatmap(true);
}}
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
const expectedIds = inputPointCloudFileIds ?? [];
@@ -348,6 +378,7 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay({ taskId, resultId, overlay });
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
setShowHeatmap(overlay.kind === 'vertexColors');
}}
externalPending={runAnalysisJob.isPending}
onRunAssembly={() => {
@@ -368,11 +399,8 @@ export default function Home() {
{/* 顶部工具栏 */}
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
<Box className="w-4 h-4 text-primary" />
</div>
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
<div className="flex items-center">
<h1 className="text-sm font-semibold">宏量点云处理软件</h1>
</div>
{activeModel && (
@@ -420,10 +448,20 @@ export default function Home() {
{loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : !isAuthenticated ? (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" />
</a>
) : user ? (
<div className="flex items-center gap-2 text-xs">
<div className="text-right leading-tight">
<div>{user.username}</div>
<div className="whitespace-nowrap text-[10px] text-muted-foreground">
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
<span className="mx-1">·</span>
本次使用时间 <span className="font-mono tabular-nums">{formatElapsedTime(sessionElapsedMs)}</span>
</div>
</div>
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
</div>
) : null}
</div>
</header>
@@ -432,7 +470,7 @@ export default function Home() {
<div className="flex-1 flex overflow-hidden">
{/* 左侧面板 */}
{showLeftPanel && (
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
<aside className="flex w-72 flex-shrink-0 flex-col overflow-hidden border-r border-border bg-card/30 p-3">
{renderLeftPanel()}
</aside>
)}
@@ -481,6 +519,7 @@ export default function Home() {
setRoiPickedPoint(pt);
setRoiPickMode(false);
}}
demoFlowActive={demoFlowState.active}
className="w-full h-full"
/>
@@ -495,6 +534,8 @@ export default function Home() {
</div>
</div>
)}
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
</div>
{/* 操作提示 */}
@@ -549,6 +590,7 @@ export default function Home() {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
setShowHeatmap(false);
setShowPointCloud(true);
return;
}
@@ -564,14 +606,16 @@ export default function Home() {
正在显示分析结果 #{heatmapViewResultId}
</span>
)}
<button
type="button"
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowHeatmap(v => !v)}
>
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span>热力图</span>
</button>
{pointCloudRenderMode === 'smoothHeatmap' && (
<button
type="button"
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowHeatmap(v => !v)}
>
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span>热力图</span>
</button>
)}
</div>
<div className="h-5 w-px bg-border" />

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { Redirect, useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function Login() {
const { user, loading } = useAuth();
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const login = trpc.auth.login.useMutation({
onSuccess: async (signedInUser) => {
utils.auth.me.setData(undefined, signedInUser);
await utils.auth.me.invalidate();
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
},
});
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form
className="w-full max-w-sm industrial-panel p-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
login.mutate({ username, password });
}}
>
<div>
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
</div>
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
{login.isPending ? "正在登录…" : "登录"}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { ArrowLeft, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
type Role = keyof typeof roleNames;
export default function Users() {
const [, setLocation] = useLocation();
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [draftSearch, setDraftSearch] = useState("");
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
const list = trpc.users.list.useQuery(search ? { search } : {});
const refresh = () => utils.users.list.invalidate();
const onError = (error: { message: string }) => toast.error(error.message);
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
return (
<div className="min-h-screen bg-background p-6 space-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
</header>
<section className="industrial-panel p-5">
<h2 className="font-medium mb-4">新建用户</h2>
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
</form>
</section>
<section className="industrial-panel p-5 space-y-4">
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
<tbody>{list.data?.map(user => (
<tr key={user.id} className="border-b border-border/60">
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
</tr>
))}</tbody>
</table>
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
</div>
</section>
</div>
);
}

View File

@@ -44,16 +44,15 @@ services:
- NODE_ENV=development
- PORT=3002
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
- AUTH_MODE=local
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
- S3_ENDPOINT=http://localhost:9010
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
- S3_BUCKET=pointcloud-data
- S3_REGION=us-east-1
- VITE_APP_TITLE=宏量点云处理系统
- VITE_OAUTH_PORTAL_URL=disabled
- VITE_APP_ID=
- VITE_APP_TITLE=宏量点云处理软件
# 数据目录路径
- DATA_DIR=/data
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)

View File

@@ -28,9 +28,9 @@ services:
- NODE_ENV=production
- PORT=3002
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
# 内网部署禁用OAuth,使用本地认证
- AUTH_MODE=local
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
# MinIO S3兼容存储配置
- S3_ENDPOINT=http://localhost:9010
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
@@ -42,7 +42,7 @@ services:
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
- ALG_ROOT=/alg
# 应用配置
- VITE_APP_TITLE=宏量点云处理系统
- VITE_APP_TITLE=宏量点云处理软件
depends_on:
mysql:
condition: service_healthy

View File

@@ -27,7 +27,7 @@ MINIO_ROOT_PASSWORD=minioadmin123
```bash
# 应用标题
VITE_APP_TITLE=宏量点云处理系统
VITE_APP_TITLE=宏量点云处理软件
```
## 可选配置

View File

@@ -16,15 +16,17 @@ USE pointcloud_db;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openId VARCHAR(64) NOT NULL UNIQUE,
username VARCHAR(64) NOT NULL UNIQUE,
passwordHash VARCHAR(255),
name TEXT,
email VARCHAR(320),
loginMethod VARCHAR(64),
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
isActive INT NOT NULL DEFAULT 1,
mustChangePassword INT NOT NULL DEFAULT 1,
sessionVersion INT NOT NULL DEFAULT 0,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_openId (openId),
lastLoginAt TIMESTAMP NULL,
INDEX idx_email (email(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -124,6 +126,22 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` (
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
`pointCount` BIGINT NULL,
`pointAttributes` JSON NULL,
`pointDataFormat` VARCHAR(32) NULL,
`bytesPerPoint` INT NULL,
`metadataError` TEXT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_model_pointcloud_modelId` (`modelId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
@@ -143,6 +161,16 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
`name` VARCHAR(255) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'uploaded',
`sourceResultId` INT NULL,
`sourcePointcloudFileId` INT NULL,
`sourceModelPointcloudId` INT NULL,
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
`pointCount` BIGINT NULL,
`pointAttributes` JSON NULL,
`pointDataFormat` VARCHAR(32) NULL,
`bytesPerPoint` INT NULL,
`metadataError` TEXT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -158,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` (
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建默认管理员用户 (内网部署使用)
INSERT INTO users (openId, name, email, role) VALUES
('admin-local', '系统管理员', 'admin@localhost', 'admin')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- 输出初始化完成信息
SELECT 'Database initialization completed successfully!' AS message;

View File

@@ -1,5 +1,11 @@
# 算法集成规范(Route B · 通用接口接入)
> **文档分工说明(2026-08-13)**:本文继续负责插件运行、Manifest、输入、进度、
> 产物和平台接入方式。算法 `result.json` 中的正式测量项、结构化任务号、检测对象、
> 多位置结果以及中间数据隔离,已由
> [`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md)
> 统一规定。若本文第 4 节旧示例与该规范冲突,**以算法交付与输出规范为准**。
本文是宏量点云处理平台对**多类算法批量接入**的对外规范,面向算法团队 +
验收方。一句话总结:
@@ -130,6 +136,11 @@ runner 在 workDir 写出 `input.json` 后调用算法 CLI,固定路径:
## 4. 输出契约 — `result.json`
以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合
[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md),
并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入
`measurements`。
算法在 `outDir/result.json` 写最终结果:
```jsonc

View File

@@ -0,0 +1,22 @@
ALTER TABLE `users`
MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`,
ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`,
ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`,
ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`,
ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`,
ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`;
UPDATE `users`
SET `username` = CONCAT('legacy_user_', `id`),
`isActive` = 0,
`mustChangePassword` = 1,
`sessionVersion` = `sessionVersion` + 1;
ALTER TABLE `users`
MODIFY COLUMN `username` VARCHAR(64) NOT NULL,
ADD UNIQUE INDEX `users_username_unique` (`username`),
DROP INDEX `users_openId_unique`,
DROP COLUMN `openId`,
DROP COLUMN `loginMethod`,
DROP COLUMN `lastSignedIn`;

View File

@@ -0,0 +1,15 @@
ALTER TABLE `model_pointcloud_files`
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
ADD COLUMN `pointCount` bigint NULL,
ADD COLUMN `pointAttributes` json NULL,
ADD COLUMN `pointDataFormat` varchar(32) NULL,
ADD COLUMN `bytesPerPoint` int NULL,
ADD COLUMN `metadataError` text NULL;
ALTER TABLE `task_pointcloud_files`
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
ADD COLUMN `pointCount` bigint NULL,
ADD COLUMN `pointAttributes` json NULL,
ADD COLUMN `pointDataFormat` varchar(32) NULL,
ADD COLUMN `bytesPerPoint` int NULL,
ADD COLUMN `metadataError` text NULL;

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS `inspection_objects` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`status` ENUM('draft','active') NOT NULL DEFAULT 'draft',
`defaultModelPointcloudId` INT NULL,
`defaultModelCadFileId` INT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_inspection_objects_model_name` (`modelId`,`name`),
KEY `idx_inspection_objects_model` (`modelId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `model_pointcloud_files`
ADD COLUMN `inspectionObjectId` INT NULL AFTER `filePath`,
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `inspectionObjectId`,
ADD KEY `idx_model_pointcloud_object` (`inspectionObjectId`);
ALTER TABLE `model_cad_files`
ADD COLUMN `contentScope` ENUM('whole_vehicle') NOT NULL DEFAULT 'whole_vehicle' AFTER `filePath`,
ADD COLUMN `status` ENUM('available','disabled') NOT NULL DEFAULT 'available' AFTER `contentScope`;
CREATE TABLE IF NOT EXISTS `model_cad_inspection_objects` (
`cadFileId` INT NOT NULL,
`inspectionObjectId` INT NOT NULL,
UNIQUE KEY `uk_model_cad_inspection_object` (`cadFileId`,`inspectionObjectId`),
KEY `idx_model_cad_object` (`inspectionObjectId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `inspection_tasks`
ADD COLUMN `inspectionObjectId` INT NULL AFTER `modelId`,
ADD KEY `idx_inspection_tasks_object` (`inspectionObjectId`);

View File

@@ -1,17 +1,21 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
import type { PointAttribute } from "../shared/pointcloudMetadata";
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
// ─── 本地用户 ──────────────────────────────────────────────────────────────────
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
openId: varchar("openId", { length: 64 }).notNull().unique(),
username: varchar("username", { length: 64 }).notNull().unique(),
passwordHash: varchar("passwordHash", { length: 255 }),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
isActive: int("isActive").default(1).notNull(),
mustChangePassword: int("mustChangePassword").default(1).notNull(),
sessionVersion: int("sessionVersion").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
lastLoginAt: timestamp("lastLoginAt"),
});
export type User = typeof users.$inferSelect;
@@ -46,6 +50,25 @@ export const vehicleModels = mysqlTable("vehicle_models", {
export type VehicleModel = typeof vehicleModels.$inferSelect;
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
// ─── 检测对象 ────────────────────────────────────────────────────────────────
// 车型下真正执行检测的对象,例如头车、中间车。默认资源只影响新建任务。
export const inspectionObjects = mysqlTable("inspection_objects", {
id: int("id").autoincrement().primaryKey(),
modelId: int("modelId").notNull(),
name: varchar("name", { length: 100 }).notNull(),
description: text("description"),
status: mysqlEnum("status", ["draft", "active"]).default("draft").notNull(),
defaultModelPointcloudId: int("defaultModelPointcloudId"),
defaultModelCadFileId: int("defaultModelCadFileId"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
modelNameUnique: uniqueIndex("uk_inspection_objects_model_name").on(table.modelId, table.name),
}));
export type InspectionObject = typeof inspectionObjects.$inferSelect;
export type InsertInspectionObject = typeof inspectionObjects.$inferInsert;
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
@@ -56,12 +79,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
format: varchar("format", { length: 32 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
filePath: varchar("filePath", { length: 512 }).notNull(),
contentScope: mysqlEnum("contentScope", ["whole_vehicle"]).default("whole_vehicle").notNull(),
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
export const modelCadInspectionObjects = mysqlTable("model_cad_inspection_objects", {
cadFileId: int("cadFileId").notNull(),
inspectionObjectId: int("inspectionObjectId").notNull(),
}, (table) => ({
cadObjectUnique: uniqueIndex("uk_model_cad_inspection_object").on(table.cadFileId, table.inspectionObjectId),
}));
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
// 一个车型可维护多个通用点云文件,供任务按需关联复用
@@ -71,6 +103,14 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
name: varchar("name", { length: 255 }).notNull(),
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
filePath: varchar("filePath", { length: 512 }).notNull(),
inspectionObjectId: int("inspectionObjectId"),
status: mysqlEnum("status", ["available", "disabled"]).default("available").notNull(),
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
pointCount: bigint("pointCount", { mode: "number" }),
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
bytesPerPoint: int("bytesPerPoint"),
metadataError: text("metadataError"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
@@ -103,6 +143,7 @@ export type InsertTaskCadFile = typeof taskCadFiles.$inferInsert;
export const inspectionTasks = mysqlTable("inspection_tasks", {
id: int("id").autoincrement().primaryKey(),
modelId: int("modelId").notNull(),
inspectionObjectId: int("inspectionObjectId"),
name: varchar("name", { length: 255 }).notNull(),
inspector: varchar("inspector", { length: 100 }),
notes: text("notes"),
@@ -136,6 +177,12 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
sourcePointcloudFileId: int("sourcePointcloudFileId"),
/** 来源车型点云文件 ID(仅 model_library 时有值) */
sourceModelPointcloudId: int("sourceModelPointcloudId"),
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
pointCount: bigint("pointCount", { mode: "number" }),
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
bytesPerPoint: int("bytesPerPoint"),
metadataError: text("metadataError"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { execFile as execFileCallback } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise";
const execFile = promisify(execFileCallback);
export type Measurement = Record<string, unknown>;
export interface CandidateRow extends RowDataPacket {
id: number;
taskId: number;
measurements: unknown;
resultData: Record<string, unknown> | null;
}
export interface BackfillPlan {
row: CandidateRow;
resultJsonPath: string;
originalResultJson: Record<string, unknown>;
measurements: Measurement[];
}
export interface ExecutionSummary {
planned: number;
applied: number;
skipped: number;
failed: number;
}
export function mergeMeasurements<T extends Record<string, unknown>>(
value: T | null,
measurements: Measurement[],
): T & { measurements: Measurement[] } {
return { ...(value ?? ({} as T)), measurements };
}
export async function executePlans(
plans: BackfillPlan[],
apply: boolean,
persist: (plan: BackfillPlan) => Promise<boolean>,
): Promise<ExecutionSummary> {
const summary: ExecutionSummary = {
planned: plans.length,
applied: 0,
skipped: apply ? 0 : plans.length,
failed: 0,
};
if (!apply) return summary;
for (const plan of plans) {
try {
if (await persist(plan)) summary.applied += 1;
else summary.skipped += 1;
} catch (error) {
summary.failed += 1;
console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error);
}
}
return summary;
}
function argumentValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function timestamp(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
async function parseMeasurements(
excelPath: string,
templatePath: string,
helperPath: string,
): Promise<Measurement[]> {
const python = process.env.PYTHON3_PATH || "/usr/bin/python3";
const { stdout } = await execFile(
python,
[helperPath, "--excel", excelPath, "--template", templatePath],
{ maxBuffer: 10 * 1024 * 1024 },
);
const parsed = JSON.parse(stdout) as unknown;
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error("解析结果为空")
}
return parsed.filter((item): item is Measurement => !!item && typeof item === "object");
}
async function listCandidates(pool: Pool, resultId?: number): Promise<CandidateRow[]> {
const filters = [
"pluginId = 'flatness-suite'",
"status = 'completed'",
"(measurements IS NULL OR JSON_LENGTH(measurements) = 0)",
];
const params: number[] = [];
if (resultId !== undefined) {
filters.push("id = ?");
params.push(resultId);
}
const [rows] = await pool.query<CandidateRow[]>(
`SELECT id, taskId, measurements, resultData
FROM task_analysis_results
WHERE ${filters.join(" AND ")}
ORDER BY id`,
params,
);
return rows;
}
async function buildPlans(
rows: CandidateRow[],
dataDir: string,
templatePath: string,
helperPath: string,
): Promise<{ plans: BackfillPlan[]; skipped: number }> {
const plans: BackfillPlan[] = [];
let skipped = 0;
for (const row of rows) {
const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id));
const resultJsonPath = path.join(resultDir, "result.json");
const excelPath = path.join(resultDir, "contour.xlsx");
try {
const [resultText] = await Promise.all([
fs.readFile(resultJsonPath, "utf8"),
fs.access(excelPath),
]);
const resultJson = JSON.parse(resultText) as Record<string, unknown>;
const extra = resultJson.extra as Record<string, unknown> | undefined;
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | undefined;
if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") {
throw new Error("不是 contour_batch 批量结果")
}
const measurements = await parseMeasurements(excelPath, templatePath, helperPath);
plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements });
console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`);
} catch (error) {
skipped += 1;
console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return { plans, skipped };
}
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`;
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await fs.rename(temporaryPath, filePath);
}
async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise<string> {
const backupDir = path.join(dataDir, "maintenance-backups");
await fs.mkdir(backupDir, { recursive: true });
const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`);
await writeJsonAtomically(backupPath, {
version: 1,
createdAt: new Date().toISOString(),
entries: plans.map((plan) => ({
id: plan.row.id,
taskId: plan.row.taskId,
resultJsonPath: plan.resultJsonPath,
measurements: plan.row.measurements,
resultData: plan.row.resultData,
resultJson: plan.originalResultJson,
})),
});
return backupPath;
}
async function persistPlan(pool: Pool, plan: BackfillPlan): Promise<boolean> {
const connection = await pool.getConnection();
const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements);
const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements);
try {
await connection.beginTransaction();
await writeJsonAtomically(plan.resultJsonPath, updatedResultJson);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE task_analysis_results
SET measurements = ?, resultData = ?
WHERE id = ?
AND pluginId = 'flatness-suite'
AND status = 'completed'
AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`,
[JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id],
);
if (result.affectedRows !== 1) {
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson);
await connection.rollback();
return false;
}
await connection.commit();
return true;
} catch (error) {
await connection.rollback();
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined);
throw error;
} finally {
connection.release();
}
}
interface BackupManifest {
entries: Array<{
id: number;
resultJsonPath: string;
measurements: unknown;
resultData: unknown;
resultJson: unknown;
}>;
}
async function restoreBackup(pool: Pool, backupPath: string): Promise<void> {
const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest;
if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效")
for (const entry of manifest.entries) {
await writeJsonAtomically(entry.resultJsonPath, entry.resultJson);
await pool.execute(
"UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'",
[
entry.measurements == null ? null : JSON.stringify(entry.measurements),
entry.resultData == null ? null : JSON.stringify(entry.resultData),
entry.id,
],
);
console.log(`[backfill] 已恢复结果 ${entry.id}`);
}
}
async function main(): Promise<void> {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("缺少 DATABASE_URL")
const dataDir = process.env.DATA_DIR || "/data";
const algRoot = process.env.ALG_ROOT || "/alg";
const resultIdRaw = argumentValue("--result-id");
const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw);
if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数")
const apply = process.argv.includes("--apply");
const restorePath = argumentValue("--restore");
const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx");
const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py");
const pool = mysql.createPool(databaseUrl);
try {
if (restorePath) {
await restoreBackup(pool, restorePath);
return;
}
const rows = await listCandidates(pool, resultId);
const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath);
let backupPath: string | undefined;
if (apply && plans.length > 0) {
backupPath = await writeBackup(plans, dataDir);
console.log(`[backfill] 写入前备份: ${backupPath}`);
}
const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan));
summary.skipped += skipped;
console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`);
if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入");
if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`);
} finally {
await pool.end();
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
main().catch((error) => {
console.error("[backfill] 失败:", error);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,60 @@
import mysql from 'mysql2/promise';
type Mapping = { modelId: number; objectName: string; pointclouds: string[]; defaultPointcloud: string; cadFiles: string[]; defaultCad: string };
const mappings: Mapping[] = [
{ modelId: 1, objectName: '中间车', pointclouds: ['1_10.ply', '1_11.ply', '1_12.ply'], defaultPointcloud: '1_10.ply', cadFiles: ['train_1_mesh.ply'], defaultCad: 'train_1_mesh.ply' },
{ modelId: 2, objectName: '头车', pointclouds: ['2_10.ply', '2_11.ply', '2_12.ply', 'train_2_10M.ply', 'train_2_1T.ply'], defaultPointcloud: '2_10.ply', cadFiles: ['m000004802418tc01_asm.stp', 'train_2_mesh.ply'], defaultCad: 'm000004802418tc01_asm.stp' },
];
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error('DATABASE_URL is required');
const connection = await mysql.createConnection(connectionString);
let createdObjects = 0;
let assignedPointclouds = 0;
let assignedCads = 0;
let classifiedTasks = 0;
const warnings: Array<{ taskId: number; reason: string }> = [];
const conflicts: string[] = [];
try {
await connection.beginTransaction();
for (const mapping of mappings) {
const [existing] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
if (existing.length === 0) {
await connection.execute('INSERT INTO inspection_objects (modelId,name,status) VALUES (?,?,\'draft\')', [mapping.modelId, mapping.objectName]);
createdObjects += 1;
}
const [objectRows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_objects WHERE modelId=? AND name=?', [mapping.modelId, mapping.objectName]);
const objectId = Number(objectRows[0].id);
for (const name of mapping.pointclouds) {
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id,inspectionObjectId FROM model_pointcloud_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
if (rows.length !== 1) { conflicts.push(`点云 model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
if (rows[0].inspectionObjectId && Number(rows[0].inspectionObjectId) !== objectId) { conflicts.push(`点云 ${name} 已属于其他检测对象`); continue; }
if (!rows[0].inspectionObjectId) { await connection.execute('UPDATE model_pointcloud_files SET inspectionObjectId=?,status=\'available\' WHERE id=?', [objectId, rows[0].id]); assignedPointclouds += 1; }
}
for (const name of mapping.cadFiles) {
const [rows] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_cad_files WHERE modelId=? AND name=?', [mapping.modelId, name]);
if (rows.length !== 1) { conflicts.push(`CAD model=${mapping.modelId} name=${name} 匹配数量=${rows.length}`); continue; }
const [result] = await connection.execute<mysql.ResultSetHeader>('INSERT IGNORE INTO model_cad_inspection_objects (cadFileId,inspectionObjectId) VALUES (?,?)', [rows[0].id, objectId]);
assignedCads += result.affectedRows;
}
const [[defaultPc]] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM model_pointcloud_files WHERE modelId=? AND name=? AND inspectionObjectId=?', [mapping.modelId, mapping.defaultPointcloud, objectId]);
const [[defaultCad]] = await connection.query<mysql.RowDataPacket[]>('SELECT c.id FROM model_cad_files c JOIN model_cad_inspection_objects r ON r.cadFileId=c.id WHERE c.modelId=? AND c.name=? AND r.inspectionObjectId=?', [mapping.modelId, mapping.defaultCad, objectId]);
if (defaultPc && defaultCad) await connection.execute('UPDATE inspection_objects SET defaultModelPointcloudId=?,defaultModelCadFileId=?,status=\'active\' WHERE id=?', [defaultPc.id, defaultCad.id, objectId]);
else conflicts.push(`${mapping.objectName} 默认输入方案资源不完整`);
}
const [tasks] = await connection.query<mysql.RowDataPacket[]>('SELECT id FROM inspection_tasks WHERE inspectionObjectId IS NULL ORDER BY id');
for (const task of tasks) {
const [sources] = await connection.query<mysql.RowDataPacket[]>('SELECT DISTINCT mp.inspectionObjectId FROM task_pointcloud_files tp JOIN model_pointcloud_files mp ON mp.id=tp.sourceModelPointcloudId WHERE tp.taskId=? AND mp.inspectionObjectId IS NOT NULL', [task.id]);
if (sources.length === 1) { await connection.execute('UPDATE inspection_tasks SET inspectionObjectId=? WHERE id=? AND inspectionObjectId IS NULL', [sources[0].inspectionObjectId, task.id]); classifiedTasks += 1; }
else warnings.push({ taskId: Number(task.id), reason: sources.length > 1 ? '包含不同检测对象的车型库点云' : '无可推断的车型库来源点云' });
}
await connection.commit();
console.log(JSON.stringify({ createdObjects, assignedPointclouds, assignedCads, classifiedTasks, unclassifiedTasks: warnings, conflicts }, null, 2));
} catch (error) {
await connection.rollback();
throw error;
} finally {
await connection.end();
}

153
server/_core/auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { parse as parseCookieHeader } from "cookie";
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import { promisify } from "util";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
const scrypt = promisify(scryptCallback);
const PASSWORD_MIN_LENGTH = 8;
function sessionSecret() {
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
}
return new TextEncoder().encode(ENV.cookieSecret);
}
export function validatePassword(password: string) {
if (password.length < PASSWORD_MIN_LENGTH) {
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
}
}
export async function hashPassword(password: string) {
validatePassword(password);
const salt = randomBytes(16);
const derived = await scrypt(password, salt, 64) as Buffer;
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
}
export async function verifyPassword(password: string, encoded: string | null) {
if (!encoded) return false;
const [scheme, saltValue, hashValue] = encoded.split("$");
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
try {
const expected = Buffer.from(hashValue, "base64url");
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
return actual.length === expected.length && timingSafeEqual(actual, expected);
} catch {
return false;
}
}
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
.sign(sessionSecret());
}
function readSessionCookie(req: Request) {
if (!req.headers.cookie) return undefined;
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
}
export async function authenticateRequest(req: Request): Promise<User | null> {
const token = readSessionCookie(req);
if (!token) return null;
try {
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
const userId = Number(payload.userId);
const sessionVersion = Number(payload.sessionVersion);
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
const user = await db.getUserById(userId);
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
return user;
} catch {
return null;
}
}
type Attempt = { failures: number[] };
const attempts = new Map<string, Attempt>();
const RATE_WINDOW_MS = 15 * 60 * 1000;
const RATE_MAX_FAILURES = 5;
function normalizedUsername(username: string) {
return username.trim().toLowerCase();
}
function attemptKeys(ip: string, username: string) {
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
}
function recentFailures(key: string, now = Date.now()) {
const record = attempts.get(key);
if (!record) return [];
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
if (record.failures.length === 0) attempts.delete(key);
return record.failures;
}
export function isLoginRateLimited(ip: string, username: string) {
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
}
export function recordLoginFailure(ip: string, username: string) {
const now = Date.now();
for (const key of attemptKeys(ip, username)) {
const failures = recentFailures(key, now);
failures.push(now);
attempts.set(key, { failures });
}
}
export function clearLoginFailures(ip: string, username: string) {
for (const key of attemptKeys(ip, username)) attempts.delete(key);
}
export function resetLoginRateLimitForTests() {
attempts.clear();
}
export async function ensureBootstrapAdmin() {
if (await db.countActiveSuperAdmins() > 0) return;
const username = ENV.bootstrapAdminUsername.trim();
const password = ENV.bootstrapAdminPassword;
if (!username || !password) {
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
}
validatePassword(password);
const existing = await db.getUserByUsername(username);
const passwordHash = await hashPassword(password);
if (existing) {
await db.updateUser(existing.id, {
passwordHash,
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: existing.sessionVersion + 1,
});
} else {
await db.createUser({
username,
passwordHash,
name: "系统管理员",
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: 0,
});
}
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
}
export function publicUser(user: User) {
const { passwordHash: _passwordHash, ...safe } = user;
return safe;
}

View File

@@ -1,6 +1,6 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { User } from "../../drizzle/schema";
import { sdk } from "./sdk";
import { authenticateRequest } from "./auth";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
@@ -14,7 +14,7 @@ export async function createContext(
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
user = await authenticateRequest(opts.req);
} catch (error) {
// Authentication is optional for public procedures.
user = null;

View File

@@ -1,13 +1,5 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
return {
httpOnly: true,
path: "/",
sameSite: "none",
sameSite: "lax",
secure: isSecureRequest(req),
};
}

View File

@@ -1,13 +1,8 @@
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
authMode: process.env.AUTH_MODE ?? "oauth",
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",

View File

@@ -7,11 +7,11 @@ import path from "path";
import { fileURLToPath } from "url";
import mysql from "mysql2/promise";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { fileRouter } from "../fileRoutes";
import { ensureBootstrapAdmin } from "./auth";
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
}
async function startServer() {
try {
await runMigrations();
} catch (e) {
console.error("[Migration] Unexpected error, server will still start:", e);
}
await runMigrations();
await ensureBootstrapAdmin();
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
@@ -128,8 +125,6 @@ async function startServer() {
server.timeout = 0; // 禁用 Node http.Server 全局超时
server.keepAliveTimeout = 0; // 长连接不超时
server.headersTimeout = 0; // 读请求头不超时
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// 禁止代理(NPS 等)缓存 API 响应
app.use('/api', (_req, res, next) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');

View File

@@ -1,53 +0,0 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
function getQueryParam(req: Request, key: string): string | undefined {
const value = req.query[key];
return typeof value === "string" ? value : undefined;
}
export function registerOAuthRoutes(app: Express) {
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
if (!userInfo.openId) {
res.status(400).json({ error: "openId missing from user info" });
return;
}
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: new Date(),
});
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.redirect(302, "/");
} catch (error) {
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});
}

View File

@@ -1,346 +0,0 @@
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import type {
ExchangeTokenRequest,
ExchangeTokenResponse,
GetUserInfoResponse,
GetUserInfoWithJwtRequest,
GetUserInfoWithJwtResponse,
} from "./types/manusTypes";
// Utility function
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
export type SessionPayload = {
openId: string;
appId: string;
name: string;
};
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
class OAuthService {
constructor(private client: ReturnType<typeof axios.create>) {
const isLocalAuth = ENV.authMode === "local";
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
if (!ENV.oAuthServerUrl && !isLocalAuth) {
console.error(
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
);
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
}
}
private decodeState(state: string): string {
const redirectUri = atob(state);
return redirectUri;
}
async getTokenByCode(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
const payload: ExchangeTokenRequest = {
clientId: ENV.appId,
grantType: "authorization_code",
code,
redirectUri: this.decodeState(state),
};
const { data } = await this.client.post<ExchangeTokenResponse>(
EXCHANGE_TOKEN_PATH,
payload
);
return data;
}
async getUserInfoByToken(
token: ExchangeTokenResponse
): Promise<GetUserInfoResponse> {
const { data } = await this.client.post<GetUserInfoResponse>(
GET_USER_INFO_PATH,
{
accessToken: token.accessToken,
}
);
return data;
}
}
const createOAuthHttpClient = (): AxiosInstance =>
axios.create({
baseURL: ENV.oAuthServerUrl,
timeout: AXIOS_TIMEOUT_MS,
});
class SDKServer {
private readonly client: AxiosInstance;
private readonly oauthService: OAuthService;
constructor(client: AxiosInstance = createOAuthHttpClient()) {
this.client = client;
this.oauthService = new OAuthService(this.client);
}
private isLocalAuthMode() {
return ENV.authMode === "local";
}
private deriveLoginMethod(
platforms: unknown,
fallback: string | null | undefined
): string | null {
if (fallback && fallback.length > 0) return fallback;
if (!Array.isArray(platforms) || platforms.length === 0) return null;
const set = new Set<string>(
platforms.filter((p): p is string => typeof p === "string")
);
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
if (
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
set.has("REGISTERED_PLATFORM_AZURE")
)
return "microsoft";
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
const first = Array.from(set)[0];
return first ? first.toLowerCase() : null;
}
/**
* Exchange OAuth authorization code for access token
* @example
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
*/
async exchangeCodeForToken(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
return this.oauthService.getTokenByCode(code, state);
}
/**
* Get user information using access token
* @example
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
*/
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
const data = await this.oauthService.getUserInfoByToken({
accessToken,
} as ExchangeTokenResponse);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoResponse;
}
private parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) {
return new Map<string, string>();
}
const parsed = parseCookieHeader(cookieHeader);
return new Map(Object.entries(parsed));
}
private getSessionSecret() {
const secret = ENV.cookieSecret;
return new TextEncoder().encode(secret);
}
/**
* Create a session token for a Manus user openId
* @example
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
*/
async createSessionToken(
openId: string,
options: { expiresInMs?: number; name?: string } = {}
): Promise<string> {
return this.signSession(
{
openId,
appId: ENV.appId,
name: options.name || "",
},
options
);
}
async signSession(
payload: SessionPayload,
options: { expiresInMs?: number } = {}
): Promise<string> {
const issuedAt = Date.now();
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
const secretKey = this.getSessionSecret();
return new SignJWT({
openId: payload.openId,
appId: payload.appId,
name: payload.name,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
.sign(secretKey);
}
async verifySession(
cookieValue: string | undefined | null
): Promise<{ openId: string; appId: string; name: string } | null> {
if (!cookieValue) {
console.warn("[Auth] Missing session cookie");
return null;
}
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
const { openId, appId, name } = payload as Record<string, unknown>;
if (
!isNonEmptyString(openId) ||
!isNonEmptyString(appId) ||
!isNonEmptyString(name)
) {
console.warn("[Auth] Session payload missing required fields");
return null;
}
return {
openId,
appId,
name,
};
} catch (error) {
console.warn("[Auth] Session verification failed", String(error));
return null;
}
}
async getUserInfoWithJwt(
jwtToken: string
): Promise<GetUserInfoWithJwtResponse> {
const payload: GetUserInfoWithJwtRequest = {
jwtToken,
projectId: ENV.appId,
};
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
GET_USER_INFO_WITH_JWT_PATH,
payload
);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoWithJwtResponse;
}
async authenticateRequest(req: Request): Promise<User> {
if (this.isLocalAuthMode()) {
const signedInAt = new Date();
const localOpenId = ENV.localDevOpenId;
try {
await db.upsertUser({
openId: localOpenId,
name: ENV.localDevName,
email: ENV.localDevEmail,
loginMethod: "local",
lastSignedIn: signedInAt,
});
const localUser = await db.getUserByOpenId(localOpenId);
if (localUser) return localUser;
} catch {
// DB not ready yet (e.g. MySQL still starting up); fall through to
// an in-memory guest user so the UI keeps working.
console.warn("[Auth] DB unavailable in local mode, using fallback user");
}
// Fallback: return a transient in-memory user so requests don't fail
// while MySQL is warming up.
return {
id: 0,
openId: localOpenId,
name: ENV.localDevName ?? "Dev User",
email: ENV.localDevEmail ?? null,
loginMethod: "local",
role: "admin" as const,
createdAt: signedInAt,
updatedAt: signedInAt,
lastSignedIn: signedInAt,
};
}
// Regular authentication flow
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = cookies.get(COOKIE_NAME);
const session = await this.verifySession(sessionCookie);
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const sessionUserId = session.openId;
const signedInAt = new Date();
let user = await db.getUserByOpenId(sessionUserId);
// If user not in DB, sync from OAuth server automatically
if (!user) {
try {
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);
} catch (error) {
console.error("[Auth] Failed to sync user from OAuth:", error);
throw ForbiddenError("Failed to sync user info");
}
}
if (!user) {
throw ForbiddenError("User not found");
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: signedInAt,
});
return user;
}
}
export const sdk = new SDKServer();

View File

@@ -1,4 +1,4 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const requireSession = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const sessionProcedure = t.procedure.use(requireSession);
const requireReadableUser = t.middleware(async opts => {
if (!opts.ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
});
export const protectedProcedure = t.procedure.use(requireReadableUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
});
}),
);
export const superAdminProcedure = t.procedure.use(
t.middleware(async opts => {
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.ctx.user.role !== "super_admin") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
}),
);

View File

@@ -1,69 +0,0 @@
// WebDev Auth TypeScript types
// Auto-generated from protobuf definitions
// Generated on: 2025-09-24T05:57:57.338Z
export interface AuthorizeRequest {
redirectUri: string;
projectId: string;
state: string;
responseType: string;
scope: string;
}
export interface AuthorizeResponse {
redirectUrl: string;
}
export interface ExchangeTokenRequest {
grantType: string;
code: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}
export interface ExchangeTokenResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
refreshToken?: string;
scope: string;
idToken: string;
}
export interface GetUserInfoRequest {
accessToken: string;
}
export interface GetUserInfoResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}
export interface CanAccessRequest {
openId: string;
projectId: string;
}
export interface CanAccessResponse {
canAccess: boolean;
}
export interface GetUserInfoWithJwtRequest {
jwtToken: string;
projectId: string;
}
export interface GetUserInfoWithJwtResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}

View File

@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
import * as db from './db.js';
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
type InspectionObjectContext,
} from './measurementCompatibility.js';
import type {
PluginManifest,
PluginProgressEvent,
@@ -111,7 +117,12 @@ async function gatherInputs(
}
}
return { task, pointCloudRecords, pointCloudPaths, cadPath };
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const inspectionContext = inspectionObjectContextFromName(inspectionObject?.name);
return { task, pointCloudRecords, pointCloudPaths, cadPath, inspectionContext };
}
async function writeInputJson(
@@ -124,6 +135,7 @@ async function writeInputJson(
cadPath: string | null,
inputPointCloudFileIds: number[],
inputCadFileIds: number[],
inspectionContext: InspectionObjectContext,
) {
const inputJson = {
schemaVersion: 1,
@@ -133,6 +145,7 @@ async function writeInputJson(
taskId,
workDir,
outDir: workDir,
context: inspectionContext,
inputs: {
pointCloudPaths,
pointCloudFileIds: inputPointCloudFileIds,
@@ -187,6 +200,11 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowTotal',
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
'demoFlowCancelledAt',
'demoFlowMode',
'demoFlowSelectedTaskNos',
'demoFlowSelectedTaskCount',
'demoFlowPluginTaskNos',
] as const;
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
@@ -329,7 +347,7 @@ export async function runAlgorithmJob(
try {
await patchProgress(resultId, pluginId, 0.02, '准备输入');
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
taskId,
manifest,
pointCloudFileIds,
@@ -348,6 +366,7 @@ export async function runAlgorithmJob(
cadPath,
inputPointCloudFileIds,
inputCadFileIds,
inspectionContext,
);
let exitCode: number | null = 0;
@@ -366,6 +385,28 @@ export async function runAlgorithmJob(
: 'error';
const demoFlowResultData = await readDemoFlowResultData(resultId);
const normalized = normalizeMeasurements({
pluginId,
measurements: result?.measurements,
extra: result?.extra,
context: inspectionContext,
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const requestedDemoTaskNos = Array.isArray(params.demoTaskNos)
? new Set(
params.demoTaskNos
.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
.map(String),
)
: null;
const normalizedMeasurements = Array.isArray(result?.measurements)
? requestedDemoTaskNos
? normalized.measurements.filter((measurement) => (
typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo)
))
: normalized.measurements
: undefined;
const resultData: PluginResultData = {
kind: 'plugin',
pluginId,
@@ -377,12 +418,12 @@ export async function runAlgorithmJob(
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
message: result?.message,
measurements: result?.measurements,
measurements: normalizedMeasurements,
errorStats: result?.errorStats,
overlay3d: result?.overlay3d,
images: result?.images,
files: result?.files,
extra: result?.extra,
extra: normalized.extra,
error: finalStatus === 'error'
? result?.message ?? `子进程退出码 ${exitCode}`
: undefined,
@@ -392,7 +433,7 @@ export async function runAlgorithmJob(
await db.updateTaskAnalysisResult(resultId, {
status: finalStatus,
errorStats: result?.errorStats ?? null,
measurements: result?.measurements ?? null,
measurements: normalizedMeasurements ?? null,
resultData,
});

View File

@@ -0,0 +1,57 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupAnalysisArtifacts, getAnalysisArtifactPaths } from "./analysisArtifacts";
const tempDirs: string[] = [];
async function makeDataDir() {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "analysis-artifacts-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })));
});
describe("cleanupAnalysisArtifacts", () => {
it("deletes the plugin directory and all legacy files for only the requested result", async () => {
const dataDir = await makeDataDir();
const analysisDir = path.join(dataDir, "tasks", "7", "analysis");
await fs.promises.mkdir(path.join(analysisDir, "57"), { recursive: true });
await fs.promises.mkdir(path.join(analysisDir, "58"), { recursive: true });
const files = [
"heatmap_57.bin",
"deviation_57.bin",
"assembly_vertex_57_car-body.bin",
"assembly_vertex_57_bogie.bin",
"heatmap_58.bin",
"assembly_vertex_570_other.bin",
];
await Promise.all(files.map((name) => fs.promises.writeFile(path.join(analysisDir, name), name)));
await cleanupAnalysisArtifacts(7, 57, dataDir);
await expect(fs.promises.stat(path.join(analysisDir, "57"))).rejects.toMatchObject({ code: "ENOENT" });
for (const name of files.slice(0, 4)) {
await expect(fs.promises.stat(path.join(analysisDir, name))).rejects.toMatchObject({ code: "ENOENT" });
}
await expect(fs.promises.stat(path.join(analysisDir, "58"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "heatmap_58.bin"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "assembly_vertex_570_other.bin"))).resolves.toBeDefined();
});
it("rejects invalid IDs before constructing a filesystem target", () => {
expect(() => getAnalysisArtifactPaths(7, Number.NaN, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(7, -1, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(1.5, 2, "/data")).toThrow("taskId");
});
it("is idempotent when artifacts are already absent", async () => {
const dataDir = await makeDataDir();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
import * as fs from "node:fs";
import * as path from "node:path";
export type AnalysisArtifactPaths = {
analysisDir: string;
resultDir: string;
legacyFiles: string[];
};
function assertNumericId(name: string, value: number): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive safe integer`);
}
}
export function getAnalysisArtifactPaths(
taskId: number,
resultId: number,
dataDir = process.env.DATA_DIR || "/data",
): AnalysisArtifactPaths {
assertNumericId("taskId", taskId);
assertNumericId("resultId", resultId);
const tasksDir = path.resolve(dataDir, "tasks");
const taskDir = path.resolve(tasksDir, String(taskId));
const analysisDir = path.resolve(taskDir, "analysis");
const resultDir = path.resolve(analysisDir, String(resultId));
if (path.dirname(taskDir) !== tasksDir || path.dirname(resultDir) !== analysisDir) {
throw new Error("analysis artifact path escaped its fixed root");
}
return {
analysisDir,
resultDir,
legacyFiles: [
path.join(analysisDir, `heatmap_${resultId}.bin`),
path.join(analysisDir, `deviation_${resultId}.bin`),
],
};
}
export async function cleanupAnalysisArtifacts(
taskId: number,
resultId: number,
dataDir = process.env.DATA_DIR || "/data",
): Promise<void> {
const paths = getAnalysisArtifactPaths(taskId, resultId, dataDir);
await fs.promises.rm(paths.resultDir, { recursive: true, force: true });
let entries: string[] = [];
try {
entries = await fs.promises.readdir(paths.analysisDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const assemblyPrefix = `assembly_vertex_${resultId}_`;
const assemblyFiles = entries
.filter((name) => name.startsWith(assemblyPrefix) && name.endsWith(".bin"))
.map((name) => path.join(paths.analysisDir, name));
await Promise.all(
[...paths.legacyFiles, ...assemblyFiles].map((file) =>
fs.promises.rm(file, { force: true }),
),
);
}

View File

@@ -0,0 +1,126 @@
import express from 'express';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
authenticated: true,
resultTaskId: 7,
}));
vi.mock('./_core/auth.js', () => ({
authenticateRequest: vi.fn(async () => mocks.authenticated
? { id: 1, role: 'user', mustChangePassword: 0 }
: null),
}));
vi.mock('./db', () => ({
getTaskAnalysisResultById: vi.fn(async (id: number) => id === 11
? { id, taskId: mocks.resultTaskId }
: undefined),
listTaskPointcloudFiles: vi.fn(async () => []),
}));
const dataDir = mkdtempSync(join(tmpdir(), 'analysis-ply-route-'));
process.env.DATA_DIR = dataDir;
function writePly(relativePath: string, withColor = false): void {
const resultDir = join(dataDir, 'tasks', '7', 'analysis', '11');
const filePath = join(resultDir, relativePath);
mkdirSync(join(filePath, '..'), { recursive: true });
const properties = withColor
? ['x', 'y', 'z', 'red', 'green', 'blue']
: ['x', 'y', 'z'];
const header = Buffer.from([
'ply',
'format binary_little_endian 1.0',
'element vertex 2',
...properties.map((name) => `property float ${name}`),
'end_header',
'',
].join('\n'), 'ascii');
const values = withColor
? [[1, 2, 3, 255, 0, 128], [4, 5, 6, 0, 255, 64]]
: [[7, 8, 9], [10, 11, 12]];
const body = Buffer.alloc(values.length * properties.length * 4);
values.flat().forEach((value, index) => body.writeFloatLE(value, index * 4));
writeFileSync(filePath, Buffer.concat([header, body]));
}
describe('POST /api/files/sample/analysis', () => {
let origin: string;
let closeServer: () => Promise<void>;
beforeAll(async () => {
writePly('plain.ply');
writePly('nested/color.ply', true);
const { fileRouter } = await import('./fileRoutes');
const app = express();
app.use(express.json());
app.use('/api/files', fileRouter);
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
closeServer = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
});
afterAll(async () => {
await closeServer();
rmSync(dataDir, { recursive: true, force: true });
});
beforeEach(() => {
mocks.authenticated = true;
mocks.resultTaskId = 7;
});
async function post(body: unknown) {
return fetch(`${origin}/api/files/sample/analysis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
it('采样单个无色 PLY 并返回 16 字节步长', async () => {
const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply'], totalPoints: 2 });
expect(response.status).toBe(200);
expect(response.headers.get('X-Point-Count')).toBe('2');
expect(response.headers.get('X-Has-Color')).toBe('0');
expect((await response.arrayBuffer()).byteLength).toBe(2 * 16);
});
it('采样多个 PLY,彩色响应使用 28 字节步长且不超预算', async () => {
const response = await post({
taskId: 7,
resultId: 11,
files: ['plain.ply', 'nested/color.ply'],
totalPoints: 3,
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Point-Count')).toBe('3');
expect(response.headers.get('X-Has-Color')).toBe('1');
expect((await response.arrayBuffer()).byteLength).toBe(3 * 28);
});
it('拒绝未登录访问', async () => {
mocks.authenticated = false;
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(401);
});
it('拒绝错误的任务与结果组合', async () => {
mocks.resultTaskId = 8;
expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(404);
});
it.each([
['路径穿越', '../plain.ply', 403],
['绝对路径', '/tmp/plain.ply', 400],
['非 PLY 文件', 'result.json', 400],
['缺失文件', 'missing.ply', 404],
])('拒绝%s', async (_label, file, status) => {
expect((await post({ taskId: 7, resultId: 11, files: [file] })).status).toBe(status);
});
});

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as db from "./db";
import { cancelAlgorithmJob } from "./algorithmJob";
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
import { deleteAnalysisResult } from "./analysisResultDeletion";
vi.mock("./db", () => ({
getTaskAnalysisResultById: vi.fn(),
deleteTaskAnalysisResultWithDerivedPointclouds: vi.fn(),
}));
vi.mock("./algorithmJob", () => ({ cancelAlgorithmJob: vi.fn() }));
vi.mock("./analysisJobControl", () => ({ cancelLegacyAnalysisJob: vi.fn() }));
vi.mock("./analysisArtifacts", () => ({ cleanupAnalysisArtifacts: vi.fn() }));
const row = { id: 57, taskId: 7 } as Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>;
describe("deleteAnalysisResult", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValue(row);
vi.mocked(cleanupAnalysisArtifacts).mockResolvedValue(undefined);
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mockResolvedValue(undefined);
});
it("cancels jobs, removes files, then transactionally cascades derived pointcloud rows", async () => {
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
expect(cancelAlgorithmJob).toHaveBeenCalledWith(57);
expect(cancelLegacyAnalysisJob).toHaveBeenCalledWith(57);
expect(cleanupAnalysisArtifacts).toHaveBeenCalledWith(7, 57);
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).toHaveBeenCalledWith(57);
expect(vi.mocked(cleanupAnalysisArtifacts).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mock.invocationCallOrder[0],
);
});
it("keeps database rows when filesystem cleanup fails", async () => {
vi.mocked(cleanupAnalysisArtifacts).mockRejectedValueOnce(new Error("permission denied"));
await expect(deleteAnalysisResult(57)).rejects.toThrow("permission denied");
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
});
it("treats deletion of an already missing result as success", async () => {
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValueOnce(undefined);
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
expect(cleanupAnalysisArtifacts).not.toHaveBeenCalled();
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
import * as db from "./db";
import { cancelAlgorithmJob } from "./algorithmJob";
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
export async function deleteAnalysisResult(id: number): Promise<{ success: true }> {
const row = await db.getTaskAnalysisResultById(id);
if (!row) return { success: true };
cancelAlgorithmJob(id);
cancelLegacyAnalysisJob(id);
// 文件删除失败时不触碰数据库,保留记录以便用户重试。
await cleanupAnalysisArtifacts(row.taskId, row.id);
await db.deleteTaskAnalysisResultWithDerivedPointclouds(id);
return { success: true };
}

View File

@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
const user: AuthenticatedUser = {
id: 1,
openId: "sample-user",
username: "sample-user",
passwordHash: null,
email: "sample@example.com",
name: "Sample User",
loginMethod: "manus",
role: "user",
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
lastLoginAt: new Date(),
};
const ctx: TrpcContext = {
@@ -54,7 +57,7 @@ describe("auth.logout", () => {
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
sameSite: "lax",
httpOnly: true,
path: "/",
});

View File

@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
hashPassword,
isLoginRateLimited,
recordLoginFailure,
resetLoginRateLimitForTests,
verifyPassword,
} from "./_core/auth";
describe("本地密码认证", () => {
beforeEach(() => resetLoginRateLimitForTests());
it("使用带随机盐的 scrypt 哈希校验密码", async () => {
const first = await hashPassword("correct-password");
const second = await hashPassword("correct-password");
expect(first).toMatch(/^scrypt\$/);
expect(first).not.toBe(second);
await expect(verifyPassword("correct-password", first)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", first)).resolves.toBe(false);
});
it("拒绝少于 8 位的密码", async () => {
await expect(hashPassword("short")).rejects.toThrow("至少需要 8 位");
});
it("分别按 IP 和用户名限制连续失败", () => {
for (let i = 0; i < 5; i += 1) recordLoginFailure("10.0.0.1", "alice");
expect(isLoginRateLimited("10.0.0.1", "another-user")).toBe(true);
expect(isLoginRateLimited("another-ip", "ALICE")).toBe(true);
expect(isLoginRateLimited("another-ip", "bob")).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./db", () => ({
countActiveSuperAdmins: vi.fn(),
getUserByUsername: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
}));
import * as db from "./db";
import { ensureBootstrapAdmin } from "./_core/auth";
import { ENV } from "./_core/env";
describe("引导超级管理员", () => {
beforeEach(() => {
vi.clearAllMocks();
ENV.bootstrapAdminUsername = "bootstrap-test";
ENV.bootstrapAdminPassword = "bootstrap-password";
});
it("不存在有效超级管理员时只创建一次", async () => {
vi.mocked(db.countActiveSuperAdmins).mockResolvedValueOnce(0).mockResolvedValueOnce(1);
vi.mocked(db.getUserByUsername).mockResolvedValue(undefined);
vi.mocked(db.createUser).mockResolvedValue({} as never);
await ensureBootstrapAdmin();
await ensureBootstrapAdmin();
expect(db.createUser).toHaveBeenCalledTimes(1);
expect(db.createUser).toHaveBeenCalledWith(expect.objectContaining({ role: "super_admin", isActive: 1, mustChangePassword: 1 }));
});
it("无有效超级管理员且缺少变量时启动失败", async () => {
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(0);
ENV.bootstrapAdminUsername = "";
ENV.bootstrapAdminPassword = "";
await expect(ensureBootstrapAdmin()).rejects.toThrow("BOOTSTRAP_ADMIN_USERNAME");
});
});

View File

@@ -1,18 +1,20 @@
import { eq, desc, inArray, sql, and } from "drizzle-orm";
import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import type { ResultSetHeader } from "mysql2";
import { inspectPointcloudMetadata } from "./pointcloudMetadata.js";
import {
users, InsertUser,
vehicleSeries, InsertVehicleSeries, VehicleSeries,
vehicleModels, InsertVehicleModel, VehicleModel,
inspectionObjects, InsertInspectionObject, InspectionObject,
modelCadFiles, InsertModelCadFile, ModelCadFile,
modelCadInspectionObjects,
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
taskCadFiles, InsertTaskCadFile, TaskCadFile,
inspectionTasks, InsertInspectionTask, InspectionTask,
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
@@ -80,60 +82,67 @@ export async function getDb() {
// ─── 用户 ─────────────────────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) throw new Error("User openId is required for upsert");
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
}
try {
const values: InsertUser = { openId: user.openId };
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) values.lastSignedIn = new Date();
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
}
export async function getUserByOpenId(openId: string) {
export async function getUserById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
return result[0];
}
export async function getUserByUsername(username: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.username, username)).limit(1);
return result[0];
}
export async function listUsers(search?: string) {
const db = await getDb();
if (!db) return [];
const term = search?.trim();
if (!term) return db.select().from(users).orderBy(desc(users.createdAt));
const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
return db.select().from(users)
.where(or(like(users.username, pattern), like(users.name, pattern), like(users.email, pattern)))
.orderBy(desc(users.createdAt));
}
export async function createUser(data: InsertUser) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(users).values(data);
return getUserById(getMysqlInsertId(result));
}
export async function updateUser(id: number, data: Partial<InsertUser>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users).set(data).where(eq(users.id, id));
return getUserById(id);
}
export async function incrementUserSessionVersion(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users)
.set({ sessionVersion: sql`${users.sessionVersion} + 1` })
.where(eq(users.id, id));
}
export async function deleteUser(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(users).where(eq(users.id, id));
}
export async function countActiveSuperAdmins() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users)
.where(and(eq(users.role, "super_admin"), eq(users.isActive, 1)));
return Number(row?.count ?? 0);
}
// ─── 车系 ─────────────────────────────────────────────────────────────────────
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
@@ -199,6 +208,192 @@ export async function deleteVehicleModel(id: number): Promise<void> {
await db.delete(vehicleModels).where(eq(vehicleModels.id, id));
}
// ─── 检测对象与默认输入方案 ──────────────────────────────────────────────────
export type InspectionObjectWithConfiguration = InspectionObject & {
defaultPointcloudName: string | null;
defaultCadName: string | null;
configurationState: "missing_pointcloud" | "missing_cad" | "ready_draft" | "active";
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
canDelete: boolean;
};
export type InspectionObjectReferenceCounts = {
taskReferenceCount: number;
pointcloudReferenceCount: number;
cadReferenceCount: number;
};
export class InspectionObjectReferencedError extends Error {
constructor(public readonly references: InspectionObjectReferenceCounts) {
const parts = [
references.taskReferenceCount > 0 ? `任务 ${references.taskReferenceCount} 个` : null,
references.pointcloudReferenceCount > 0 ? `点云 ${references.pointcloudReferenceCount} 个` : null,
references.cadReferenceCount > 0 ? `CAD ${references.cadReferenceCount} 个` : null,
].filter(Boolean);
super(`检测对象仍被以下数据引用:${parts.join("、")}。请先处理关联数据后再删除。`);
this.name = "InspectionObjectReferencedError";
}
}
export function assertInspectionObjectDeletable(references: InspectionObjectReferenceCounts): void {
if (references.taskReferenceCount + references.pointcloudReferenceCount + references.cadReferenceCount > 0) {
throw new InspectionObjectReferencedError(references);
}
}
export async function listInspectionObjects(modelId: number, activeOnly = false): Promise<InspectionObjectWithConfiguration[]> {
const db = await getDb();
if (!db) return [];
const conditions = [eq(inspectionObjects.modelId, modelId)];
if (activeOnly) conditions.push(eq(inspectionObjects.status, "active"));
const rows = await db.select().from(inspectionObjects).where(and(...conditions)).orderBy(inspectionObjects.createdAt);
if (rows.length === 0) return [];
const objectIds = rows.map(row => row.id);
const pointclouds = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.modelId, modelId));
const cadFiles = await db.select().from(modelCadFiles).where(eq(modelCadFiles.modelId, modelId));
const taskReferences = await db.select({
inspectionObjectId: inspectionTasks.inspectionObjectId,
count: sql<number>`count(*)`,
}).from(inspectionTasks).where(inArray(inspectionTasks.inspectionObjectId, objectIds)).groupBy(inspectionTasks.inspectionObjectId);
const cadReferences = await db.select({
inspectionObjectId: modelCadInspectionObjects.inspectionObjectId,
count: sql<number>`count(*)`,
}).from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.inspectionObjectId, objectIds)).groupBy(modelCadInspectionObjects.inspectionObjectId);
const pcById = new Map(pointclouds.map(row => [row.id, row]));
const cadById = new Map(cadFiles.map(row => [row.id, row]));
const taskCountByObjectId = new Map(taskReferences.map(row => [row.inspectionObjectId, Number(row.count)]));
const cadCountByObjectId = new Map(cadReferences.map(row => [row.inspectionObjectId, Number(row.count)]));
const pointcloudCountByObjectId = new Map<number, number>();
for (const pointcloud of pointclouds) {
if (pointcloud.inspectionObjectId != null) {
pointcloudCountByObjectId.set(pointcloud.inspectionObjectId, (pointcloudCountByObjectId.get(pointcloud.inspectionObjectId) ?? 0) + 1);
}
}
return rows.map(row => {
const pc = row.defaultModelPointcloudId ? pcById.get(row.defaultModelPointcloudId) : undefined;
const cad = row.defaultModelCadFileId ? cadById.get(row.defaultModelCadFileId) : undefined;
const hasPc = pc?.inspectionObjectId === row.id && pc.status === "available";
const hasCad = cad?.status === "available";
const taskReferenceCount = taskCountByObjectId.get(row.id) ?? 0;
const pointcloudReferenceCount = pointcloudCountByObjectId.get(row.id) ?? 0;
const cadReferenceCount = cadCountByObjectId.get(row.id) ?? 0;
return {
...row,
defaultPointcloudName: pc?.name ?? null,
defaultCadName: cad?.name ?? null,
configurationState: !hasPc ? "missing_pointcloud" : !hasCad ? "missing_cad" : row.status === "active" ? "active" : "ready_draft",
taskReferenceCount,
pointcloudReferenceCount,
cadReferenceCount,
canDelete: taskReferenceCount + pointcloudReferenceCount + cadReferenceCount === 0,
};
});
}
export async function getInspectionObjectById(id: number): Promise<InspectionObject | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(inspectionObjects).where(eq(inspectionObjects.id, id));
return row;
}
export async function createInspectionObject(data: InsertInspectionObject): Promise<InspectionObject> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(inspectionObjects).values(data);
return (await getInspectionObjectById(getMysqlInsertId(result)))!;
}
export async function updateInspectionObject(id: number, data: Partial<InsertInspectionObject>): Promise<InspectionObject> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(inspectionObjects).set(data).where(eq(inspectionObjects.id, id));
return (await getInspectionObjectById(id))!;
}
export async function deleteInspectionObject(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, id)).limit(1).for("update");
if (!object) throw new Error("检测对象不存在");
const taskRows = await tx.select({ id: inspectionTasks.id }).from(inspectionTasks)
.where(eq(inspectionTasks.inspectionObjectId, id)).for("update");
const pointcloudRows = await tx.select({ id: modelPointcloudFiles.id }).from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.inspectionObjectId, id)).for("update");
const cadRows = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects)
.where(eq(modelCadInspectionObjects.inspectionObjectId, id)).for("update");
assertInspectionObjectDeletable({
taskReferenceCount: taskRows.length,
pointcloudReferenceCount: pointcloudRows.length,
cadReferenceCount: cadRows.length,
});
await tx.delete(inspectionObjects).where(eq(inspectionObjects.id, id));
});
}
export async function cadAppliesToInspectionObject(cadFileId: number, inspectionObjectId: number): Promise<boolean> {
const db = await getDb();
if (!db) return false;
const [row] = await db.select().from(modelCadInspectionObjects).where(and(
eq(modelCadInspectionObjects.cadFileId, cadFileId),
eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId),
)).limit(1);
return Boolean(row);
}
export async function setCadInspectionObjects(cadFileId: number, inspectionObjectIds: number[]): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const lockedObjects = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(inArray(inspectionObjects.id, inspectionObjectIds)).for("share");
if (lockedObjects.length !== new Set(inspectionObjectIds).size) throw new Error("Inspection object not found");
await tx.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, cadFileId));
if (inspectionObjectIds.length > 0) {
await tx.insert(modelCadInspectionObjects).values(inspectionObjectIds.map(inspectionObjectId => ({ cadFileId, inspectionObjectId })));
}
});
}
export async function listCadInspectionObjectIds(cadFileIds: number[]): Promise<Record<number, number[]>> {
const db = await getDb();
if (!db || cadFileIds.length === 0) return {};
const rows = await db.select().from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.cadFileId, cadFileIds));
return rows.reduce<Record<number, number[]>>((result, row) => {
(result[row.cadFileId] ??= []).push(row.inspectionObjectId);
return result;
}, {});
}
export async function configureInspectionObject(input: {
id: number;
defaultModelPointcloudId: number;
defaultModelCadFileId: number;
active: boolean;
}): Promise<InspectionObject> {
const object = await getInspectionObjectById(input.id);
const pc = await getModelPointcloudFileById(input.defaultModelPointcloudId);
const cad = await getModelCadFileById(input.defaultModelCadFileId);
if (!object || !pc || !cad) throw new Error("Configuration resource not found");
if (pc.modelId !== object.modelId || pc.inspectionObjectId !== object.id || pc.status !== "available") {
throw new Error("Pointcloud is incompatible with inspection object");
}
if (cad.modelId !== object.modelId || cad.status !== "available" || !await cadAppliesToInspectionObject(cad.id, object.id)) {
throw new Error("CAD is incompatible with inspection object");
}
return updateInspectionObject(object.id, {
defaultModelPointcloudId: pc.id,
defaultModelCadFileId: cad.id,
status: input.active ? "active" : "draft",
});
}
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
export async function createModelCadFile(data: InsertModelCadFile): Promise<ModelCadFile> {
@@ -217,6 +412,18 @@ export async function listModelCadFiles(modelId: number): Promise<ModelCadFile[]
.orderBy(desc(modelCadFiles.createdAt));
}
export async function listModelCadFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise<ModelCadFile[]> {
const db = await getDb();
if (!db) return [];
return db.select({
id: modelCadFiles.id, modelId: modelCadFiles.modelId, name: modelCadFiles.name,
format: modelCadFiles.format, fileSize: modelCadFiles.fileSize, filePath: modelCadFiles.filePath,
contentScope: modelCadFiles.contentScope, status: modelCadFiles.status, createdAt: modelCadFiles.createdAt,
}).from(modelCadFiles).innerJoin(modelCadInspectionObjects, eq(modelCadInspectionObjects.cadFileId, modelCadFiles.id))
.where(and(eq(modelCadFiles.modelId, modelId), eq(modelCadFiles.status, "available"), eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId)))
.orderBy(desc(modelCadFiles.createdAt));
}
export async function getModelCadFileById(id: number): Promise<ModelCadFile | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -227,6 +434,16 @@ export async function getModelCadFileById(id: number): Promise<ModelCadFile | un
export async function deleteModelCadFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.defaultModelCadFileId, id)).limit(1);
if (defaultUse) throw new Error("Resource is used as default input");
const [historicalUse] = await db.select({ id: taskCadFiles.id }).from(taskCadFiles)
.where(eq(taskCadFiles.sourceModelCadFileId, id)).limit(1);
if (historicalUse) {
await db.update(modelCadFiles).set({ status: "disabled" }).where(eq(modelCadFiles.id, id));
return;
}
await db.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, id));
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
}
@@ -235,17 +452,72 @@ export async function deleteModelCadFile(id: number): Promise<void> {
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(modelPointcloudFiles).values(data);
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
return row;
return db.transaction(async tx => {
if (data.inspectionObjectId != null) {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share");
if (!object) throw new Error("Inspection object not found");
}
const result = await tx.insert(modelPointcloudFiles).values(data);
const [row] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
return row;
});
}
const modelMetadataBackfills = new Map<number, Promise<ModelPointcloudFile>>();
async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise<ModelPointcloudFile> {
if (row.metadataStatus !== "pending") return row;
const existing = modelMetadataBackfills.get(row.id);
if (existing) return existing;
const backfill = (async () => {
const db = await getDb();
if (!db) return row;
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
await db.update(modelPointcloudFiles)
.set(metadata)
.where(and(
eq(modelPointcloudFiles.id, row.id),
eq(modelPointcloudFiles.metadataStatus, "pending"),
));
return { ...row, ...metadata };
})();
modelMetadataBackfills.set(row.id, backfill);
try {
return await backfill;
} finally {
modelMetadataBackfills.delete(row.id);
}
}
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(modelPointcloudFiles)
const rows = await db.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.modelId, modelId))
.orderBy(desc(modelPointcloudFiles.createdAt));
return Promise.all(rows.map(backfillModelPointcloudMetadata));
}
export async function listModelPointcloudFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise<ModelPointcloudFile[]> {
const rows = await listModelPointcloudFiles(modelId);
return rows.filter(row => row.inspectionObjectId === inspectionObjectId && row.status === "available");
}
export async function assignModelPointcloudInspectionObject(id: number, inspectionObjectId: number): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async tx => {
const [object] = await tx.select().from(inspectionObjects)
.where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share");
const [file] = await tx.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.id, id)).limit(1).for("update");
if (!file || !object || file.modelId !== object.modelId) throw new Error("Inspection object does not belong to file model");
await tx.update(modelPointcloudFiles).set({ inspectionObjectId }).where(eq(modelPointcloudFiles.id, id));
const [updated] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
return updated;
});
}
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
@@ -255,9 +527,48 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
return row;
}
export async function findModelPointcloudFileByModelAndName(
modelId: number,
name: string,
excludeId?: number,
): Promise<ModelPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const conditions = [
eq(modelPointcloudFiles.modelId, modelId),
sql`lower(${modelPointcloudFiles.name}) = lower(${name})`,
];
if (excludeId !== undefined) {
conditions.push(sql`${modelPointcloudFiles.id} <> ${excludeId}`);
}
const [row] = await db.select().from(modelPointcloudFiles).where(and(...conditions)).limit(1);
return row;
}
export async function updateModelPointcloudFileName(
id: number,
name: string,
): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(modelPointcloudFiles).set({ name }).where(eq(modelPointcloudFiles.id, id));
const row = await getModelPointcloudFileById(id);
if (!row) throw new Error("Model pointcloud file disappeared after update");
return row;
}
export async function deleteModelPointcloudFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.defaultModelPointcloudId, id)).limit(1);
if (defaultUse) throw new Error("Resource is used as default input");
const [historicalUse] = await db.select({ id: taskPointcloudFiles.id }).from(taskPointcloudFiles)
.where(eq(taskPointcloudFiles.sourceModelPointcloudId, id)).limit(1);
if (historicalUse) {
await db.update(modelPointcloudFiles).set({ status: "disabled" }).where(eq(modelPointcloudFiles.id, id));
return;
}
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
}
@@ -315,6 +626,9 @@ export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds:
if (source.modelId !== task.modelId) {
throw new Error("Cannot link CAD file from another model");
}
if (!task.inspectionObjectId || source.status !== "available" || !await cadAppliesToInspectionObject(source.id, task.inspectionObjectId)) {
throw new Error("Cannot link CAD file from another inspection object");
}
const record = await createTaskCadFile({
taskId,
@@ -342,6 +656,49 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
return row;
}
export async function createInspectionTaskWithDefaults(data: InsertInspectionTask & { inspectionObjectId: number }): Promise<{
task: InspectionTask;
pointcloudFile: TaskPointcloudFile;
cadFile: TaskCadFile;
}> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return db.transaction(async tx => {
const [object] = await tx.select().from(inspectionObjects)
.where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share");
if (!object || object.modelId !== data.modelId || object.status !== "active") throw new Error("Inspection object is not active for model");
if (!object.defaultModelPointcloudId || !object.defaultModelCadFileId) throw new Error("Default input scheme is incomplete");
const [pointcloud] = await tx.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.id, object.defaultModelPointcloudId)).limit(1);
const [cad] = await tx.select().from(modelCadFiles)
.where(eq(modelCadFiles.id, object.defaultModelCadFileId)).limit(1);
const [cadApplication] = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects).where(and(
eq(modelCadInspectionObjects.cadFileId, object.defaultModelCadFileId),
eq(modelCadInspectionObjects.inspectionObjectId, object.id),
)).limit(1);
if (!pointcloud || pointcloud.inspectionObjectId !== object.id || pointcloud.status !== "available") throw new Error("Default pointcloud is invalid");
if (!cad || cad.modelId !== object.modelId || cad.status !== "available" || !cadApplication) throw new Error("Default CAD is invalid");
const taskInsert = await tx.insert(inspectionTasks).values(data);
const taskId = getMysqlInsertId(taskInsert);
const pointcloudInsert = await tx.insert(taskPointcloudFiles).values({
taskId, name: pointcloud.name, fileSize: pointcloud.fileSize, filePath: pointcloud.filePath,
source: "model_library", sourceModelPointcloudId: pointcloud.id,
metadataStatus: pointcloud.metadataStatus, pointCount: pointcloud.pointCount,
pointAttributes: pointcloud.pointAttributes, pointDataFormat: pointcloud.pointDataFormat,
bytesPerPoint: pointcloud.bytesPerPoint, metadataError: pointcloud.metadataError,
});
const cadInsert = await tx.insert(taskCadFiles).values({
taskId, name: cad.name, format: cad.format, fileSize: cad.fileSize, filePath: cad.filePath,
source: "model_library", sourceModelCadFileId: cad.id,
});
const [task] = await tx.select().from(inspectionTasks).where(eq(inspectionTasks.id, taskId));
const [pointcloudFile] = await tx.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, getMysqlInsertId(pointcloudInsert)));
const [cadFile] = await tx.select().from(taskCadFiles).where(eq(taskCadFiles.id, getMysqlInsertId(cadInsert)));
return { task, pointcloudFile, cadFile };
});
}
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
const db = await getDb();
if (!db) return undefined;
@@ -417,6 +774,17 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
await db.update(inspectionTasks).set(data).where(eq(inspectionTasks.id, id));
}
export async function classifyInspectionTask(id: number, inspectionObjectId: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async tx => {
const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects)
.where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share");
if (!object) throw new Error("Inspection object not found");
await tx.update(inspectionTasks).set({ inspectionObjectId }).where(eq(inspectionTasks.id, id));
});
}
export async function deleteInspectionTask(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
@@ -448,6 +816,7 @@ export async function createRegisteredPointcloudFile(
): Promise<TaskPointcloudFile> {
const fs = await import('node:fs');
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
const metadata = inspectPointcloudMetadata(filePath, name);
return createTaskPointcloudFile({
taskId,
name,
@@ -456,15 +825,81 @@ export async function createRegisteredPointcloudFile(
source: 'registered',
sourceResultId,
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
...metadata,
} as InsertTaskPointcloudFile);
}
const taskMetadataBackfills = new Map<number, Promise<TaskPointcloudFile>>();
async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise<TaskPointcloudFile> {
if (row.metadataStatus !== "pending") return row;
const existing = taskMetadataBackfills.get(row.id);
if (existing) return existing;
const backfill = (async () => {
const db = await getDb();
if (!db) return row;
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
await db.update(taskPointcloudFiles)
.set(metadata)
.where(and(
eq(taskPointcloudFiles.id, row.id),
eq(taskPointcloudFiles.metadataStatus, "pending"),
));
return { ...row, ...metadata };
})();
taskMetadataBackfills.set(row.id, backfill);
try {
return await backfill;
} finally {
taskMetadataBackfills.delete(row.id);
}
}
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(taskPointcloudFiles)
const rows = await db.select().from(taskPointcloudFiles)
.where(eq(taskPointcloudFiles.taskId, taskId))
.orderBy(taskPointcloudFiles.createdAt);
return Promise.all(rows.map(backfillTaskPointcloudMetadata));
}
export async function getTaskPointcloudFileById(id: number): Promise<TaskPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
return row;
}
export async function findTaskPointcloudFileByTaskAndName(
taskId: number,
name: string,
excludeId?: number,
): Promise<TaskPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const conditions = [
eq(taskPointcloudFiles.taskId, taskId),
sql`lower(${taskPointcloudFiles.name}) = lower(${name})`,
];
if (excludeId !== undefined) {
conditions.push(sql`${taskPointcloudFiles.id} <> ${excludeId}`);
}
const [row] = await db.select().from(taskPointcloudFiles).where(and(...conditions)).limit(1);
return row;
}
export async function updateTaskPointcloudFileName(
id: number,
name: string,
): Promise<TaskPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(taskPointcloudFiles).set({ name }).where(eq(taskPointcloudFiles.id, id));
const row = await getTaskPointcloudFileById(id);
if (!row) throw new Error("Task pointcloud file disappeared after update");
return row;
}
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
@@ -499,6 +934,9 @@ export async function linkTaskPointcloudFilesFromModel(
if (source.modelId !== task.modelId) {
throw new Error("Cannot link pointcloud file from another model");
}
if (!task.inspectionObjectId || source.inspectionObjectId !== task.inspectionObjectId || source.status !== "available") {
throw new Error("Cannot link pointcloud file from another inspection object");
}
const record = await createTaskPointcloudFile({
taskId,
@@ -507,6 +945,12 @@ export async function linkTaskPointcloudFilesFromModel(
filePath: source.filePath,
source: "model_library",
sourceModelPointcloudId: source.id,
metadataStatus: source.metadataStatus,
pointCount: source.pointCount,
pointAttributes: source.pointAttributes,
pointDataFormat: source.pointDataFormat,
bytesPerPoint: source.bytesPerPoint,
metadataError: source.metadataError,
} as InsertTaskPointcloudFile);
inserted.push(record);
existingSourceIds.add(id);
@@ -580,3 +1024,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
if (!db) return;
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
}
/**
* 删除分析结果及其派生的已配准点云记录。
* 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。
*/
export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async (tx) => {
await tx.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.sourceResultId, id));
await tx.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
});
}

84
server/demoFlow.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from './demoFlow';
import { listPlugins } from './plugins/registry';
import type { PluginManifest } from './plugins/types';
function manifest(id: string, tasks: PluginManifest['demoTasks']): PluginManifest {
return {
id,
name: id,
description: id,
version: '1',
schemaVersion: 1,
demoTasks: tasks,
demoSelection: { kind: 'task-nos', param: 'demoTaskNos' },
runtime: { kind: 'mock', fixture: 'unused.json' },
};
}
const manifests = [
manifest('distance', [
{ taskNo: 2, name: '车体长度', category: '尺寸', inspectionObjects: ['head', 'middle'] },
]),
manifest('contour', [
{ taskNo: 11, name: '车钩面板的平整度', category: '轮廓', inspectionObjects: ['head', 'middle'] },
]),
manifest('coupler', [
{ taskNo: 9, name: '参考面右偏转', category: '车钩', inspectionObjects: ['middle'] },
{ taskNo: 10, name: '纵向中心右偏转', category: '车钩', inspectionObjects: ['head'] },
]),
];
describe('demo task catalog', () => {
it('按数值排序并标记不适用于当前检测对象的任务', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
expect(catalog.items.map((item) => item.taskNo)).toEqual([2, 9, 10, 11]);
expect(catalog.total).toBe(3);
expect(catalog.items.find((item) => item.taskNo === 9)).toMatchObject({ enabled: false });
});
it('跨算法选择转换为最小作业集合并去重', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
const plan = resolveSelectedDemoJobs(manifests, catalog, [11, 2, 10, 2]);
expect(plan.taskNos).toEqual([2, 10, 11]);
expect(plan.jobs.map((job) => job.pluginId)).toEqual(['distance', 'coupler', 'contour']);
expect(plan.jobs).toHaveLength(3);
});
it('拒绝当前检测对象不可运行的任务', () => {
const catalog = buildDemoTaskCatalog(manifests, 'head', '头车');
expect(() => resolveSelectedDemoJobs(manifests, catalog, [9])).toThrow('不适用于当前头车');
});
});
describe('actual plugin demo task catalog', () => {
const actualManifests = listPlugins();
it('返回真实非连续任务号、名称与检测对象动态数量', () => {
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
const middle = buildDemoTaskCatalog(actualManifests, 'middle', '中车');
expect(head.total).toBe(39);
expect(middle.total).toBe(26);
expect(head.items.find((item) => item.taskNo === 11)?.name).toBe('车钩面板的平整度');
expect(head.items.some((item) => item.taskNo === 3)).toBe(false);
expect(head.items.map((item) => item.taskNo)).toEqual(
[...head.items.map((item) => item.taskNo)].sort((a, b) => a - b),
);
});
it('任务 2、11、10 生成三个插件作业,单个车钩任务只生成一次共享作业', () => {
const head = buildDemoTaskCatalog(actualManifests, 'head', '头车');
const mixed = resolveSelectedDemoJobs(actualManifests, head, [2, 11, 10]);
expect(mixed.jobs.map((job) => job.pluginId)).toEqual([
'train-measurement',
'coupler-panel',
'flatness-suite',
]);
const couplerOnly = resolveSelectedDemoJobs(actualManifests, head, [8]);
expect(couplerOnly.jobs).toEqual([{
pluginId: 'coupler-panel',
taskNos: [8],
params: { demoTaskNos: [8] },
}]);
});
});

120
server/demoFlow.ts Normal file
View File

@@ -0,0 +1,120 @@
import type {
DemoInspectionObject,
PluginDemoTask,
PluginManifest,
} from './plugins/types.js';
export interface DemoTaskCatalogItem extends PluginDemoTask {
pluginId: string;
pluginName: string;
enabled: boolean;
disabledReason?: string;
}
export interface DemoTaskCatalog {
inspectionObject: DemoInspectionObject | 'unknown';
inspectionObjectName?: string;
total: number;
items: DemoTaskCatalogItem[];
}
export interface DemoSelectedPluginJob {
pluginId: string;
taskNos: number[];
params: Record<string, unknown>;
}
function disabledReasonFor(
task: PluginDemoTask,
inspectionObject: DemoInspectionObject | 'unknown',
inspectionObjectName?: string,
): string | undefined {
if (!task.inspectionObjects?.length) return undefined;
if (inspectionObject === 'unknown') {
return inspectionObjectName
? `检测对象“${inspectionObjectName}”尚未配置任务适用规则`
: '当前任务尚未确认头车或中车检测对象';
}
if (!task.inspectionObjects.includes(inspectionObject)) {
return `不适用于当前${inspectionObject === 'head' ? '头车' : '中车'}检测对象`;
}
return undefined;
}
/** 从插件 manifest 构建服务端权威任务目录。 */
export function buildDemoTaskCatalog(
manifests: readonly PluginManifest[],
inspectionObject: DemoInspectionObject | 'unknown',
inspectionObjectName?: string,
): DemoTaskCatalog {
const items: DemoTaskCatalogItem[] = [];
const claimedTaskNos = new Map<number, string>();
for (const manifest of manifests) {
for (const task of manifest.demoTasks ?? []) {
const previousPlugin = claimedTaskNos.get(task.taskNo);
if (previousPlugin) {
throw new Error(`演示任务 ${task.taskNo} 被插件 ${previousPlugin} 与 ${manifest.id} 重复声明`);
}
claimedTaskNos.set(task.taskNo, manifest.id);
const disabledReason = disabledReasonFor(task, inspectionObject, inspectionObjectName);
items.push({
...task,
pluginId: manifest.id,
pluginName: manifest.name,
enabled: disabledReason == null,
...(disabledReason ? { disabledReason } : {}),
});
}
}
items.sort((a, b) => a.taskNo - b.taskNo);
return {
inspectionObject,
...(inspectionObjectName ? { inspectionObjectName } : {}),
total: items.filter((item) => item.enabled).length,
items,
};
}
/** 校验所选任务并转换为最小插件作业集合。 */
export function resolveSelectedDemoJobs(
manifests: readonly PluginManifest[],
catalog: DemoTaskCatalog,
requestedTaskNos: readonly number[],
): { taskNos: number[]; jobs: DemoSelectedPluginJob[] } {
const taskNos = Array.from(new Set(requestedTaskNos)).sort((a, b) => a - b);
if (taskNos.length === 0) throw new Error('请至少选择一个任务');
const itemByTaskNo = new Map(catalog.items.map((item) => [item.taskNo, item]));
for (const taskNo of taskNos) {
const item = itemByTaskNo.get(taskNo);
if (!item) throw new Error(`任务 ${taskNo} 不存在或当前算法未提供`);
if (!item.enabled) throw new Error(`任务 ${taskNo} 当前不可执行:${item.disabledReason ?? '运行条件不满足'}`);
}
const taskNosByPlugin = new Map<string, number[]>();
for (const taskNo of taskNos) {
const pluginId = itemByTaskNo.get(taskNo)!.pluginId;
const pluginTaskNos = taskNosByPlugin.get(pluginId) ?? [];
pluginTaskNos.push(taskNo);
taskNosByPlugin.set(pluginId, pluginTaskNos);
}
const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest]));
const jobs: DemoSelectedPluginJob[] = [];
for (const [pluginId, pluginTaskNos] of Array.from(taskNosByPlugin.entries())) {
const manifest = manifestById.get(pluginId);
if (!manifest) throw new Error(`未注册的算法插件:${pluginId}`);
if (manifest.demoSelection?.kind !== 'task-nos' || !manifest.demoSelection.param) {
throw new Error(`算法插件 ${pluginId} 未配置任务选择执行方式`);
}
jobs.push({
pluginId,
taskNos: pluginTaskNos,
params: { [manifest.demoSelection.param]: pluginTaskNos },
});
}
return { taskNos, jobs };
}

View File

@@ -1,10 +1,17 @@
import { Router } from 'express';
import { Router, type RequestHandler } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import multer from 'multer';
import * as db from './db';
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
import { samplePointcloudFiles } from './pointcloudSample.js';
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
import { authenticateRequest } from './_core/auth.js';
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from './measurementCompatibility.js';
const router = Router();
@@ -48,6 +55,33 @@ const diskUpload = multer({
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
});
const requireFileRead: RequestHandler = async (req, res, next) => {
try {
const user = await authenticateRequest(req);
if (!user) return res.status(401).json({ error: '请先登录' });
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
res.locals.user = user;
next();
} catch (error) {
next(error);
}
};
const requireFileWrite: RequestHandler = async (req, res, next) => {
try {
const user = await authenticateRequest(req);
if (!user) return res.status(401).json({ error: '请先登录' });
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
if (user.role !== 'admin' && user.role !== 'super_admin') {
return res.status(403).json({ error: '无权执行此操作' });
}
res.locals.user = user;
next();
} catch (error) {
next(error);
}
};
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
function formatFileSize(bytes: number): string {
@@ -59,17 +93,35 @@ function formatFileSize(bytes: number): string {
function safeResolve(base: string, filename: string): string | null {
const resolved = path.resolve(base, decodeURIComponent(filename));
return resolved.startsWith(path.resolve(base)) ? resolved : null;
const relative = path.relative(path.resolve(base), resolved);
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
? resolved
: null;
}
function sendSampledPointcloudBatch(res: Parameters<RequestHandler>[1], batch: Awaited<ReturnType<typeof samplePointcloudFiles>>) {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', batch.pointCount);
res.setHeader('X-Has-Color', batch.hasColor ? '1' : '0');
res.setHeader('X-Has-Error', batch.hasError ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(batch.fileStats));
res.send(batch.buffer);
}
// ─── 车型 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/cad/:modelId
// multipart/form-data field: file
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
const inspectionObjectIds = String(req.body.inspectionObjectIds ?? '').split(',').map(Number).filter(Number.isFinite);
if (!req.file || isNaN(modelId) || inspectionObjectIds.length === 0) {
return res.status(400).json({ success: false, error: '上传车型 CAD 时必须选择适用检测对象' });
}
const objects = await Promise.all(inspectionObjectIds.map(id => db.getInspectionObjectById(id)));
if (objects.some(object => !object || object.modelId !== modelId)) {
await fs.promises.rm(req.file.path, { force: true });
return res.status(400).json({ success: false, error: '适用检测对象必须属于当前车型' });
}
const destPath = req.file.path;
@@ -81,6 +133,7 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
fileSize: req.file.size,
filePath: destPath,
});
await db.setCadInspectionObjects(record.id, inspectionObjectIds);
res.json({
success: true,
@@ -98,18 +151,27 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
// POST /api/files/upload/model-pointcloud/:modelId
// multipart/form-data field: file
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
const inspectionObjectId = Number(req.body.inspectionObjectId);
if (!req.file || isNaN(modelId) || !Number.isFinite(inspectionObjectId)) {
return res.status(400).json({ success: false, error: '上传车型点云时必须选择检测对象' });
}
const object = await db.getInspectionObjectById(inspectionObjectId);
if (!object || object.modelId !== modelId) {
await fs.promises.rm(req.file.path, { force: true });
return res.status(400).json({ success: false, error: '检测对象必须属于当前车型' });
}
const destPath = req.file.path;
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
const record = await db.createModelPointcloudFile({
modelId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
inspectionObjectId,
...metadata,
});
res.json({
@@ -126,7 +188,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/task-cad/:taskId
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/task-cad/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
@@ -157,7 +219,7 @@ router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, r
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
// GET /api/files/read/task-cad/:taskId/:filename
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
router.get('/read/task-cad/:taskId/:filename', requireFileRead, (req, res) => {
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -168,18 +230,20 @@ router.get('/read/task-cad/:taskId/:filename', (req, res) => {
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
// POST /api/files/upload/pointcloud/:taskId
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/pointcloud/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
}
const destPath = req.file.path;
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
const record = await db.createTaskPointcloudFile({
taskId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
...metadata,
});
res.json({
@@ -196,7 +260,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/cad/:modelId/:filename
router.get('/read/cad/:modelId/:filename', (req, res) => {
router.get('/read/cad/:modelId/:filename', requireFileRead, (req, res) => {
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -207,7 +271,7 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
// GET /api/files/read/model-pointcloud/:modelId/:filename
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => {
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -218,7 +282,7 @@ router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/pointcloud/:taskId/:filename
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (req, res) => {
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -229,7 +293,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/cad/:id
router.delete('/delete/cad/:id', async (req, res) => {
router.delete('/delete/cad/:id', requireFileWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
@@ -245,7 +309,7 @@ router.delete('/delete/cad/:id', async (req, res) => {
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/pointcloud/:id
router.delete('/delete/pointcloud/:id', async (req, res) => {
router.delete('/delete/pointcloud/:id', requireFileWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
await db.deleteTaskPointcloudFile(id);
@@ -254,13 +318,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
// POST /api/files/sample/batch
// Body: { taskId: number, totalPoints?: number }
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
router.post('/sample/batch', async (req, res) => {
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
router.post('/sample/batch', requireFileRead, async (req, res) => {
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
taskId?: number;
filePaths?: string[];
totalPoints?: number;
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
fileIds?: number[];
@@ -268,7 +330,7 @@ router.post('/sample/batch', async (req, res) => {
let paths: string[] = [];
if (taskId != null) {
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
let records = await db.listTaskPointcloudFiles(taskId);
// 当前端传入 fileIds 时只采样选中的文件
if (Array.isArray(fileIds) && fileIds.length > 0) {
@@ -276,10 +338,8 @@ router.post('/sample/batch', async (req, res) => {
records = records.filter((r) => idSet.has(r.id));
}
paths = records.map((r) => r.filePath);
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
paths = rawPaths;
} else {
return res.status(400).json({ error: 'Provide taskId or filePaths' });
return res.status(400).json({ error: '请提供有效的 taskId' });
}
if (paths.length === 0) {
@@ -290,7 +350,6 @@ router.post('/sample/batch', async (req, res) => {
return res.send(empty);
}
const budget = Math.min(totalPoints, 2000000);
const supportedExts = ['.ply', '.txt', '.xyz', '.pts'];
const fileInfos = paths.map((fp) => ({
@@ -305,68 +364,104 @@ router.post('/sample/batch', async (req, res) => {
console.log(
`[FileRoutes] Batch sample: ${fileInfos.length} files ` +
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}`
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${totalPoints}`
);
try {
const quotaMap = new Map<string, number>();
for (const f of supportedFiles) {
const quota = totalBytes > 0
? Math.max(100, Math.round((f.size / totalBytes) * budget))
: Math.floor(budget / supportedFiles.length);
quotaMap.set(f.filePath, quota);
}
const sampledPerFile = await Promise.all(
fileInfos.map((f) => {
if (!supportedExts.includes(f.ext) || f.size === 0) return Promise.resolve([] as SampledPoint[]);
return samplePointcloudFile(f.filePath, f.size, quotaMap.get(f.filePath) ?? 0);
})
);
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
let offset = 0;
for (const pts of sampledPerFile) {
for (const p of pts) {
binBuf.writeFloatLE(p.x, offset);
binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12);
if (hasColor) {
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24);
}
offset += bytesPerPoint;
}
}
const fileStats = fileInfos.map((f, i) => ({
name: f.name,
sizeGB: +(f.size / 1e9).toFixed(3),
sampled: sampledPerFile[i].length,
}));
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf);
const batch = await samplePointcloudFiles(supportedFiles.map((file) => file.filePath), totalPoints);
console.log(`[FileRoutes] Batch done: ${batch.pointCount} pts, ${(batch.buffer.length / 1e6).toFixed(1)} MB`);
sendSampledPointcloudBatch(res, batch);
} catch (err) {
console.error('[FileRoutes] Batch sampling error:', err);
res.status(500).json({ error: 'Failed to sample files' });
}
});
// ─── 分析结果 PLY 批量采样接口 ───────────────────────────────────────────────
// POST /api/files/sample/analysis
// Body: { taskId: number, resultId: number, files: string[], totalPoints?: number }
router.post('/sample/analysis', requireFileRead, async (req, res) => {
const { taskId, resultId, files, totalPoints = 300_000 } = req.body as {
taskId?: number;
resultId?: number;
files?: unknown;
totalPoints?: number;
};
if (!Number.isSafeInteger(taskId) || Number(taskId) <= 0 ||
!Number.isSafeInteger(resultId) || Number(resultId) <= 0 ||
!Number.isSafeInteger(totalPoints) || totalPoints <= 0) {
return res.status(400).json({ error: '分析结果采样参数无效' });
}
if (!Array.isArray(files) || files.length === 0 ||
files.some((file) => typeof file !== 'string' || file.length === 0)) {
return res.status(400).json({ error: '请提供 PLY 文件列表' });
}
const validTaskId = taskId as number;
const validResultId = resultId as number;
const validTotalPoints = totalPoints as number;
const row = await db.getTaskAnalysisResultById(validResultId);
if (!row || row.taskId !== validTaskId) {
return res.status(404).json({ error: '分析结果不存在或不属于当前任务' });
}
const baseDir = path.join(TASKS_DIR, String(validTaskId), 'analysis', String(validResultId));
let realBaseDir: string;
try {
realBaseDir = fs.realpathSync(baseDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return res.status(404).json({ error: '分析结果目录不存在' });
}
throw error;
}
const resolvedFiles: string[] = [];
for (const file of files as string[]) {
if (path.isAbsolute(file) || path.extname(file).toLowerCase() !== '.ply') {
return res.status(400).json({ error: '只允许读取分析结果目录中的相对 PLY 路径' });
}
let filePath: string | null;
try {
filePath = safeResolve(baseDir, file);
} catch {
return res.status(400).json({ error: 'PLY 文件路径编码无效' });
}
if (!filePath) return res.status(403).json({ error: 'PLY 文件路径越界' });
let stat: fs.Stats;
try {
stat = fs.lstatSync(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return res.status(404).json({ error: `PLY 文件不存在: ${file}` });
}
throw error;
}
if (!stat.isFile() || stat.isSymbolicLink()) {
return res.status(400).json({ error: `PLY 路径不是普通文件: ${file}` });
}
const realFilePath = fs.realpathSync(filePath);
const realRelative = path.relative(realBaseDir, realFilePath);
if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
return res.status(403).json({ error: 'PLY 文件路径越界' });
}
resolvedFiles.push(realFilePath);
}
try {
const batch = await samplePointcloudFiles(resolvedFiles, validTotalPoints, 300_000);
sendSampledPointcloudBatch(res, batch);
} catch (error) {
console.error('[FileRoutes] Analysis PLY sampling error:', error);
return res.status(500).json({ error: '分析结果 PLY 采样失败' });
}
});
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
@@ -402,7 +497,7 @@ router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = req.params.filename;
@@ -432,7 +527,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
router.get('/read/analysis/:taskId/:resultId/*', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
@@ -455,7 +550,7 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
// POST /api/files/resync/analysis/:taskId/:resultId
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
@@ -472,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
try {
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
const task = await db.getInspectionTaskById(taskId);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const normalized = normalizeMeasurements({
pluginId: row.pluginId ?? (typeof existingRd.pluginId === 'string' ? existingRd.pluginId : undefined),
measurements: result.measurements ?? existingRd.measurements,
extra: result.extra ?? existingRd.extra,
context: inspectionObjectContextFromName(inspectionObject?.name),
algorithmProtocolVersion: result.schemaVersion ?? row.schemaVersion,
});
logCompatibilityWarnings(resultId, normalized.warnings);
const mergedRd = {
...existingRd,
message: result.message ?? existingRd.message,
measurements: result.measurements ?? existingRd.measurements,
measurements: normalized.measurements,
errorStats: result.errorStats ?? existingRd.errorStats,
overlay3d: result.overlay3d ?? existingRd.overlay3d,
images: result.images ?? existingRd.images,
files: result.files ?? existingRd.files,
extra: result.extra ?? existingRd.extra,
extra: normalized.extra,
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
};
await db.updateTaskAnalysisResult(resultId, { resultData: mergedRd });
await db.updateTaskAnalysisResult(resultId, {
measurements: normalized.measurements,
resultData: mergedRd,
});
res.json({ success: true, overlay3d: result.overlay3d });
} catch (e) {
res.status(500).json({ error: String(e) });
@@ -494,7 +604,7 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
// POST /api/files/plugins/reload
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
// 用于更新算法版本后立即生效。
router.post('/plugins/reload', (_req, res) => {
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
try {
const plugins = reloadPluginRegistry();
res.json({

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import {
executePlans,
mergeMeasurements,
type BackfillPlan,
} from "../scripts/backfill-flatness-suite-measurements";
function plan(id: number): BackfillPlan {
return {
row: { id, taskId: 24, measurements: [], resultData: { kind: "plugin", marker: "kept" } } as BackfillPlan["row"],
resultJsonPath: `/tmp/${id}/result.json`,
originalResultJson: { status: "completed", extra: { module: "contour_batch" } },
measurements: [{
name: "任务11 · 车钩面板的平整度",
value: null,
unit: "mm",
toleranceLabel: "0.5mm",
status: "fail",
passLabel: "成功",
warningLabel: "部分成功",
failLabel: "失败",
}],
};
}
describe("flatness-suite 历史回填", () => {
it("dry-run 不调用持久化", async () => {
const persist = vi.fn(async () => true);
const summary = await executePlans([plan(234)], false, persist);
expect(persist).not.toHaveBeenCalled();
expect(summary).toEqual({ planned: 1, applied: 0, skipped: 1, failed: 0 });
});
it("apply 由持久化层守卫实现幂等", async () => {
const applied = new Set<number>();
const persist = vi.fn(async (item: BackfillPlan) => {
if (applied.has(item.row.id)) return false;
applied.add(item.row.id);
return true;
});
expect((await executePlans([plan(234)], true, persist)).applied).toBe(1);
const second = await executePlans([plan(234)], true, persist);
expect(second.applied).toBe(0);
expect(second.skipped).toBe(1);
});
it("异常记录不阻断其他记录", async () => {
const persist = vi.fn(async (item: BackfillPlan) => {
if (item.row.id === 1) throw new Error("bad excel");
return true;
});
const summary = await executePlans([plan(1), plan(2)], true, persist);
expect(summary).toEqual({ planned: 2, applied: 1, skipped: 0, failed: 1 });
});
it("扩展字段完整进入 resultData 且保留已有字段", () => {
const item = plan(234);
const merged = mergeMeasurements(item.row.resultData, item.measurements);
expect(merged.marker).toBe("kept");
expect(merged.measurements).toEqual(item.measurements);
});
});

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appRouter } from "./routers";
import * as db from "./db";
import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => {
class InspectionObjectReferencedError extends Error {
constructor(public readonly references: { taskReferenceCount: number; pointcloudReferenceCount: number; cadReferenceCount: number }) {
const parts = [
references.taskReferenceCount > 0 ? `任务 ${references.taskReferenceCount} 个` : null,
references.pointcloudReferenceCount > 0 ? `点云 ${references.pointcloudReferenceCount} 个` : null,
references.cadReferenceCount > 0 ? `CAD ${references.cadReferenceCount} 个` : null,
].filter(Boolean);
super(`检测对象仍被以下数据引用:${parts.join("、")}。请先处理关联数据后再删除。`);
}
}
return {
deleteInspectionObject: vi.fn(async () => undefined),
InspectionObjectReferencedError,
};
});
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createContext(role: AuthenticatedUser["role"]): TrpcContext {
return {
user: {
id: 1, username: "test-user", passwordHash: null, email: "test@example.com", name: "Test User",
role, isActive: 1, mustChangePassword: 0, sessionVersion: 0,
createdAt: new Date(), updatedAt: new Date(), lastLoginAt: new Date(),
},
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
};
}
describe("inspection object delete router", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.deleteInspectionObject).mockResolvedValue(undefined);
});
it("allows an administrator to delete an empty object", async () => {
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.resolves.toEqual({ success: true });
expect(db.deleteInspectionObject).toHaveBeenCalledWith(7);
});
it.each([
[{ taskReferenceCount: 2, pointcloudReferenceCount: 0, cadReferenceCount: 0 }, "任务 2 个"],
[{ taskReferenceCount: 0, pointcloudReferenceCount: 3, cadReferenceCount: 0 }, "点云 3 个"],
[{ taskReferenceCount: 0, pointcloudReferenceCount: 0, cadReferenceCount: 4 }, "CAD 4 个"],
])("rejects a single reference type with its count", async (references, message) => {
vi.mocked(db.deleteInspectionObject).mockRejectedValueOnce(new db.InspectionObjectReferencedError(references));
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: expect.stringContaining(message) });
});
it("reports all reference types when a concurrent reference is observed by the transaction", async () => {
vi.mocked(db.deleteInspectionObject).mockRejectedValueOnce(new db.InspectionObjectReferencedError({
taskReferenceCount: 1, pointcloudReferenceCount: 2, cadReferenceCount: 3,
}));
await expect(appRouter.createCaller(createContext("admin")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: "检测对象仍被以下数据引用:任务 1 个、点云 2 个、CAD 3 个。请先处理关联数据后再删除。",
});
});
it("forbids a non-administrator from deleting", async () => {
await expect(appRouter.createCaller(createContext("user")).inspectionObject.delete({ id: 7 }))
.rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.deleteInspectionObject).not.toHaveBeenCalled();
});
});

View File

@@ -5,16 +5,10 @@ import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => ({
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
createInspectionTask: vi.fn(async (data) => ({
id: 1,
modelId: data.modelId,
name: data.name,
inspector: data.inspector ?? null,
notes: data.notes ?? null,
status: data.status,
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
createInspectionTaskWithDefaults: vi.fn(async (data) => ({
task: { id: 1, modelId: data.modelId, inspectionObjectId: data.inspectionObjectId, name: data.name, inspector: data.inspector ?? null, notes: data.notes ?? null, status: data.status, createdAt: new Date(), updatedAt: new Date(), completedAt: null },
pointcloudFile: { id: 11, taskId: 1 },
cadFile: { id: 12, taskId: 1 },
})),
}));
@@ -23,14 +17,17 @@ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user",
username: "test-user",
passwordHash: null,
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role: "user",
role: "admin",
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
lastLoginAt: new Date(),
};
return {
@@ -57,13 +54,15 @@ describe("inspection task router", () => {
const result = await caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: " 首检任务 ",
});
expect(result.status).toBe("created");
expect(result.task.status).toBe("created");
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
expect(db.createInspectionTask).toHaveBeenCalledWith({
expect(db.createInspectionTaskWithDefaults).toHaveBeenCalledWith({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
inspector: null,
notes: null,
@@ -75,6 +74,7 @@ describe("inspection task router", () => {
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
id: 1,
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
inspector: null,
notes: null,
@@ -89,17 +89,18 @@ describe("inspection task router", () => {
await expect(caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",
message: "该车型下已存在同名检测任务。",
});
expect(db.createInspectionTask).not.toHaveBeenCalled();
expect(db.createInspectionTaskWithDefaults).not.toHaveBeenCalled();
});
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
vi.mocked(db.createInspectionTask).mockRejectedValueOnce({
vi.mocked(db.createInspectionTaskWithDefaults).mockRejectedValueOnce({
code: "ER_DUP_ENTRY",
errno: 1062,
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
@@ -110,6 +111,7 @@ describe("inspection task router", () => {
await expect(caller.inspectionTask.create({
modelId: 1,
inspectionObjectId: 9,
name: "首检任务",
})).rejects.toMatchObject({
code: "CONFLICT",

View File

@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
inspectionObjectContextFromName,
normalizeMeasurements,
sortNormalizedMeasurements,
} from './measurementCompatibility.js';
const valueFields = {
value: 1.25,
unit: 'mm',
tolerance: 0.8,
toleranceLabel: '+0.8/-0.2',
baseline: 375,
status: 'pass' as const,
passLabel: '通过',
};
afterEach(() => {
delete process.env.MEASUREMENT_COMPAT_ENABLED;
});
describe('normalizeMeasurements', () => {
it.each([
['任务46_车体横断面对角线差值', '46', '车体横断面对角线差值'],
['任务11 · 车钩面板的平整度', '11', '车钩面板的平整度'],
])('拆分通用旧任务名 %s', (originalName, taskNo, name) => {
const output = normalizeMeasurements({
pluginId: 'train-measurement',
measurements: [{ name: originalName, ...valueFields }],
});
expect(output.measurements[0]).toMatchObject({
taskNo,
metricCode: `legacy.train_measurement.task_${taskNo}`,
name,
legacyOriginalName: originalName,
...valueFields,
});
});
it('新协议结果原样保留且重复调用一致', () => {
const measurement = { taskNo: '8', metricCode: 'coupler.reference-offset', name: '参考面右偏转', ...valueFields, customConclusion: '合格' };
const first = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [measurement], extra: { marker: true } });
const second = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: first.measurements, extra: first.extra });
expect(first.measurements).toEqual([measurement]);
expect(second).toEqual(first);
});
it('任务 5 保留左右梁两行和位置顺序', () => {
const output = normalizeMeasurements({
pluginId: 'underframe-deflection',
measurements: [
{ name: '右梁 · 纵向梁到直线的垂直偏差(挠度)', value: 2 },
{ name: '左梁 · 纵向梁到直线的垂直偏差(挠度)', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: '5', name: '挠度', position: '左梁', value: 1 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 2 },
]);
});
it.each([
['头车', '8', '10'],
['中车', '9', '12'],
['中间车', '9', '12'],
])('%s 车钩精确映射为正确任务号', (objectName, referenceTaskNo, longitudinalTaskNo) => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName(objectName),
measurements: [
{ name: '车钩面板距纵向中心的右偏转角', value: 2 },
{ name: '车钩面板距参考面的右偏转角', value: 1 },
],
});
expect(output.measurements).toMatchObject([
{ taskNo: referenceTaskNo, name: '参考面右偏转', value: 1 },
{ taskNo: longitudinalTaskNo, name: '纵向中心右偏转', value: 2 },
]);
});
it('未知检测对象不默认成头车', () => {
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('拖车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }],
});
expect(output.measurements[0]).toMatchObject({ legacyUnmapped: true });
expect(output.measurements[0].taskNo).toBeUndefined();
expect(output.warnings).toHaveLength(1);
});
it('车钩中间量移入 extra.intermediateMetrics 并保留字段', () => {
const intermediate = ['最大 x 偏差 dx_y', '最大 x 偏差 dx_z', '面板 y 向最大偏差 D_y', '面板 z 向最大偏差 D_z']
.map((name, index) => ({ name, value: index, unit: 'mm', status: 'info' }));
const output = normalizeMeasurements({
pluginId: 'coupler-panel',
context: inspectionObjectContextFromName('头车'),
measurements: [{ name: '车钩面板距参考面的右偏转角', value: 1 }, ...intermediate],
extra: { marker: 'kept' },
});
expect(output.measurements).toHaveLength(1);
expect(output.extra).toEqual({ marker: 'kept', intermediateMetrics: intermediate });
});
it('未知名称不模糊猜测并生成稳定编码', () => {
const source = { name: '类似车钩面板距参考面的右偏转角但不是', ...valueFields, customConclusion: '人工通过' };
const output = normalizeMeasurements({ pluginId: 'coupler-panel', measurements: [source] });
expect(output.measurements[0]).toMatchObject({
...source,
legacyUnmapped: true,
legacyOriginalName: source.name,
});
expect(output.measurements[0].metricCode).toMatch(/^legacy\.coupler_panel\.unmapped\.[0-9a-f]{8}$/);
});
it('按数字任务号排序且未映射项最后', () => {
const sorted = sortNormalizedMeasurements([
{ taskNo: '46', name: '46' },
{ name: 'unknown' },
{ taskNo: '5', position: '右梁', name: '5R' },
{ taskNo: '11', name: '11' },
{ taskNo: '5', position: '左梁', name: '5L' },
]);
expect(sorted.map((item) => item.name)).toEqual(['5L', '5R', '11', '46', 'unknown']);
});
it('环境开关关闭时原样返回', () => {
process.env.MEASUREMENT_COMPAT_ENABLED = '0';
const measurements = [{ name: '任务46_原始', value: 1 }];
const output = normalizeMeasurements({ pluginId: 'train-measurement', measurements, extra: { raw: true } });
expect(output).toEqual({ measurements, extra: { raw: true }, warnings: [], converted: false });
});
});

View File

@@ -0,0 +1,261 @@
import type { PluginMeasurement } from './plugins/types.js';
export type InspectionObjectKind = 'head' | 'middle' | 'unknown';
export interface InspectionObjectContext {
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface CompatibilityWarning {
code: 'legacy-unmapped';
pluginId: string;
originalName: string;
inspectionObject: InspectionObjectKind;
inspectionObjectName?: string;
}
export interface NormalizeMeasurementsInput {
pluginId?: string | null;
measurements?: unknown;
extra?: unknown;
context?: InspectionObjectContext;
algorithmProtocolVersion?: string | number | null;
}
export interface NormalizeMeasurementsOutput {
measurements: PluginMeasurement[];
extra: Record<string, unknown>;
warnings: CompatibilityWarning[];
converted: boolean;
}
const COUPLER_INTERMEDIATE_NAMES = new Set([
'最大 x 偏差 dx_y',
'最大 x 偏差 dx_z',
'面板 y 向最大偏差 D_y',
'面板 z 向最大偏差 D_z',
]);
const DEFLECTION_NAMES = new Map<string, string>([
['左梁 · 纵向梁到直线的垂直偏差(挠度)', '左梁'],
['右梁 · 纵向梁到直线的垂直偏差(挠度)', '右梁'],
['挠度左梁', '左梁'],
['挠度右梁', '右梁'],
]);
const COUPLER_NAMES = {
longitudinal: new Set([
'车钩面板距纵向中心的右偏转角',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
'车钩面板距纵向中心的右偏转角(头车一位端处车钩)',
]),
reference: new Set([
'车钩面板距参考面的右偏转角',
'车钩面板距参考面的右偏转角(头车一位端处车钩)',
]),
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function pluginKey(pluginId: string): string {
return (pluginId || 'unknown').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'unknown';
}
function stableNameHash(value: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
function legacyMetricCode(pluginId: string, taskNo: string): string {
return `legacy.${pluginKey(pluginId)}.task_${taskNo}`;
}
function unmappedMetricCode(pluginId: string, name: string): string {
return `legacy.${pluginKey(pluginId)}.unmapped.${stableNameHash(name)}`;
}
export function inspectionObjectContextFromName(name?: string | null): InspectionObjectContext {
const normalized = name?.trim() ?? '';
return {
inspectionObject: normalized === '头车'
? 'head'
: normalized === '中车' || normalized === '中间车'
? 'middle'
: 'unknown',
inspectionObjectName: normalized || undefined,
};
}
export function measurementCompatibilityEnabled(): boolean {
return process.env.MEASUREMENT_COMPAT_ENABLED !== '0';
}
function positionOrder(position: unknown): number {
if (position === '左梁') return 0;
if (position === '右梁') return 1;
return 2;
}
export function sortNormalizedMeasurements<T extends { taskNo?: unknown; position?: unknown }>(items: readonly T[]): T[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aNo = typeof a.item.taskNo === 'string' && /^\d+$/.test(a.item.taskNo) ? Number(a.item.taskNo) : Number.POSITIVE_INFINITY;
const bNo = typeof b.item.taskNo === 'string' && /^\d+$/.test(b.item.taskNo) ? Number(b.item.taskNo) : Number.POSITIVE_INFINITY;
if (aNo !== bNo) return aNo - bNo;
const positionDelta = positionOrder(a.item.position) - positionOrder(b.item.position);
return positionDelta || a.index - b.index;
})
.map(({ item }) => item);
}
export function normalizeMeasurements(input: NormalizeMeasurementsInput): NormalizeMeasurementsOutput {
const pluginId = input.pluginId ?? '';
const context = input.context ?? { inspectionObject: 'unknown' as const };
const sourceMeasurements = Array.isArray(input.measurements)
? input.measurements.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
: [];
const sourceExtra = asRecord(input.extra);
if (!measurementCompatibilityEnabled()) {
return {
measurements: sourceMeasurements as unknown as PluginMeasurement[],
extra: sourceExtra,
warnings: [],
converted: false,
};
}
const measurements: PluginMeasurement[] = [];
const intermediateMetrics = Array.isArray(sourceExtra.intermediateMetrics)
? [...sourceExtra.intermediateMetrics]
: [];
const warnings: CompatibilityWarning[] = [];
let converted = false;
sourceMeasurements.forEach((source) => {
const item = { ...source };
const originalName = typeof item.name === 'string' ? item.name : '';
// 新协议项必须同时具备 taskNo 和 metricCode;重复规范化时保持原样。
if (typeof item.taskNo === 'string' && item.taskNo && typeof item.metricCode === 'string' && item.metricCode) {
measurements.push(item as unknown as PluginMeasurement);
return;
}
if (pluginId === 'coupler-panel' && COUPLER_INTERMEDIATE_NAMES.has(originalName)) {
intermediateMetrics.push(item);
converted = true;
return;
}
const generic = /^任务(\d+)(?:_|\s*·\s*)(.+)$/.exec(originalName);
if (generic) {
const [, taskNo, name] = generic;
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: name.trim(),
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
if (pluginId === 'underframe-deflection') {
const position = DEFLECTION_NAMES.get(originalName);
if (position) {
measurements.push({
...item,
taskNo: '5',
metricCode: legacyMetricCode(pluginId, '5'),
name: '挠度',
position,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
if (pluginId === 'coupler-panel') {
const isLongitudinal = COUPLER_NAMES.longitudinal.has(originalName);
const isReference = COUPLER_NAMES.reference.has(originalName);
if (isLongitudinal || isReference) {
const taskNo = context.inspectionObject === 'head'
? (isLongitudinal ? '10' : '8')
: context.inspectionObject === 'middle'
? (isLongitudinal ? '12' : '9')
: undefined;
if (taskNo) {
measurements.push({
...item,
taskNo,
metricCode: legacyMetricCode(pluginId, taskNo),
name: isLongitudinal ? '纵向中心右偏转' : '参考面右偏转',
legacyOriginalName: originalName,
} as unknown as PluginMeasurement);
converted = true;
return;
}
}
}
const unmapped = {
...item,
metricCode: unmappedMetricCode(pluginId, originalName),
legacyUnmapped: true,
legacyOriginalName: originalName,
} as unknown as PluginMeasurement;
measurements.push(unmapped);
warnings.push({
code: 'legacy-unmapped',
pluginId,
originalName,
inspectionObject: context.inspectionObject,
inspectionObjectName: context.inspectionObjectName,
});
converted = true;
});
return {
measurements: sortNormalizedMeasurements(measurements),
extra: intermediateMetrics.length > 0 ? { ...sourceExtra, intermediateMetrics } : sourceExtra,
warnings,
converted,
};
}
export function logCompatibilityWarnings(resultId: number, warnings: readonly CompatibilityWarning[]): void {
warnings.forEach((warning) => {
const signature = [
resultId,
warning.pluginId,
warning.originalName,
warning.inspectionObject,
warning.inspectionObjectName ?? '',
].join('\u0000');
if (loggedWarningSignatures.has(signature)) return;
loggedWarningSignatures.add(signature);
console.warn('[MeasurementCompatibility] legacy measurement unmapped', {
resultId,
pluginId: warning.pluginId,
originalName: warning.originalName,
inspectionObject: warning.inspectionObject,
inspectionObjectName: warning.inspectionObjectName,
});
});
}
/** 避免历史结果轮询每次重复打印同一条告警;进程重启后会重新记录一次。 */
const loggedWarningSignatures = new Set<string>();

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
import { protectedProcedure, router, superAdminProcedure } from "./_core/trpc";
const testRouter = router({
read: protectedProcedure.query(() => "read-ok"),
write: protectedProcedure.mutation(() => "write-ok"),
manageUsers: superAdminProcedure.query(() => "users-ok"),
});
function user(role: User["role"], mustChangePassword = 0): User {
const now = new Date();
return {
id: 1,
username: `${role}-account`,
passwordHash: "unused",
name: role,
email: null,
role,
isActive: 1,
mustChangePassword,
sessionVersion: 0,
createdAt: now,
updatedAt: now,
lastLoginAt: null,
};
}
function context(currentUser: User | null): TrpcContext {
return { user: currentUser, req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
}
describe("三角色权限矩阵", () => {
it("未登录不能读取", async () => {
await expect(testRouter.createCaller(context(null)).read()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("首次改密前不能读取业务数据", async () => {
await expect(testRouter.createCaller(context(user("super_admin", 1))).read()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("普通用户可读但不可写,也不可管理用户", async () => {
const caller = testRouter.createCaller(context(user("user")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("管理员可读写但不可管理用户", async () => {
const caller = testRouter.createCaller(context(user("admin")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).resolves.toBe("write-ok");
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("超级管理员拥有全部权限", async () => {
const caller = testRouter.createCaller(context(user("super_admin")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).resolves.toBe("write-ok");
await expect(caller.manageUsers()).resolves.toBe("users-ok");
});
});

View File

@@ -155,23 +155,53 @@ def main() -> int:
offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right))
offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref))
measurements = [
{
inspection_object = str((payload.get("context") or {}).get("inspectionObject") or "unknown")
longitudinal_task_no = "10" if inspection_object == "head" else "12" if inspection_object == "middle" else None
reference_task_no = "8" if inspection_object == "head" else "9" if inspection_object == "middle" else None
raw_selected_task_nos = config.get("demoTaskNos")
selected_task_nos = (
{str(int(value)) for value in raw_selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()}
if isinstance(raw_selected_task_nos, list)
else None
)
def _selected(task_no: str | None) -> bool:
return selected_task_nos is None or (task_no is not None and task_no in selected_task_nos)
measurements = []
if _selected(longitudinal_task_no):
longitudinal_measurement = {
"name": "车钩面板距纵向中心的右偏转角",
"value": round(offset_right, 6),
"unit": "mm",
"baseline": 700.0,
"tolerance": 1.0,
"status": _status_for(offset_right, 1.0),
},
{
}
if longitudinal_task_no:
longitudinal_measurement.update({
"taskNo": longitudinal_task_no,
"metricCode": "coupler.longitudinal_right_offset",
"name": "纵向中心右偏转",
})
measurements.append(longitudinal_measurement)
if _selected(reference_task_no):
reference_measurement = {
"name": "车钩面板距参考面的右偏转角",
"value": round(offset_ref, 6),
"unit": "mm",
"baseline": 375.0,
"tolerance": 0.8,
"status": _status_for(offset_ref, 0.8),
},
}
if reference_task_no:
reference_measurement.update({
"taskNo": reference_task_no,
"metricCode": "coupler.reference_right_offset",
"name": "参考面右偏转",
})
measurements.append(reference_measurement)
measurements.extend([
{
"name": "最大 x 偏差 dx_y",
"value": dx_y,
@@ -198,7 +228,7 @@ def main() -> int:
"unit": "mm",
"status": "info",
},
]
])
payload_out = {
"schemaVersion": getattr(result, "schema_version", "1.1"),

View File

@@ -0,0 +1,246 @@
"""flatness-suite 批量测量结果的模板关联与旧报表解析。"""
from __future__ import annotations
import argparse
import json
import math
import posixpath
from pathlib import Path
from typing import Any
from zipfile import BadZipFile, ZipFile
from xml.etree import ElementTree as ET
_SHEET_NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
def _column_index(reference: str) -> int:
letters = "".join(character for character in reference if character.isalpha())
if not letters:
raise ValueError(f"无效的 Excel 单元格引用: {reference}")
result = 0
for character in letters.upper():
result = result * 26 + ord(character) - ord("A") + 1
return result
def _cell_value(cell: ET.Element, shared_strings: list[str]) -> Any:
cell_type = cell.attrib.get("t")
if cell_type == "inlineStr":
return "".join(text.text or "" for text in cell.findall(".//a:t", _SHEET_NS))
value_node = cell.find("a:v", _SHEET_NS)
if value_node is None or value_node.text is None:
return ""
raw = value_node.text
if cell_type == "s":
try:
return shared_strings[int(raw)]
except (IndexError, ValueError) as exc:
raise ValueError("Excel 共享字符串索引无效") from exc
if cell_type in {"str", "e"}:
return raw
if cell_type == "b":
return raw == "1"
try:
number = float(raw)
return int(number) if number.is_integer() else number
except ValueError:
return raw
def read_xlsx_rows(path: str | Path) -> tuple[str, list[list[Any]]]:
"""只用标准库读取 xlsx 的第一个工作表。"""
workbook_path = Path(path)
if not workbook_path.is_file():
raise FileNotFoundError(f"Excel 文件不存在: {workbook_path}")
try:
with ZipFile(workbook_path) as xlsx:
names = set(xlsx.namelist())
shared_strings: list[str] = []
if "xl/sharedStrings.xml" in names:
shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml"))
shared_strings = [
"".join(text.text or "" for text in item.findall(".//a:t", _SHEET_NS))
for item in shared_root.findall("a:si", _SHEET_NS)
]
workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml"))
first_sheet = workbook_root.find("a:sheets/a:sheet", _SHEET_NS)
if first_sheet is None:
raise ValueError(f"Excel 中没有工作表: {workbook_path}")
sheet_name = first_sheet.attrib.get("name", "Sheet1")
relation_id = first_sheet.attrib.get(f"{{{_REL_NS}}}id")
if not relation_id:
raise ValueError(f"Excel 工作表关系缺失: {workbook_path}")
rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels"))
relation_targets = {
relation.attrib["Id"]: relation.attrib["Target"] for relation in rels_root
}
target = relation_targets.get(relation_id)
if not target:
raise ValueError(f"Excel 工作表文件关系缺失: {workbook_path}")
sheet_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join("xl", target))
if sheet_path not in names:
raise ValueError(f"Excel 工作表文件不存在: {sheet_path}")
sheet_root = ET.fromstring(xlsx.read(sheet_path))
sparse_rows: list[dict[int, Any]] = []
max_column = 0
for row in sheet_root.findall("a:sheetData/a:row", _SHEET_NS):
values: dict[int, Any] = {}
for cell in row.findall("a:c", _SHEET_NS):
column = _column_index(cell.attrib.get("r", ""))
values[column] = _cell_value(cell, shared_strings)
max_column = max(max_column, column)
if values:
sparse_rows.append(values)
except (BadZipFile, KeyError, ET.ParseError) as exc:
raise ValueError(f"无法解析 Excel 文件 {workbook_path}: {exc}") from exc
rows = [
[row.get(column, "") for column in range(1, max_column + 1)]
for row in sparse_rows
]
if not rows:
raise ValueError(f"Excel 工作表为空: {workbook_path}")
return sheet_name, rows
def _header_index(headers: list[Any], candidates: tuple[str, ...]) -> int:
normalized = [str(header).strip() for header in headers]
for candidate in candidates:
if candidate in normalized:
return normalized.index(candidate)
raise ValueError(f"Excel 缺少列: {'/'.join(candidates)}")
def normalize_task_id(value: Any) -> str:
if isinstance(value, float) and value.is_integer():
return str(int(value))
text = str(value).strip()
return text[:-2] if text.endswith(".0") and text[:-2].isdigit() else text
def _label(value: Any) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
def read_measurement_template(path: str | Path) -> dict[str, dict[str, str]]:
"""读取任务号对应的项点名称与原始公差文本。"""
_, rows = read_xlsx_rows(path)
headers = rows[0]
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
name_column = _header_index(headers, ("项点名称", "项目名称", "项目"))
tolerance_column = _header_index(headers, ("公差要求", "公差"))
result: dict[str, dict[str, str]] = {}
for row in rows[1:]:
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
if not task_id:
continue
item_name = _label(row[name_column] if name_column < len(row) else "")
if not item_name:
raise ValueError(f"模板任务 {task_id} 缺少项点名称")
result[task_id] = {
"item_name": item_name,
"tolerance_label": _label(row[tolerance_column] if tolerance_column < len(row) else ""),
}
if not result:
raise ValueError(f"模板中没有测量任务: {path}")
return result
def read_legacy_batch_rows(path: str | Path) -> list[dict[str, Any]]:
"""读取旧版技术汇总表中的任务号、平均值和状态。"""
_, rows = read_xlsx_rows(path)
headers = rows[0]
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
mean_column = _header_index(headers, ("平均值", "mean", "mean_dist", "average_distance"))
status_column = _header_index(headers, ("状态", "测量结论", "结论"))
result: list[dict[str, Any]] = []
for row in rows[1:]:
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
if not task_id:
continue
result.append({
"task_id": task_id,
"mean": row[mean_column] if mean_column < len(row) else None,
"status": row[status_column] if status_column < len(row) else "",
})
if not result:
raise ValueError(f"旧版 Excel 中没有批量结果: {path}")
return result
def _finite_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def build_measurements(
rows: list[dict[str, Any]],
template_path: str | Path,
) -> list[dict[str, Any]]:
template = read_measurement_template(template_path)
status_fields = {
"成功": ("pass", "成功"),
"部分成功": ("warning", "部分成功"),
"失败": ("fail", "失败"),
}
measurements: list[dict[str, Any]] = []
for row in rows:
task_id = normalize_task_id(row.get("task_id", ""))
if not task_id:
raise ValueError("批量结果包含空任务号")
template_row = template.get(task_id)
if template_row is None:
raise ValueError(f"模板中找不到任务 {task_id}")
raw_status = str(row.get("status", "")).strip()
if raw_status not in status_fields:
raise ValueError(f"任务 {task_id} 的状态无效: {raw_status or '<空>'}")
status, _ = status_fields[raw_status]
measurements.append({
"name": f"任务{task_id} · {template_row['item_name']}",
"value": _finite_float(row.get("mean")),
"unit": "mm",
"toleranceLabel": template_row["tolerance_label"],
"status": status,
"passLabel": "成功",
"warningLabel": "部分成功",
"failLabel": "失败",
})
return measurements
def measurements_from_batch_result(result: dict[str, Any], template_path: str | Path) -> list[dict[str, Any]]:
rows = result.get("rows")
if not isinstance(rows, list):
raise ValueError("run_contour_batch 返回值缺少 rows")
return build_measurements(rows, template_path)
def main() -> int:
parser = argparse.ArgumentParser(description="从旧版 flatness-suite Excel 构造通用 measurements")
parser.add_argument("--excel", required=True)
parser.add_argument("--template", required=True)
args = parser.parse_args()
measurements = build_measurements(read_legacy_batch_rows(args.excel), args.template)
print(json.dumps(measurements, ensure_ascii=False, allow_nan=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -31,6 +31,7 @@ from _plugin_io import (
write_error_result,
write_result,
)
from flatness_suite_measurements import measurements_from_batch_result
def _safe_float(v: object) -> float | None:
@@ -40,6 +41,25 @@ def _safe_float(v: object) -> float | None:
return None
def _config_bool(config: dict, key: str, default: bool = False) -> bool:
value = config.get(key, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _cleanup_temporary_artifacts(out_dir: Path) -> None:
# 仅删除适配器自己创建、且不会写入 result.json 的固定临时文件。
# 不使用通配符,避免误删算法结果或用户要求保留的产物。
for name in ("_roi_cropped.ply",):
try:
(out_dir / name).unlink(missing_ok=True)
except OSError as exc:
print(f"[flatness-suite] 清理临时文件失败 {name}: {exc}", file=sys.stderr)
def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path:
"""根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。
@@ -191,34 +211,45 @@ def main() -> int:
if use_batch_mode and module == "contour":
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
data_root = alg_dir / "datasets"
# 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定
context_pc = (inputs.get("contextPointCloudPath")
or pc_path_raw
or None)
# combined_contour.ply 可能包含完整整车点云,单次可达十几 GB。
# 平台页面只使用各面板 *_contour.ply,因此默认从源头跳过组合文件;
# 仅在用户显式开启时才生成,并作为可下载产物登记。
keep_combined_contour = _config_bool(config, "keepCombinedContour", False)
context_pc = None
if keep_combined_contour:
context_pc = (config.get("contextPointCloudPath")
or inputs.get("contextPointCloudPath")
or pc_path_raw
or None)
save_dir = str(out_dir / "contour_batch")
excel_path = str(out_dir / "contour.xlsx")
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
if not result_template_path.is_file():
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
selected_task_nos = config.get("demoTaskNos")
selected_task_ids = (
[str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()]
if isinstance(selected_task_nos, list)
else None
)
if selected_task_ids == []:
raise RuntimeError("请至少选择一个轮廓测量任务")
res = pipeline.run_contour_batch(
all_tasks=True,
task_ids=selected_task_ids,
all_tasks=selected_task_ids is None,
data_root=str(data_root),
pointcloud_kind="noise",
point_chunk_size=8192,
triangle_chunk_size=512,
save_path=save_dir,
excel_output_path=excel_path,
result_template_path=str(result_template_path),
combined_context_pointcloud_path=context_pc,
save_combined_visualization=keep_combined_contour,
no_visualize=True,
)
emit_progress(0.95, "整理批量结果")
batch_stats = res.get("batch_stats") or {}
measurements: list[dict] = []
for task_id, task_res in (res.get("results") or {}).items():
s = task_res.get("distance_stats") or {}
if s:
measurements.append({
"name": f"任务{task_id} 轮廓偏差均值",
"value": round(float(s.get("mean", 0)), 4),
"unit": "mm", "status": "info",
})
measurements = measurements_from_batch_result(res, result_template_path)
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
panel_plys = sorted([
f"contour_batch/{p.name}"
@@ -229,12 +260,19 @@ def main() -> int:
overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys}
else:
overlay3d = {"kind": "none"}
combined_rel = "contour_batch/combined_contour.ply"
result_files = [combined_rel] if keep_combined_contour and (out_dir / combined_rel).is_file() else []
write_result(out_dir, {
"status": "completed",
"message": f"批量完成 {len(res.get('results', {}))} 个面板测量",
"message": f"批量完成 {len(res.get('rows') or [])} 个面板测量",
"measurements": measurements,
"overlay3d": overlay3d,
"extra": {"module": "contour_batch", "excelPath": "contour.xlsx"},
"files": result_files,
"extra": {
"module": "contour_batch",
"excelPath": "contour.xlsx",
"combinedContourKept": bool(result_files),
},
})
return 0
@@ -430,6 +468,8 @@ def main() -> int:
extra={"traceback": traceback.format_exc()},
)
return 1
finally:
_cleanup_temporary_artifacts(out_dir)
if __name__ == "__main__":

View File

@@ -0,0 +1,24 @@
import importlib.util
import unittest
from pathlib import Path
def _load_adapter(name: str):
path = Path(__file__).with_name(name)
spec = importlib.util.spec_from_file_location(path.stem, path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class DemoTaskSelectionTest(unittest.TestCase):
def test_train_task_number_parsing_and_selection(self):
adapter = _load_adapter("train_measurement_run.py")
self.assertEqual(adapter._task_no_from_name("任务46_车体横断面对角线差值"), "46")
self.assertEqual(adapter._selected_task_nos({"demoTaskNos": [2, 46, 2]}), {"2", "46"})
self.assertIsNone(adapter._selected_task_nos({}))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,96 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from flatness_suite_run import _cleanup_temporary_artifacts, _config_bool
from flatness_suite_measurements import (
measurements_from_batch_result,
read_legacy_batch_rows,
)
TEMPLATE = (
Path(__file__).parents[3]
/ "alg"
/ "平整度、平面度、轮廓测量v1.5"
/ "excel"
/ "测量结果.xlsx"
)
class FlatnessSuiteRetentionConfigTest(TestCase):
def test_combined_contour_defaults_to_disabled(self) -> None:
self.assertFalse(_config_bool({}, "keepCombinedContour", False))
def test_explicit_boolean_or_string_can_enable_retention(self) -> None:
self.assertTrue(_config_bool({"keepCombinedContour": True}, "keepCombinedContour"))
self.assertTrue(_config_bool({"keepCombinedContour": "true"}, "keepCombinedContour"))
self.assertFalse(_config_bool({"keepCombinedContour": "false"}, "keepCombinedContour"))
def test_cleanup_removes_only_known_temporary_artifacts(self) -> None:
with TemporaryDirectory() as temp:
out_dir = Path(temp)
temporary = out_dir / "_roi_cropped.ply"
result = out_dir / "contour.ply"
temporary.touch()
result.touch()
_cleanup_temporary_artifacts(out_dir)
self.assertFalse(temporary.exists())
self.assertTrue(result.exists())
class FlatnessSuiteBatchMeasurementTest(TestCase):
def test_uses_top_level_rows_instead_of_nested_sample_results(self) -> None:
result = {
"rows": [{"task_id": "11", "mean": 1.25, "status": "成功"}],
"results": {
"11": {
"11": {"distance_stats": {"mean": 999}},
},
},
}
measurements = measurements_from_batch_result(result, TEMPLATE)
self.assertEqual(len(measurements), 1)
self.assertEqual(measurements[0]["name"], "任务11 · 车钩面板的平整度")
self.assertEqual(measurements[0]["value"], 1.25)
self.assertEqual(measurements[0]["toleranceLabel"], "0.5mm")
def test_maps_success_partial_success_failure_and_empty_value(self) -> None:
result = {
"rows": [
{"task_id": 11, "mean": 0.1, "status": "成功"},
{"task_id": 13, "mean": 0.2, "status": "部分成功"},
{"task_id": 17, "mean": None, "status": "失败"},
],
"results": {},
}
measurements = measurements_from_batch_result(result, TEMPLATE)
self.assertEqual([item["status"] for item in measurements], ["pass", "warning", "fail"])
self.assertEqual(measurements[0]["passLabel"], "成功")
self.assertEqual(measurements[1]["warningLabel"], "部分成功")
self.assertEqual(measurements[2]["failLabel"], "失败")
self.assertIsNone(measurements[2]["value"])
def test_missing_template_has_explicit_error(self) -> None:
with self.assertRaisesRegex(FileNotFoundError, "Excel 文件不存在"):
measurements_from_batch_result(
{"rows": [{"task_id": 11, "mean": 0.1, "status": "成功"}]},
Path("missing-template.xlsx"),
)
def test_new_template_is_not_misread_as_legacy_summary(self) -> None:
with self.assertRaisesRegex(ValueError, "平均值"):
read_legacy_batch_rows(TEMPLATE)
def test_malformed_excel_has_explicit_error(self) -> None:
with TemporaryDirectory() as temp:
invalid = Path(temp) / "contour.xlsx"
invalid.write_text("not an xlsx", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "无法解析 Excel"):
read_legacy_batch_rows(invalid)

View File

@@ -19,6 +19,7 @@ v2.2 变更:
from __future__ import annotations
import os
import re
import sys
import traceback
from pathlib import Path
@@ -33,6 +34,18 @@ from _plugin_io import (
)
def _task_no_from_name(name: object) -> str | None:
match = re.match(r"^任务\s*(\d+)", str(name or ""))
return match.group(1) if match else None
def _selected_task_nos(config: dict) -> set[str] | None:
raw = config.get("demoTaskNos")
if not isinstance(raw, list):
return None
return {str(int(value)) for value in raw if isinstance(value, (int, float)) and float(value).is_integer()}
def _to_float(value):
if value is None:
return None
@@ -155,6 +168,13 @@ def main() -> int:
if t.get("enabled", True)
}
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
selected_task_nos = _selected_task_nos(config)
if selected_task_nos is not None:
available_task_nos = {_task_no_from_name(task.name) for task in tm.tasks}
missing_task_nos = selected_task_nos - available_task_nos
if missing_task_nos:
raise RuntimeError(f"测距配置中找不到所选任务:{','.join(sorted(missing_task_nos, key=int))}")
tm.tasks = [task for task in tm.tasks if _task_no_from_name(task.name) in selected_task_nos]
# 注入可视化输出目录
viz_dir = out_dir / "visualizations"

View File

@@ -49,6 +49,11 @@ def main() -> int:
try:
emit_progress(0.05, "加载挠度算法包 v1.2.1")
selected_task_nos = config.get("demoTaskNos")
if isinstance(selected_task_nos, list) and 5 not in selected_task_nos:
write_error_result(out_dir, "构架挠度插件仅支持任务 5")
return 1
# ── 输入文件 ────────────────────────────────────────────────────────
pointcloud_path = first_pointcloud(payload)
if not pointcloud_path:

View File

@@ -43,7 +43,7 @@ def main() -> int:
pc_path = first_pointcloud(payload)
cad_path = inputs.get("cadPath")
if not cad_path:
raise RuntimeError("整车配准需要 STEP CAD 文件,请在任务下上传并选中 CAD。")
raise RuntimeError("整车配准需要 CAD 文件(STEP 或 PLY mesh),请在任务下上传并选中 CAD。")
axis = str(config.get("axis") or "x")
crop_ratio = float(config.get("cropRatio") or 0.01)

View File

@@ -8,6 +8,13 @@
"category": "几何测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 8, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
{ "taskNo": 9, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["middle"] },
{ "taskNo": 10, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["head"] },
{ "taskNo": 12, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["middle"] }
],
"inputs": [
{
"name": "profile",

View File

@@ -3,11 +3,34 @@
"name": "平整度 / 平面度 / 轮廓测量",
"shortName": "flatness-suite",
"description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
"version": "1.5.0",
"version": "1.5.1",
"schemaVersion": 1,
"category": "几何测量",
"requiresPointCloud": false,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 11, "name": "车钩面板的平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 13, "name": "车长方向平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 17, "name": "车高方向弧形轮廓测量", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 20, "name": "门柱平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 22, "name": "从门口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 23, "name": "从窗口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 25, "name": "内风挡外圈平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 26, "name": "内风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 27, "name": "外风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 31, "name": "车头前墙区域平面度", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 34, "name": "司机室门外弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 35, "name": "前窗框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 36, "name": "前窗框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 37, "name": "顶灯框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 38, "name": "顶灯框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 39, "name": "逃生窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 40, "name": "逃生窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 41, "name": "曲面窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 42, "name": "曲面窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] },
{ "taskNo": 45, "name": "地板平度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }
],
"inputs": [
{
"name": "useBatchDatasets",
@@ -15,6 +38,13 @@
"type": "boolean",
"default": true
},
{
"name": "keepCombinedContour",
"label": "保留组合轮廓点云(可能占用十几 GB)",
"type": "boolean",
"default": false,
"description": "默认关闭:页面使用各面板热力点云,不需要组合文件。仅在需要离线下载包含整车背景的 combined_contour.ply 时开启。"
},
{
"name": "module",
"label": "测量模块",
@@ -47,10 +77,10 @@
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
{
"name": "contextPointCloudPath",
"label": "整车背景点云路径(可视化合并用)",
"label": "整车背景点云路径(仅保留组合轮廓点云时使用)",
"type": "string",
"default": null,
"description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。"
"description": "仅开启“保留组合轮廓点云”时生效。可填写容器内完整点云路径;留空时使用本次选择的点云作为背景。"
}
],
"runtime": {

View File

@@ -8,6 +8,25 @@
"category": "尺寸测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 1, "name": "转向架距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 2, "name": "车体长度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 7, "name": "边梁下平面到参考线的距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 15, "name": "侧墙最大点到参考平面距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 16, "name": "车体最大宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 18, "name": "门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 21, "name": "侧门高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 28, "name": "端门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 29, "name": "端门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 30, "name": "参考线到司机室辅助孔距离", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 32, "name": "司机室登车门门口宽度", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 33, "name": "司机室登车门门口高度", "category": "尺寸", "inspectionObjects": ["head"] },
{ "taskNo": 43, "name": "窗口下沿高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 44, "name": "车顶高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 46, "name": "车体横断面对角线差值", "category": "尺寸", "inspectionObjects": ["head", "middle"] },
{ "taskNo": 47, "name": "内端墙门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }
],
"uiPanel": "train-measurement",
"inputs": [
{

View File

@@ -8,6 +8,10 @@
"category": "几何测量",
"requiresPointCloud": true,
"requiresCad": false,
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
"demoTasks": [
{ "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] }
],
"inputs": [
{
"name": "leftRoiPath",

View File

@@ -68,6 +68,22 @@ export interface PluginRuntimeMock {
export type PluginRuntime = PluginRuntimeProcess | PluginRuntimeMock;
export type DemoInspectionObject = 'head' | 'middle';
/** 插件可在“全流程任务选择”中暴露的正式检验任务。 */
export interface PluginDemoTask {
taskNo: number;
name: string;
category: string;
inspectionObjects?: DemoInspectionObject[];
}
/** 任务选择如何转换为插件参数。 */
export interface PluginDemoSelection {
kind: 'task-nos';
param: string;
}
/** 插件 manifest(写在 server/plugins/manifests/*.json) */
export interface PluginManifest {
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
@@ -89,6 +105,10 @@ export interface PluginManifest {
requiresCad?: boolean;
/** 是否需要点云(默认 true) */
requiresPointCloud?: boolean;
/** “选择任务运行”模式的权威任务目录。 */
demoTasks?: PluginDemoTask[];
/** 选中任务传给适配器的方式。 */
demoSelection?: PluginDemoSelection;
runtime: PluginRuntime;
}
@@ -105,12 +125,30 @@ export interface PluginProgressEvent {
/** 测量项(GenericResultView 渲染) */
export interface PluginMeasurement {
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
taskNo?: string;
/** 跨版本稳定的测量项编码。 */
metricCode?: string;
name: string;
value: number;
/** 同任务内的位置,例如左梁、右梁。 */
position?: string;
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
value: number | null;
unit?: string;
tolerance?: number;
/** 无法安全数值化的原始公差表达式。 */
toleranceLabel?: string;
/** 斜率类测量的基准长度(mm)。 */
baseline?: number;
/** pass / warning / fail / info */
status?: 'pass' | 'warning' | 'fail' | 'info';
passLabel?: string;
warningLabel?: string;
failLabel?: string;
/** 兼容层无法确定任务号时置为 true。 */
legacyUnmapped?: boolean;
/** 兼容转换前的原始项目名,用于追溯。 */
legacyOriginalName?: string;
}
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */

View File

@@ -0,0 +1,125 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
inspectPointcloudMetadata,
MAX_PLY_HEADER_BYTES,
parsePlyHeader,
PointcloudMetadataParseError,
} from "./pointcloudMetadata";
const temporaryDirectories: string[] = [];
afterEach(() => {
while (temporaryDirectories.length > 0) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
}
});
function header(lines: string[], newline = "\n") {
return Buffer.from([...lines, "end_header", ""].join(newline), "ascii");
}
describe("parsePlyHeader", () => {
it("解析 ASCII PLY,并以 element vertex 作为精确点数", () => {
const result = parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1000000000",
"property float x",
"property float y",
"property double z",
"element face 999",
"property list uchar int vertex_indices",
]));
expect(result).toEqual({
pointCount: 1_000_000_000,
pointDataFormat: "ascii",
bytesPerPoint: 16,
pointAttributes: [
{ name: "x", type: "float32", bytes: 4 },
{ name: "y", type: "float32", bytes: 4 },
{ name: "z", type: "float64", bytes: 8 },
],
});
});
it.each([
["binary_little_endian", "binary_little_endian"],
["binary_big_endian", "binary_big_endian"],
] as const)("解析 %s PLY", (format, expected) => {
const result = parsePlyHeader(header([
"ply",
`format ${format} 1.0`,
"element vertex 42",
"property int16 x",
"property ushort intensity",
"property uchar red",
]));
expect(result.pointDataFormat).toBe(expected);
expect(result.bytesPerPoint).toBe(5);
});
it("支持 CRLF 文件头和类型别名", () => {
const result = parsePlyHeader(header([
"ply",
"format ascii 1.0",
"comment generated",
"element vertex 3",
"property char classification",
"property uint confidence",
"property float32 temperature",
], "\r\n"));
expect(result.pointAttributes).toEqual([
{ name: "classification", type: "int8", bytes: 1 },
{ name: "confidence", type: "uint32", bytes: 4 },
{ name: "temperature", type: "float32", bytes: 4 },
]);
});
it("拒绝无效文件头", () => {
expect(() => parsePlyHeader(Buffer.from("not-ply\n"))).toThrow(PointcloudMetadataParseError);
});
it("拒绝未知的顶点属性类型", () => {
expect(() => parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1",
"property half x",
]))).toThrow("未知的顶点属性类型");
});
it("拒绝列表型顶点属性,但忽略其他 element 的列表属性", () => {
expect(() => parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1",
"property list uchar float samples",
]))).toThrow("列表型顶点属性");
});
it("非 PLY 格式直接标记为 unsupported,不读取文件", () => {
expect(inspectPointcloudMetadata("/path/does/not/exist.txt", "cloud.txt")).toEqual({
metadataStatus: "unsupported",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: null,
});
});
it("只读取 16KB 文件头,超长文件头标记为解析失败", () => {
const directory = mkdtempSync(join(tmpdir(), "pointcloud-metadata-"));
temporaryDirectories.push(directory);
const filePath = join(directory, "oversized.ply");
writeFileSync(filePath, `ply\nformat ascii 1.0\ncomment ${"x".repeat(MAX_PLY_HEADER_BYTES)}\nend_header\n`);
const result = inspectPointcloudMetadata(filePath, "oversized.ply");
expect(result.metadataStatus).toBe("error");
expect(result.metadataError).toContain("16KB");
});
});

View File

@@ -0,0 +1,153 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type {
PointAttribute,
PointcloudDataFormat,
PointcloudMetadataValues,
} from "../shared/pointcloudMetadata";
export const MAX_PLY_HEADER_BYTES = 16 * 1024;
type ScalarType = { type: string; bytes: number };
const PLY_SCALAR_TYPES: Record<string, ScalarType> = {
char: { type: "int8", bytes: 1 },
int8: { type: "int8", bytes: 1 },
uchar: { type: "uint8", bytes: 1 },
uint8: { type: "uint8", bytes: 1 },
short: { type: "int16", bytes: 2 },
int16: { type: "int16", bytes: 2 },
ushort: { type: "uint16", bytes: 2 },
uint16: { type: "uint16", bytes: 2 },
int: { type: "int32", bytes: 4 },
int32: { type: "int32", bytes: 4 },
uint: { type: "uint32", bytes: 4 },
uint32: { type: "uint32", bytes: 4 },
float: { type: "float32", bytes: 4 },
float32: { type: "float32", bytes: 4 },
double: { type: "float64", bytes: 8 },
float64: { type: "float64", bytes: 8 },
};
export class PointcloudMetadataParseError extends Error {}
export type ParsedPlyMetadata = {
pointCount: number;
pointAttributes: PointAttribute[];
pointDataFormat: PointcloudDataFormat;
bytesPerPoint: number;
};
/** 解析内存中的 PLY 文件头。调用方应保证输入不超过 MAX_PLY_HEADER_BYTES。 */
export function parsePlyHeader(buffer: Buffer): ParsedPlyMetadata {
const text = buffer.toString("latin1");
const endMatch = /(?:^|\r?\n)end_header(?:\r?\n|$)/.exec(text);
if (!endMatch) {
throw new PointcloudMetadataParseError("未在 16KB 内找到 PLY 文件头结束标记");
}
const headerEnd = endMatch.index + endMatch[0].length;
const lines = text.slice(0, headerEnd).split(/\r?\n/).map((line) => line.trim());
if (lines[0] !== "ply") {
throw new PointcloudMetadataParseError("不是有效的 PLY 文件");
}
let pointDataFormat: PointcloudDataFormat | null = null;
let pointCount: number | null = null;
let currentElement: string | null = null;
const pointAttributes: PointAttribute[] = [];
for (const line of lines.slice(1)) {
if (!line || line === "end_header" || line.startsWith("comment ") || line.startsWith("obj_info ")) {
continue;
}
const tokens = line.split(/\s+/);
if (tokens[0] === "format") {
const value = tokens[1];
if (value !== "ascii" && value !== "binary_little_endian" && value !== "binary_big_endian") {
throw new PointcloudMetadataParseError("不支持的 PLY 编码");
}
pointDataFormat = value;
continue;
}
if (tokens[0] === "element") {
currentElement = tokens[1] ?? null;
if (currentElement === "vertex") {
const count = Number(tokens[2]);
if (!Number.isSafeInteger(count) || count < 0) {
throw new PointcloudMetadataParseError("PLY 顶点数无效");
}
pointCount = count;
}
continue;
}
if (tokens[0] === "property" && currentElement === "vertex") {
if (tokens[1] === "list") {
throw new PointcloudMetadataParseError("暂不支持列表型顶点属性");
}
const scalar = PLY_SCALAR_TYPES[tokens[1]];
const name = tokens[2];
if (!scalar || !name) {
throw new PointcloudMetadataParseError("PLY 包含未知的顶点属性类型");
}
pointAttributes.push({ name, type: scalar.type, bytes: scalar.bytes });
}
}
if (!pointDataFormat) {
throw new PointcloudMetadataParseError("PLY 文件头缺少编码信息");
}
if (pointCount === null) {
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点数量");
}
if (pointAttributes.length === 0) {
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点属性");
}
return {
pointCount,
pointAttributes,
pointDataFormat,
bytesPerPoint: pointAttributes.reduce((sum, attribute) => sum + attribute.bytes, 0),
};
}
export function inspectPointcloudMetadata(filePath: string, displayName = filePath): PointcloudMetadataValues {
const extension = path.extname(displayName || filePath).toLowerCase();
if (extension !== ".ply") {
return {
metadataStatus: "unsupported",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: null,
};
}
try {
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.allocUnsafe(MAX_PLY_HEADER_BYTES);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
const parsed = parsePlyHeader(buffer.subarray(0, bytesRead));
return { metadataStatus: "ready", ...parsed, metadataError: null };
} finally {
fs.closeSync(fd);
}
} catch (error) {
return {
metadataStatus: "error",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: error instanceof PointcloudMetadataParseError
? error.message
: "PLY 文件头解析失败",
};
}
}

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
import { appRouter } from "./routers";
vi.mock("./db", () => ({
getTaskPointcloudFileById: vi.fn(),
findTaskPointcloudFileByTaskAndName: vi.fn(),
updateTaskPointcloudFileName: vi.fn(),
getModelPointcloudFileById: vi.fn(),
findModelPointcloudFileByModelAndName: vi.fn(),
updateModelPointcloudFileName: vi.fn(),
}));
const now = new Date("2026-07-21T00:00:00Z");
const taskPointcloud = {
id: 11,
taskId: 101,
name: "原始点云.ply",
fileSize: 123,
filePath: "/data/tasks/101/physical-file.PLY",
source: "uploaded",
sourceResultId: null,
sourcePointcloudFileId: null,
sourceModelPointcloudId: null,
createdAt: now,
};
const modelPointcloud = {
id: 22,
modelId: 202,
name: "车型点云.txt",
fileSize: 456,
filePath: "/data/models/202/physical-file.txt",
createdAt: now,
};
function user(role: User["role"]): User {
return {
id: 1,
username: `${role}-account`,
passwordHash: "unused",
name: role,
email: null,
role,
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: now,
updatedAt: now,
lastLoginAt: null,
};
}
function caller(role: User["role"] = "admin") {
const context: TrpcContext = {
user: user(role),
req: {} as TrpcContext["req"],
res: {} as TrpcContext["res"],
};
return appRouter.createCaller(context);
}
describe("点云重命名路由", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValue(taskPointcloud);
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValue(undefined);
vi.mocked(db.updateTaskPointcloudFileName).mockImplementation(async (id, name) => ({
...taskPointcloud,
id,
name,
}));
vi.mocked(db.getModelPointcloudFileById).mockResolvedValue(modelPointcloud);
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValue(undefined);
vi.mocked(db.updateModelPointcloudFileName).mockImplementation(async (id, name) => ({
...modelPointcloud,
id,
name,
}));
});
it("任务点云去除首尾空格并按实际路径保留扩展名", async () => {
const result = await caller().pointcloudFile.rename({ id: 11, baseName: " 新名称 " });
expect(result?.name).toBe("新名称.PLY");
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "新名称.PLY", 11);
expect(db.updateTaskPointcloudFileName).toHaveBeenCalledWith(11, "新名称.PLY");
});
it("车型库点云按 modelId 检查重名并返回更新记录", async () => {
const result = await caller("super_admin").modelPointcloudFile.rename({ id: 22, baseName: "扫描结果" });
expect(result?.name).toBe("扫描结果.txt");
expect(db.findModelPointcloudFileByModelAndName).toHaveBeenCalledWith(202, "扫描结果.txt", 22);
expect(db.updateModelPointcloudFileName).toHaveBeenCalledWith(22, "扫描结果.txt");
});
it.each(["", " "])("拒绝空名称 %#", async (baseName) => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "点云名称不能为空",
});
});
it.each(["目录/名称", "目录\\名称", "名称\n换行"])("拒绝非法名称 %s", async (baseName) => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("拒绝拼接扩展名后超过 255 个字符的名称", async () => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "名".repeat(252) })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "点云名称不能超过 255 个字符(含扩展名)",
});
});
it("同一任务中忽略英文大小写禁止重名", async () => {
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValueOnce({
...taskPointcloud,
id: 12,
name: "NEW-NAME.ply",
});
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "new-name" })).rejects.toMatchObject({
code: "CONFLICT",
message: "当前任务下已存在同名点云",
});
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
});
it("同一车型中忽略英文大小写禁止重名", async () => {
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValueOnce({
...modelPointcloud,
id: 23,
name: "SCAN.txt",
});
await expect(caller().modelPointcloudFile.rename({ id: 22, baseName: "scan" })).rejects.toMatchObject({
code: "CONFLICT",
message: "当前车型下已存在同名点云",
});
});
it("记录不存在时返回 NOT_FOUND", async () => {
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(undefined);
vi.mocked(db.getModelPointcloudFileById).mockResolvedValueOnce(undefined);
await expect(caller().pointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(caller().modelPointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("名称未变化时直接成功且不查询重名、不更新", async () => {
const unchanged = { ...taskPointcloud, name: "原始点云.PLY" };
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(unchanged);
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toEqual(unchanged);
expect(db.findTaskPointcloudFileByTaskAndName).not.toHaveBeenCalled();
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
});
it("名称只改变大小写时排除自身后允许更新", async () => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toMatchObject({
name: "原始点云.PLY",
});
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "原始点云.PLY", 11);
});
it("普通用户无权调用,管理员和超级管理员可以调用", async () => {
await expect(caller("user").pointcloudFile.rename({ id: 11, baseName: "无权修改" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
await expect(caller("admin").pointcloudFile.rename({ id: 11, baseName: "管理员修改" })).resolves.toMatchObject({
name: "管理员修改.PLY",
});
await expect(caller("super_admin").pointcloudFile.rename({ id: 11, baseName: "超管修改" })).resolves.toMatchObject({
name: "超管修改.PLY",
});
});
});

View File

@@ -0,0 +1,89 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { samplePointcloudFile, samplePointcloudFiles } from './pointcloudSample';
const temporaryDirectories: string[] = [];
afterEach(() => {
while (temporaryDirectories.length > 0) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
}
});
function writeBinaryPly(properties: string[], records: number[][]): string {
const directory = mkdtempSync(join(tmpdir(), 'pointcloud-sample-'));
temporaryDirectories.push(directory);
const filePath = join(directory, 'cloud.ply');
const header = Buffer.from([
'ply',
'format binary_little_endian 1.0',
`element vertex ${records.length}`,
...properties.map((name) => `property float ${name}`),
'end_header',
'',
].join('\n'), 'ascii');
const body = Buffer.alloc(records.length * properties.length * 4);
records.forEach((record, recordIndex) => {
record.forEach((value, propertyIndex) => {
body.writeFloatLE(value, (recordIndex * properties.length + propertyIndex) * 4);
});
});
writeFileSync(filePath, Buffer.concat([header, body]));
return filePath;
}
describe('samplePointcloudFile', () => {
it('不会为只有 xyz 的输入点云伪造误差', async () => {
const filePath = writeBinaryPly(
['x', 'y', 'z'],
[[1, 2, 3], [4, 5, 6]],
);
const points = await samplePointcloudFile(filePath, 1_000, 2);
expect(points).toHaveLength(2);
expect(points.every((point) => point.error === undefined)).toBe(true);
});
it.each(['error', 'deviation'])('保留 PLY 中真实的 %s 属性', async (errorProperty) => {
const filePath = writeBinaryPly(
['x', 'y', 'z', errorProperty],
[[1, 2, 3, 0.25], [4, 5, 6, -0.5]],
);
const points = await samplePointcloudFile(filePath, 1_000, 2);
expect(points.map((point) => point.error)).toEqual([0.25, -0.5]);
});
});
describe('samplePointcloudFiles', () => {
it('合并彩色与无色 PLY,并使用 28 字节彩色步长', async () => {
const plain = writeBinaryPly(['x', 'y', 'z'], [[1, 2, 3], [4, 5, 6]]);
const colored = writeBinaryPly(
['x', 'y', 'z', 'red', 'green', 'blue'],
[[7, 8, 9, 255, 128, 0], [10, 11, 12, 0, 64, 255]],
);
const result = await samplePointcloudFiles([plain, colored], 4, 10);
expect(result.pointCount).toBe(4);
expect(result.hasColor).toBe(true);
expect(result.hasError).toBe(false);
expect(result.buffer.byteLength).toBe(4 * 28);
expect(result.fileStats.map((file) => file.sampled)).toEqual([2, 2]);
});
it('限制总采样数量,无色 PLY 使用 16 字节步长', async () => {
const first = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i, i, i]));
const second = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i + 10, i, i]));
const result = await samplePointcloudFiles([first, second], 100, 6);
expect(result.pointCount).toBe(6);
expect(result.hasColor).toBe(false);
expect(result.buffer.byteLength).toBe(6 * 16);
});
});

View File

@@ -1,7 +1,15 @@
import * as fs from 'fs';
import * as path from 'path';
export type SampledPoint = { x: number; y: number; z: number; error: number; r?: number; g?: number; b?: number };
export type SampledPoint = { x: number; y: number; z: number; error?: number; r?: number; g?: number; b?: number };
export type SampledPointcloudBatch = {
buffer: Buffer;
pointCount: number;
hasColor: boolean;
hasError: boolean;
fileStats: Array<{ name: string; sizeGB: number; sampled: number }>;
};
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
export async function randomSeekSample(
@@ -66,9 +74,10 @@ export async function randomSeekSample(
const y = parseFloat(parts[1]);
const z = parseFloat(parts[2]);
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
if (isNaN(error)) error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5;
points.push({ x, y, z, error });
const parsedError = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
const point: SampledPoint = { x, y, z };
if (Number.isFinite(parsedError)) point.error = parsedError;
points.push(point);
}
} finally {
fs.closeSync(fd);
@@ -173,8 +182,10 @@ function samplePlyBinary(
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
let xOff = -1, yOff = -1, zOff = -1;
let rOff = -1, gOff = -1, bOff = -1;
let errorOff = -1;
let xType = 'float', yType = 'float', zType = 'float';
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
let errorType = 'float';
let byteOff = 0;
for (const p of properties) {
const n = p.name.toLowerCase();
@@ -184,6 +195,7 @@ function samplePlyBinary(
else if (n === 'red') { rOff = byteOff; rType = p.type; }
else if (n === 'green') { gOff = byteOff; gType = p.type; }
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
else if (n === 'error' || n === 'deviation') { errorOff = byteOff; errorType = p.type; }
byteOff += p.byteSize;
}
@@ -228,8 +240,11 @@ function samplePlyBinary(
const y = readNum(recBuf, yOff, yType);
const z = readNum(recBuf, zOff, zType);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
const pt: SampledPoint = { x, y, z, error };
const pt: SampledPoint = { x, y, z };
if (errorOff >= 0) {
const error = readNum(recBuf, errorOff, errorType);
if (Number.isFinite(error)) pt.error = error;
}
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
pt.r = readNum(recBuf, rOff, rType);
pt.g = readNum(recBuf, gOff, gType);
@@ -289,18 +304,22 @@ function samplePlyAscii(
if (!line) continue;
const parts = line.split(/\s+/);
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1, errorI = -1;
header.properties.forEach((p, i) => {
const n = p.name.toLowerCase();
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
else if (n === 'error' || n === 'deviation') errorI = i;
});
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
const pt: SampledPoint = { x, y, z, error };
const pt: SampledPoint = { x, y, z };
if (errorI >= 0) {
const error = parseFloat(parts[errorI]);
if (Number.isFinite(error)) pt.error = error;
}
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
pt.r = parseFloat(parts[ri]);
pt.g = parseFloat(parts[gi]);
@@ -345,3 +364,70 @@ export async function samplePointcloudFile(
// .txt / .xyz / .pts / etc. — existing text line sampler
return randomSeekSample(filePath, fileSize, k);
}
/**
* 对一组已校验的点云文件按文件大小分配总采样配额,并编码为浏览器消费的二进制协议。
* 每点固定包含 x/y/z/error;任一文件带颜色时,所有点再包含归一化后的 r/g/b。
*/
export async function samplePointcloudFiles(
filePaths: string[],
requestedTotalPoints: number,
maxTotalPoints = 2_000_000,
): Promise<SampledPointcloudBatch> {
const totalPoints = Number.isFinite(requestedTotalPoints)
? Math.max(0, Math.min(Math.floor(requestedTotalPoints), maxTotalPoints))
: 0;
const fileInfos = filePaths.map((filePath) => ({
filePath,
name: path.basename(filePath),
size: fs.statSync(filePath).size,
}));
const totalBytes = fileInfos.reduce((sum, file) => sum + file.size, 0);
// floor 后把剩余名额依次补给文件,保证所有配额之和严格不超过总预算。
const quotas = fileInfos.map((file) => totalBytes > 0
? Math.floor((file.size / totalBytes) * totalPoints)
: 0);
let remainder = totalPoints - quotas.reduce((sum, quota) => sum + quota, 0);
for (let index = 0; remainder > 0 && fileInfos.length > 0; index = (index + 1) % fileInfos.length) {
quotas[index] += 1;
remainder -= 1;
}
const sampledPerFile = await Promise.all(
fileInfos.map((file, index) => samplePointcloudFile(file.filePath, file.size, quotas[index])),
);
const pointCount = sampledPerFile.reduce((sum, points) => sum + points.length, 0);
const hasError = sampledPerFile.some((points) => points.some((point) => Number.isFinite(point.error)));
const hasColor = sampledPerFile.some((points) => points.some((point) => point.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const buffer = Buffer.allocUnsafe(pointCount * bytesPerPoint);
let offset = 0;
for (const points of sampledPerFile) {
for (const point of points) {
buffer.writeFloatLE(point.x, offset);
buffer.writeFloatLE(point.y, offset + 4);
buffer.writeFloatLE(point.z, offset + 8);
buffer.writeFloatLE(point.error ?? Number.NaN, offset + 12);
if (hasColor) {
buffer.writeFloatLE((point.r ?? 128) / 255, offset + 16);
buffer.writeFloatLE((point.g ?? 128) / 255, offset + 20);
buffer.writeFloatLE((point.b ?? 128) / 255, offset + 24);
}
offset += bytesPerPoint;
}
}
return {
buffer,
pointCount,
hasColor,
hasError,
fileStats: fileInfos.map((file, index) => ({
name: file.name,
sizeGB: +(file.size / 1e9).toFixed(3),
sampled: sampledPerFile[index].length,
})),
};
}

View File

@@ -1,217 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
// Mock database functions
vi.mock("./db", () => ({
createProject: vi.fn().mockResolvedValue({
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "uploading",
totalPoints: 0,
totalChunks: 10,
uploadedChunks: 0,
totalSize: 0,
lodLevels: 0,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
getProjectsByUserId: vi.fn().mockResolvedValue([
{
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "ready",
totalPoints: 1000000,
totalChunks: 10,
uploadedChunks: 10,
totalSize: 32000000,
lodLevels: 8,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
getProjectById: vi.fn().mockResolvedValue({
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "ready",
totalPoints: 1000000,
totalChunks: 10,
uploadedChunks: 10,
totalSize: 32000000,
lodLevels: 8,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
updateProject: vi.fn().mockResolvedValue(undefined),
createAnalysisResult: vi.fn().mockResolvedValue({
id: 1,
projectId: 1,
cadModelId: null,
type: "error_analysis",
status: "pending",
resultData: null,
errorStats: null,
measurements: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
updateAnalysisResult: vi.fn().mockResolvedValue(undefined),
}));
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user",
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
describe("project router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a new project", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.create({
name: "Test Project",
description: "Test Description",
totalChunks: 10,
});
expect(result).toBeDefined();
expect(result.name).toBe("Test Project");
expect(result.status).toBe("uploading");
});
it("lists user projects", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.list();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result[0].name).toBe("Test Project");
});
it("gets a project by id", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.get({ id: 1 });
expect(result).toBeDefined();
expect(result?.id).toBe(1);
expect(result?.name).toBe("Test Project");
});
it("updates a project", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.update({
id: 1,
name: "Updated Project",
status: "ready",
});
expect(result).toEqual({ success: true });
});
});
describe("analysis router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates an analysis result", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.create({
projectId: 1,
type: "error_analysis",
});
expect(result).toBeDefined();
expect(result.type).toBe("error_analysis");
expect(result.status).toBe("pending");
});
it("runs error analysis simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runErrorAnalysis({
analysisId: 1,
projectId: 1,
});
expect(result.success).toBe(true);
expect(result.errorStats).toBeDefined();
expect(result.errorStats.min).toBeDefined();
expect(result.errorStats.max).toBeDefined();
expect(result.errorStats.mean).toBeDefined();
});
it("runs measurement simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runMeasurement({
analysisId: 1,
projectId: 1,
});
expect(result.success).toBe(true);
expect(result.measurements).toBeDefined();
expect(Array.isArray(result.measurements)).toBe(true);
expect(result.measurements.length).toBeGreaterThan(0);
});
it("runs compression simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runCompression({
analysisId: 1,
projectId: 1,
compressionRatio: 0.3,
});
expect(result.success).toBe(true);
expect(result.resultData).toBeDefined();
expect(result.resultData.compressionRatio).toBe(0.3);
});
});

View File

@@ -1,7 +1,7 @@
import { COOKIE_NAME } from "@shared/const";
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as fs from "fs";
@@ -13,12 +13,45 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js";
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from "./measurementCompatibility.js";
import {
clearLoginFailures,
createSessionToken,
hashPassword,
isLoginRateLimited,
publicUser,
recordLoginFailure,
validatePassword,
verifyPassword,
} from "./_core/auth";
const DATA_DIR = process.env.DATA_DIR || "/data";
const TASKS_DIR = path.join(DATA_DIR, "tasks");
function pointcloudDisplayName(baseNameInput: string, filePath: string): string {
const baseName = baseNameInput.trim();
if (!baseName) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" });
}
if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" });
}
const name = `${baseName}${path.extname(filePath)}`;
if (Array.from(name).length > 255) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" });
}
return name;
}
const DEMO_MEASUREMENT_PLUGIN_IDS = [
"train-measurement",
"coupler-panel",
@@ -64,6 +97,7 @@ async function validateRegistrationRunInput(input: {
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
await validateTaskInputCompatibility(input.taskId, pointcloud, cad);
const registeredChildren = pointcloudFiles
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
@@ -77,6 +111,29 @@ async function validateRegistrationRunInput(input: {
}
}
async function validateTaskInputCompatibility(
taskId: number,
pointcloud: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>[number],
cad: Awaited<ReturnType<typeof db.listTaskCadFiles>>[number],
) {
const task = await db.getInspectionTaskById(taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
if (!task.inspectionObjectId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "历史任务尚未确认检测对象,请联系管理员完成归类后再运行算法。" });
}
if (pointcloud.source === "model_library" && pointcloud.sourceModelPointcloudId) {
const source = await db.getModelPointcloudFileById(pointcloud.sourceModelPointcloudId);
if (!source || source.inspectionObjectId !== task.inspectionObjectId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云与当前检测对象不兼容。" });
}
}
if (cad.source === "model_library" && cad.sourceModelCadFileId) {
if (!await db.cadAppliesToInspectionObject(cad.sourceModelCadFileId, task.inspectionObjectId)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "CAD 与当前检测对象不兼容。" });
}
}
}
function latestRegisteredPointcloud(
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
sourcePointcloudFileId: number,
@@ -257,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户
};
}
interface DemoFlowMeasurementJob {
pluginId: string;
params?: Record<string, unknown>;
taskNos?: number[];
}
interface DemoFlowMetadata {
demoFlowMode: "all" | "selected";
demoFlowSelectedTaskNos?: number[];
demoFlowSelectedTaskCount?: number;
}
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
registrationResultId: number;
measurementPluginIds: string[];
measurementJobs: DemoFlowMeasurementJob[];
demoFlowRunId: string;
metadata: DemoFlowMetadata;
}) {
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
if (registrationResult?.status !== "completed") {
@@ -279,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
return;
}
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
const pluginId = input.measurementPluginIds[idx];
for (let idx = 0; idx < input.measurementJobs.length; idx += 1) {
const job = input.measurementJobs[idx];
const pluginId = job.pluginId;
const manifest = getPlugin(pluginId);
await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId,
params: paramsForDemoPlugin(pluginId),
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
? [registered.id]
: [input.sourcePointCloudFileId],
@@ -294,24 +365,248 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
demoFlowRunId: input.demoFlowRunId,
demoFlowRole: "measurement",
demoFlowIndex: idx + 2,
demoFlowTotal: input.measurementPluginIds.length + 1,
demoFlowTotal: input.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
...(job.taskNos ? { demoFlowPluginTaskNos: job.taskNos } : {}),
},
});
}
}
async function startDemoFlow(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
measurementJobs: DemoFlowMeasurementJob[];
metadata: DemoFlowMetadata;
}) {
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const job of input.measurementJobs) {
const manifest = getPlugin(job.pluginId);
if (!manifest) throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${job.pluginId}` });
if (manifest.category === "配准") {
throw new TRPCError({ code: "BAD_REQUEST", message: "全流程的测量算法列表不能包含配准算法。" });
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
if (sourcePointcloud.source === "registered") {
throw new TRPCError({ code: "BAD_REQUEST", message: "请选择原始点云,系统会自动找到对应配准产物。" });
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad);
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: input.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[demoFlow] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementJobs: input.measurementJobs,
demoFlowRunId,
metadata: input.metadata,
});
}).catch((err) => console.error("[demoFlow] 配准链式提交失败:", err));
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: input.measurementJobs.length,
scheduler: algorithmJobStats(),
};
}
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
login: publicProcedure
.input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }))
.mutation(async ({ input, ctx }) => {
const username = input.username.trim();
const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown";
if (isLoginRateLimited(ip, username)) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" });
}
const user = await db.getUserByUsername(username);
const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null));
if (!user || !valid) {
recordLoginFailure(ip, username);
throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" });
}
clearLoginFailures(ip, username);
await db.updateUser(user.id, { lastLoginAt: new Date() });
const token = await createSessionToken(user);
ctx.res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(ctx.req),
maxAge: SESSION_TTL_MS,
});
return publicUser({ ...user, lastLoginAt: new Date() });
}),
me: sessionProcedure.query(opts => publicUser(opts.ctx.user)),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
changePassword: sessionProcedure
.input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) }))
.mutation(async ({ input, ctx }) => {
if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" });
}
if (input.currentPassword === input.newPassword) {
throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" });
}
validatePassword(input.newPassword);
const passwordHash = await hashPassword(input.newPassword);
const nextVersion = ctx.user.sessionVersion + 1;
await db.updateUser(ctx.user.id, {
passwordHash,
mustChangePassword: 0,
sessionVersion: nextVersion,
});
const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion });
ctx.res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(ctx.req),
maxAge: SESSION_TTL_MS,
});
return { success: true } as const;
}),
}),
users: router({
list: superAdminProcedure
.input(z.object({ search: z.string().max(100).optional() }).optional())
.query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)),
create: superAdminProcedure
.input(z.object({
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"),
temporaryPassword: z.string().min(8).max(256),
name: z.string().trim().min(1).max(100),
email: z.string().email().max(320).optional().or(z.literal("")),
role: z.enum(["user", "admin", "super_admin"]),
}))
.mutation(async ({ input }) => {
try {
const row = await db.createUser({
username: input.username,
passwordHash: await hashPassword(input.temporaryPassword),
name: input.name,
email: input.email || null,
role: input.role,
isActive: 1,
mustChangePassword: 1,
sessionVersion: 0,
});
if (!row) throw new Error("创建用户失败");
return publicUser(row);
} catch (error) {
if (isDuplicateKeyError(error)) {
throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
}
throw error;
}
}),
update: superAdminProcedure
.input(z.object({
id: z.number().int().positive(),
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
name: z.string().trim().min(1).max(100).optional(),
email: z.string().email().max(320).optional().or(z.literal("")),
role: z.enum(["user", "admin", "super_admin"]).optional(),
}))
.mutation(async ({ input, ctx }) => {
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.id === ctx.user.id && input.role && input.role !== target.role) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" });
}
if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" });
}
const { id, email, ...rest } = input;
try {
const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) });
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
return publicUser(row);
} catch (error) {
if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
throw error;
}
}),
setActive: superAdminProcedure
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
.mutation(async ({ input, ctx }) => {
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" });
if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) {
throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" });
}
if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" });
}
const row = await db.updateUser(target.id, {
isActive: input.isActive ? 1 : 0,
sessionVersion: target.sessionVersion + 1,
});
return publicUser(row!);
}),
resetPassword: superAdminProcedure
.input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) }))
.mutation(async ({ input, ctx }) => {
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" });
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
await db.updateUser(target.id, {
passwordHash: await hashPassword(input.temporaryPassword),
mustChangePassword: 1,
sessionVersion: target.sessionVersion + 1,
});
return { success: true } as const;
}),
delete: superAdminProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" });
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" });
}
await db.deleteUser(target.id);
return { success: true } as const;
}),
}),
// ─── 车系 ──────────────────────────────────────────────────────────────────
@@ -402,16 +697,88 @@ export const appRouter = router({
}),
}),
// ─── 检测对象配置 ─────────────────────────────────────────────────────────
inspectionObject: router({
list: protectedProcedure
.input(z.object({ modelId: z.number(), activeOnly: z.boolean().optional() }))
.query(({ input }) => db.listInspectionObjects(input.modelId, input.activeOnly ?? false)),
create: adminProcedure
.input(z.object({ modelId: z.number(), name: z.string().trim().min(1).max(100), description: z.string().optional() }))
.mutation(({ input }) => db.createInspectionObject({ ...input, description: input.description ?? null, status: "draft" })),
update: adminProcedure
.input(z.object({ id: z.number(), name: z.string().trim().min(1).max(100).optional(), description: z.string().optional() }))
.mutation(({ input }) => {
const { id, ...data } = input;
return db.updateInspectionObject(id, data);
}),
configureDefaults: adminProcedure
.input(z.object({ id: z.number(), defaultModelPointcloudId: z.number(), defaultModelCadFileId: z.number(), active: z.boolean() }))
.mutation(async ({ input }) => {
try { return await db.configureInspectionObject(input); }
catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "默认输入方案无效" }); }
}),
setStatus: adminProcedure
.input(z.object({ id: z.number(), status: z.enum(["draft", "active"]) }))
.mutation(async ({ input }) => {
if (input.status === "active") {
const object = await db.getInspectionObjectById(input.id);
if (!object?.defaultModelPointcloudId || !object.defaultModelCadFileId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "缺少预置点云或默认 CAD,不能启用检测对象" });
}
return db.configureInspectionObject({
id: object.id,
defaultModelPointcloudId: object.defaultModelPointcloudId,
defaultModelCadFileId: object.defaultModelCadFileId,
active: true,
});
}
return db.updateInspectionObject(input.id, { status: "draft" });
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
await db.deleteInspectionObject(input.id);
return { success: true };
} catch (error) {
if (error instanceof db.InspectionObjectReferencedError) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: error.message });
}
if (error instanceof Error && error.message === "检测对象不存在") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
throw error;
}
}),
}),
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────
cadFile: router({
list: protectedProcedure
.input(z.object({ modelId: z.number() }))
.query(async ({ input }) => {
return db.listModelCadFiles(input.modelId);
const files = await db.listModelCadFiles(input.modelId);
const objectIds = await db.listCadInspectionObjectIds(files.map(file => file.id));
return files.map(file => ({ ...file, inspectionObjectIds: objectIds[file.id] ?? [] }));
}),
delete: protectedProcedure
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelCadFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
setInspectionObjects: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectIds: z.array(z.number()).min(1) }))
.mutation(async ({ input }) => {
const cad = await db.getModelCadFileById(input.id);
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "CAD 文件不存在" });
const objects = await Promise.all(input.inspectionObjectIds.map(id => db.getInspectionObjectById(id)));
if (objects.some(object => !object || object.modelId !== cad.modelId)) throw new TRPCError({ code: "BAD_REQUEST", message: "适用检测对象必须属于 CAD 当前车型" });
await db.setCadInspectionObjects(input.id, input.inspectionObjectIds);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteModelCadFile(input.id);
@@ -444,7 +811,32 @@ export const appRouter = router({
return db.listModelPointcloudFiles(input.modelId);
}),
delete: protectedProcedure
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelPointcloudFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
assignInspectionObject: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(({ input }) => db.assignModelPointcloudInspectionObject(input.id, input.inspectionObjectId)),
rename: adminProcedure
.input(z.object({ id: z.number(), baseName: z.string() }))
.mutation(async ({ input }) => {
const row = await db.getModelPointcloudFileById(input.id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "车型库点云不存在" });
}
const name = pointcloudDisplayName(input.baseName, row.filePath);
if (name === row.name) return row;
const duplicate = await db.findModelPointcloudFileByModelAndName(row.modelId, name, row.id);
if (duplicate) {
throw new TRPCError({ code: "CONFLICT", message: "当前车型下已存在同名点云" });
}
return db.updateModelPointcloudFileName(row.id, name);
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteModelPointcloudFile(input.id);
@@ -524,9 +916,23 @@ export const appRouter = router({
return db.getInspectionTaskById(input.id);
}),
classify: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.id);
const object = await db.getInspectionObjectById(input.inspectionObjectId);
if (!task || !object) throw new TRPCError({ code: "NOT_FOUND", message: "任务或检测对象不存在" });
if (task.modelId !== object.modelId || object.status !== "active") {
throw new TRPCError({ code: "BAD_REQUEST", message: "只能确认当前车型下已启用的检测对象" });
}
await db.classifyInspectionTask(task.id, object.id);
return { success: true };
}),
create: protectedProcedure
.input(z.object({
modelId: z.number(),
inspectionObjectId: z.number(),
name: z.string().trim().min(1).max(255),
inspector: z.string().max(100).optional(),
notes: z.string().optional(),
@@ -538,8 +944,9 @@ export const appRouter = router({
}
try {
return await db.createInspectionTask({
return await db.createInspectionTaskWithDefaults({
modelId: input.modelId,
inspectionObjectId: input.inspectionObjectId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
@@ -596,6 +1003,23 @@ export const appRouter = router({
return db.listTaskPointcloudFiles(input.taskId);
}),
rename: adminProcedure
.input(z.object({ id: z.number(), baseName: z.string() }))
.mutation(async ({ input }) => {
const row = await db.getTaskPointcloudFileById(input.id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" });
}
const name = pointcloudDisplayName(input.baseName, row.filePath);
if (name === row.name) return row;
const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id);
if (duplicate) {
throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" });
}
return db.updateTaskPointcloudFileName(row.id, name);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
@@ -631,7 +1055,43 @@ export const appRouter = router({
list: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
return db.listTaskAnalysisResults(input.taskId);
const [rows, task] = await Promise.all([
db.listTaskAnalysisResults(input.taskId),
db.getInspectionTaskById(input.taskId),
]);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return rows.map((row) => {
const resultData = row.resultData && typeof row.resultData === 'object' && !Array.isArray(row.resultData)
? { ...(row.resultData as Record<string, unknown>) }
: {};
const pluginId = row.pluginId ?? (typeof resultData.pluginId === 'string' ? resultData.pluginId : undefined);
const rawMeasurements = Array.isArray(row.measurements)
? row.measurements
: resultData.measurements;
if (!pluginId || !Array.isArray(rawMeasurements)) return row;
const normalized = normalizeMeasurements({
pluginId,
measurements: rawMeasurements,
extra: resultData.extra,
context,
algorithmProtocolVersion: row.schemaVersion ?? (resultData.schemaVersion as string | number | undefined),
});
logCompatibilityWarnings(row.id, normalized.warnings);
return {
...row,
measurements: normalized.measurements,
resultData: {
...resultData,
measurements: normalized.measurements,
extra: normalized.extra,
},
};
});
}),
runAnalysis: protectedProcedure
@@ -801,11 +1261,7 @@ export const appRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
// 先取消正在运行或排队的 job,再删数据库记录
cancelAlgorithmJob(input.id);
cancelLegacyAnalysisJob(input.id);
await db.deleteTaskAnalysisResult(input.id);
return { success: true };
return deleteAnalysisResult(input.id);
}),
}),
@@ -956,6 +1412,19 @@ export const appRouter = router({
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
}),
/** 当前检测对象可用的正式任务目录。 */
demoTaskCatalog: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return buildDemoTaskCatalog(listPlugins(), context.inspectionObject, context.inspectionObjectName);
}),
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
runDemoAll: protectedProcedure
.input(
@@ -968,84 +1437,50 @@ export const appRouter = router({
)
.mutation(async ({ input }) => {
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const pluginId of measurementPluginIds) {
const manifest = getPlugin(pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${pluginId}`,
});
}
if (manifest.category === "配准") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "一键全流程演示的测量算法列表不能包含配准算法。",
});
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
}
if (sourcePointcloud.source === "registered") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "请选择原始点云,系统会自动找到对应配准产物。",
});
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
const registration = await createAndEnqueuePluginJob({
return startDemoFlow({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
metadata: { demoFlowMode: "all" },
});
}),
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
runDemoSelected: protectedProcedure
.input(z.object({
taskId: z.number(),
sourcePointCloudFileId: z.number(),
cadFileId: z.number(),
taskNos: z.array(z.number().int().positive()).min(1).max(64),
}))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
const manifests = listPlugins();
const catalog = buildDemoTaskCatalog(manifests, context.inspectionObject, context.inspectionObjectName);
let selectedPlan: ReturnType<typeof resolveSelectedDemoJobs>;
try {
selectedPlan = resolveSelectedDemoJobs(manifests, catalog, input.taskNos);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "所选任务无效" });
}
const result = await startDemoFlow({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
measurementJobs: selectedPlan.jobs,
metadata: {
demoFlowMode: "selected",
demoFlowSelectedTaskNos: selectedPlan.taskNos,
demoFlowSelectedTaskCount: selectedPlan.taskNos.length,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise
.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementPluginIds,
demoFlowRunId,
});
})
.catch((err) => {
console.error("[runDemoAll] 配准链式提交失败:", err);
});
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: measurementPluginIds.length,
scheduler: algorithmJobStats(),
};
return { ...result, selectedTaskNos: selectedPlan.taskNos };
}),
cancelDemoAll: protectedProcedure
@@ -1053,6 +1488,26 @@ export const appRouter = router({
.mutation(async ({ input }) => {
markDemoFlowCancelled(input.taskId);
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
const rows = await db.listTaskAnalysisResults(input.taskId);
const latestDemoRow = rows.find((row) => {
const data = row.resultData as Record<string, unknown> | null;
return typeof data?.demoFlowRunId === "string";
});
if (latestDemoRow) {
const latestDemoData = latestDemoRow.resultData as Record<string, unknown>;
const latestRunId = latestDemoData.demoFlowRunId;
const cancelledAt = new Date().toISOString();
// 即使停止发生在“配准完成、测量记录尚未创建”的短暂窗口,
// 也在本次链路的全部现有记录上留下结束标记,避免刷新后重新显示为排队中。
await Promise.all(
rows
.filter((row) => {
const data = row.resultData as Record<string, unknown> | null;
return data?.demoFlowRunId === latestRunId;
})
.map((row) => db.patchTaskAnalysisResultData(row.id, { demoFlowCancelledAt: cancelledAt })),
);
}
return {
...result,
scheduler: algorithmJobStats(),

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
vi.mock("./db", () => ({ getUserById: vi.fn() }));
import * as db from "./db";
import { authenticateRequest, createSessionToken } from "./_core/auth";
import { ENV } from "./_core/env";
import { COOKIE_NAME } from "../shared/const";
function user(overrides: Partial<User> = {}): User {
const now = new Date();
return { id: 7, username: "session-user", passwordHash: "unused", name: "Session User", email: null, role: "user", isActive: 1, mustChangePassword: 0, sessionVersion: 3, createdAt: now, updatedAt: now, lastLoginAt: null, ...overrides };
}
function requestWith(token: string) {
return { headers: { cookie: `${COOKIE_NAME}=${token}` } } as Parameters<typeof authenticateRequest>[0];
}
describe("会话即时失效", () => {
beforeEach(() => {
vi.clearAllMocks();
ENV.cookieSecret = "test-session-secret-with-at-least-32-chars";
});
it("有效账号与匹配版本可恢复会话", async () => {
const current = user();
vi.mocked(db.getUserById).mockResolvedValue(current);
const token = await createSessionToken(current);
await expect(authenticateRequest(requestWith(token))).resolves.toMatchObject({ id: 7, sessionVersion: 3 });
});
it("改密或重置密码递增版本后旧会话立即失效", async () => {
const token = await createSessionToken(user());
vi.mocked(db.getUserById).mockResolvedValue(user({ sessionVersion: 4 }));
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
});
it("账号被禁用后旧会话立即失效", async () => {
const token = await createSessionToken(user());
vi.mocked(db.getUserById).mockResolvedValue(user({ isActive: 0 }));
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => ({
getUserById: vi.fn(),
countActiveSuperAdmins: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
}));
import * as db from "./db";
import { appRouter } from "./routers";
function makeUser(id: number, role: User["role"] = "super_admin"): User {
const now = new Date();
return { id, username: `user-${id}`, passwordHash: "unused", name: `User ${id}`, email: null, role, isActive: 1, mustChangePassword: 0, sessionVersion: 0, createdAt: now, updatedAt: now, lastLoginAt: null };
}
function caller() {
const ctx: TrpcContext = { user: makeUser(1), req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
return appRouter.createCaller(ctx);
}
describe("用户管理安全保护", () => {
beforeEach(() => vi.clearAllMocks());
it("禁止删除自己", async () => {
await expect(caller().users.delete({ id: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.deleteUser).not.toHaveBeenCalled();
});
it("禁止修改自己的角色", async () => {
vi.mocked(db.getUserById).mockResolvedValue(makeUser(1));
await expect(caller().users.update({ id: 1, role: "admin" })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
it("禁止禁用最后一个有效超级管理员", async () => {
vi.mocked(db.getUserById).mockResolvedValue(makeUser(2));
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(1);
await expect(caller().users.setActive({ id: 2, isActive: false })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
it("有多个超级管理员时允许禁用其中一个并使旧会话失效", async () => {
const target = makeUser(2);
vi.mocked(db.getUserById).mockResolvedValue(target);
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(2);
vi.mocked(db.updateUser).mockResolvedValue({ ...target, isActive: 0, sessionVersion: 1 });
await expect(caller().users.setActive({ id: 2, isActive: false })).resolves.toMatchObject({ isActive: 0, sessionVersion: 1 });
expect(db.updateUser).toHaveBeenCalledWith(2, { isActive: 0, sessionVersion: 1 });
});
it("遗留账号未修改用户名并重置密码前不能启用", async () => {
const legacy = { ...makeUser(2, "user"), username: "legacy_user_2", passwordHash: null, isActive: 0 };
vi.mocked(db.getUserById).mockResolvedValue(legacy);
await expect(caller().users.setActive({ id: 2, isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
export const COOKIE_NAME = "app_session_id";
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
export const AXIOS_TIMEOUT_MS = 30_000;
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';
export const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
export const UNAUTHED_ERR_MSG = '请先登录';
export const NOT_ADMIN_ERR_MSG = '无权执行此操作';
export const MUST_CHANGE_PASSWORD_ERR_MSG = '请先修改初始密码';

View File

@@ -0,0 +1,21 @@
export type PointcloudMetadataStatus = "pending" | "ready" | "unsupported" | "error";
export type PointcloudDataFormat =
| "ascii"
| "binary_little_endian"
| "binary_big_endian";
export type PointAttribute = {
name: string;
type: string;
bytes: number;
};
export type PointcloudMetadataValues = {
metadataStatus: Exclude<PointcloudMetadataStatus, "pending">;
pointCount: number | null;
pointAttributes: PointAttribute[] | null;
pointDataFormat: PointcloudDataFormat | null;
bytesPerPoint: number | null;
metadataError: string | null;
};

View File

@@ -14,6 +14,6 @@ export default defineConfig({
},
test: {
environment: "node",
include: ["server/**/*.test.ts", "server/**/*.spec.ts"],
include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/src/**/*.test.ts", "client/src/**/*.spec.ts"],
},
});