Compare commits
9 Commits
66c7dd1b65
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e3dc7ebe21 | |||
| f879328562 | |||
| 4b9a2f4cd9 | |||
| 185dd36bf3 | |||
| ac1387debc | |||
| d33b280a93 | |||
| 9f4482bea1 | |||
| 29a69793db | |||
| 3db84a7fb2 |
39
.agents/skills/package-source-sha1/SKILL.md
Normal file
39
.agents/skills/package-source-sha1/SKILL.md
Normal 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`,再复制到交付位置。
|
||||||
4
.agents/skills/package-source-sha1/agents/openai.yaml
Normal file
4
.agents/skills/package-source-sha1/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "源码包 SHA-1"
|
||||||
|
short_description: "可复现地打包项目纯源码并自动生成 SHA-1 校验值"
|
||||||
|
default_prompt: "使用 $package-source-sha1 打包当前项目纯源码并返回 ZIP 的 SHA-1。"
|
||||||
176
.agents/skills/package-source-sha1/scripts/package_source.py
Normal file
176
.agents/skills/package-source-sha1/scripts/package_source.py
Normal 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())
|
||||||
@@ -59,7 +59,7 @@ MINIO_ROOT_USER=minioadmin
|
|||||||
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
||||||
|
|
||||||
# 应用配置
|
# 应用配置
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# 或者手动编辑
|
# 或者手动编辑
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 设置构建时环境变量
|
# 设置构建时环境变量
|
||||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
ARG VITE_APP_TITLE="宏量点云处理软件"
|
||||||
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
||||||
|
|
||||||
# 构建应用
|
# 构建应用
|
||||||
|
|||||||
259
alg/00_算法交付与输出规范/README.md
Normal file
259
alg/00_算法交付与输出规范/README.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal file
27
alg/00_算法交付与输出规范/examples/invalid-legacy-result.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
115
alg/00_算法交付与输出规范/result.schema.json
Normal file
115
alg/00_算法交付与输出规范/result.schema.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
alg/00_算法交付与输出规范/validate_result.py
Executable file
136
alg/00_算法交付与输出规范/validate_result.py
Executable 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())
|
||||||
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal file
45
alg/00_算法交付与输出规范/算法工程师通知模板.md
Normal 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`;测量结果字段发生冲突时,以新规范为准。
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
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 { toast } from 'sonner';
|
||||||
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
||||||
|
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
|
||||||
|
|
||||||
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
'train-measurement',
|
'train-measurement',
|
||||||
@@ -130,6 +131,19 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
|
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({
|
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
|
||||||
onSuccess: ({ cancelledIds }) => {
|
onSuccess: ({ cancelledIds }) => {
|
||||||
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
|
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
|
||||||
@@ -151,10 +165,15 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
{ taskId: taskId ?? 0 },
|
{ taskId: taskId ?? 0 },
|
||||||
{ enabled: taskId != null, refetchInterval: 3000 },
|
{ 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 [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
||||||
|
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
|
||||||
|
|
||||||
// 首次拉到 plugins 后用默认值初始化每个表单
|
// 首次拉到 plugins 后用默认值初始化每个表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,13 +221,13 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
||||||
...demoMeasurementPluginIds,
|
...demoMeasurementPluginIds,
|
||||||
];
|
];
|
||||||
const demoAlgorithmCount = demoAllPluginIds.length;
|
|
||||||
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
|
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
|
||||||
|
const demoTaskCount = demoTaskCatalog?.total ?? 0;
|
||||||
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
|
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
|
||||||
const demoPluginIds = new Set(demoAllPluginIds);
|
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 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 demoFlowRunning = runningDemoResults.length > 0;
|
||||||
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
|
const demoFlowActive = demoFlowRunning || runDemoAll.isPending || runDemoSelected.isPending;
|
||||||
|
|
||||||
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||||
@@ -232,7 +251,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
taskId == null ? '请先选择检测任务'
|
taskId == null ? '请先选择检测任务'
|
||||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
||||||
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
||||||
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
||||||
: null;
|
: null;
|
||||||
const registrationBlockReason =
|
const registrationBlockReason =
|
||||||
baseRunBlockReason
|
baseRunBlockReason
|
||||||
@@ -257,6 +276,8 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
||||||
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
||||||
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
||||||
|
: demoCatalogLoading ? '正在加载任务目录'
|
||||||
|
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
|
||||||
: null);
|
: null);
|
||||||
const canRunDemoAll = demoBlockReason == null;
|
const canRunDemoAll = demoBlockReason == null;
|
||||||
const demoRouteHint =
|
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 = () => {
|
const handleCancelDemoAll = () => {
|
||||||
if (taskId == null || cancelDemoAll.isPending) return;
|
if (taskId == null || cancelDemoAll.isPending) return;
|
||||||
cancelDemoAll.mutate({ taskId });
|
cancelDemoAll.mutate({ taskId });
|
||||||
@@ -353,7 +388,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
<Cpu className="w-3.5 h-3.5" />
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
一键全流程演示
|
一键全流程演示
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
{demoFlowRunning && (
|
{demoFlowRunning && (
|
||||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
|
||||||
运行中 {runningDemoResults.length}
|
运行中 {runningDemoResults.length}
|
||||||
@@ -373,18 +408,32 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
停止全流程
|
停止全流程
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
type="button"
|
||||||
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
|
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}
|
disabled={!canRunDemoAll}
|
||||||
title={demoBlockReason ?? demoRunTitle}
|
onClick={handleRunDemoAll}
|
||||||
>
|
title={demoBlockReason ?? demoRunTitle}
|
||||||
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
>
|
||||||
运行全部 {demoAlgorithmCount} 个算法
|
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
</button>
|
运行全部任务({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>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DemoTaskSelectionDialog
|
||||||
|
open={demoTaskDialogOpen}
|
||||||
|
onOpenChange={setDemoTaskDialogOpen}
|
||||||
|
items={demoTaskCatalog?.items ?? []}
|
||||||
|
total={demoTaskCount}
|
||||||
|
loading={demoCatalogLoading}
|
||||||
|
pending={runDemoSelected.isPending}
|
||||||
|
runBlockReason={demoBlockReason}
|
||||||
|
onRun={handleRunDemoSelected}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState, useEffect, useMemo, useRef } from 'react';
|
|||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
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 { 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, statusClass, statusLabel, type Overlay3D } from './GenericResultView';
|
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, sortMeasurements, statusClass, type Overlay3D } from './GenericResultView';
|
||||||
import { aggregateDemoFlowResults, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
@@ -66,7 +66,19 @@ interface Props {
|
|||||||
}) => void;
|
}) => 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 };
|
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
|
||||||
|
|
||||||
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
||||||
@@ -112,10 +124,13 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
|||||||
|
|
||||||
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
||||||
const showDetails = displayMode === 'maximized';
|
const showDetails = displayMode === 'maximized';
|
||||||
|
const sortedItems = sortMeasurements(items);
|
||||||
return (
|
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>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<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-left 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>}
|
||||||
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
@@ -123,9 +138,23 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((m, i) => (
|
{sortedItems.map((m, i) => (
|
||||||
<tr key={i} className="border-t border-border/40">
|
<tr key={i} className="border-t border-border/40">
|
||||||
<td className="py-1">{m.name}</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 font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
||||||
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
@@ -141,6 +170,7 @@ function MeasurementTable({ items, displayMode }: { items: Measurement[]; displa
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +263,9 @@ type DemoFlowMeta = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
index?: number;
|
index?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
mode?: 'all' | 'selected';
|
||||||
|
selectedTaskNos?: number[];
|
||||||
|
selectedTaskCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||||
@@ -263,6 +296,11 @@ function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
|
|||||||
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
|
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
|
||||||
index: numberFromUnknown(data.demoFlowIndex),
|
index: numberFromUnknown(data.demoFlowIndex),
|
||||||
total: numberFromUnknown(data.demoFlowTotal),
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,11 +894,24 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
const cardKey = `${variant}:${group.runId}`;
|
const cardKey = `${variant}:${group.runId}`;
|
||||||
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
||||||
const artifactsExpanded = expandedFlowArtifactCards.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(
|
const aggregated = aggregateDemoFlowResults(
|
||||||
group.results,
|
group.results,
|
||||||
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
||||||
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
|
{ 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';
|
const showMeasurementDetails = variant === 'maximized';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -871,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" />}
|
{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" />
|
<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">
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
{completed}/{total} 完成
|
{completed}/{total} 完成
|
||||||
</span>
|
</span>
|
||||||
@@ -907,6 +958,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<table className="w-full text-[10px] border-collapse">
|
<table className="w-full text-[10px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground border-b border-border/60">
|
<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>
|
<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>}
|
||||||
{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>}
|
||||||
@@ -914,13 +966,30 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{aggregated.tableRows.map((row) => row.kind === 'algorithm' ? (
|
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
|
||||||
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
||||||
<td colSpan={showMeasurementDetails ? 4 : 2} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
<td colSpan={showMeasurementDetails ? 5 : 3} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
||||||
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>{row.measurement.name}</td>
|
<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">
|
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
|
||||||
{row.measurement.baseline != null
|
{row.measurement.baseline != null
|
||||||
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
||||||
@@ -937,7 +1006,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</td>}
|
</td>}
|
||||||
<td className="px-2 text-right whitespace-nowrap">
|
<td className="px-2 text-right whitespace-nowrap">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
||||||
{row.measurement.status === 'info' ? '参考' : statusLabel(row.measurement)}
|
{demoFlowStatusLabel(row.measurement)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal file
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ import { useState } from 'react';
|
|||||||
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface Measurement {
|
interface Measurement {
|
||||||
|
taskNo?: string;
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
position?: string;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
@@ -24,6 +27,8 @@ interface Measurement {
|
|||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
warningLabel?: string;
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorStats {
|
interface ErrorStats {
|
||||||
@@ -113,6 +118,24 @@ export function filterVisibleMeasurements<T extends { status?: unknown }>(measur
|
|||||||
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
|
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 {
|
export function formatValue(v: number | null): string {
|
||||||
if (v == null) return '—';
|
if (v == null) return '—';
|
||||||
if (!Number.isFinite(v)) return '—';
|
if (!Number.isFinite(v)) return '—';
|
||||||
@@ -174,7 +197,7 @@ export default function GenericResultView({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
const errorStats = data.errorStats ?? null;
|
const errorStats = data.errorStats ?? null;
|
||||||
const measurements = filterVisibleMeasurements(data.measurements ?? []);
|
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
|
||||||
const showMeasurementDetails = displayMode === 'maximized';
|
const showMeasurementDetails = displayMode === 'maximized';
|
||||||
const images = data.images ?? [];
|
const images = data.images ?? [];
|
||||||
const files = (data.files ?? []).map((f) =>
|
const files = (data.files ?? []).map((f) =>
|
||||||
@@ -216,9 +239,11 @@ export default function GenericResultView({
|
|||||||
{measurements.length > 0 && (
|
{measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">测量值</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>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<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-left 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>}
|
||||||
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
@@ -228,8 +253,23 @@ export default function GenericResultView({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{measurements.map((m, i) => (
|
{measurements.map((m, i) => (
|
||||||
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
|
<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}>
|
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||||||
{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>
|
||||||
{showMeasurementDetails && <td className="text-right font-mono">
|
{showMeasurementDetails && <td className="text-right font-mono">
|
||||||
{m.baseline != null
|
{m.baseline != null
|
||||||
@@ -254,6 +294,7 @@ export default function GenericResultView({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
176
client/src/components/InspectionObjectConfig.tsx
Normal file
176
client/src/components/InspectionObjectConfig.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1890,21 +1890,32 @@ export default function PointCloudViewer({
|
|||||||
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
|
setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' });
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// 用 /sample/batch 接口按 filePath 采样,最多 30 万点
|
// 通过受限的分析结果接口加载相对 PLY 路径,支持多文件合并采样
|
||||||
// 支持多文件(各面板 PLY),合并采样
|
|
||||||
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
|
const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0
|
||||||
? overlay.files
|
? overlay.files
|
||||||
: [overlay.file];
|
: [overlay.file];
|
||||||
const resp = await fetch('/api/files/sample/batch', {
|
const resp = await fetch('/api/files/sample/analysis', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`),
|
taskId,
|
||||||
|
resultId,
|
||||||
|
files: plyFiles,
|
||||||
totalPoints: 300_000,
|
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 hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||||
const stride = hasColor ? 7 : 4;
|
const stride = hasColor ? 7 : 4;
|
||||||
const buf = await resp.arrayBuffer();
|
const buf = await resp.arrayBuffer();
|
||||||
@@ -1950,7 +1961,10 @@ export default function PointCloudViewer({
|
|||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||||
console.warn('[PlyOverlay] load failed:', e);
|
console.warn('[PlyOverlay] load failed:', e);
|
||||||
setPlyOverlayData(null);
|
setPlyOverlayData(null);
|
||||||
setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' });
|
setPlyOverlayStatus({
|
||||||
|
status: 'error',
|
||||||
|
message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -171,10 +171,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
|
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'}`}
|
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
|
||||||
onClick={() => {
|
onClick={() => toggleSeries(series.id)}
|
||||||
toggleSeries(series.id);
|
|
||||||
onSelect({ seriesId: series.id, modelId: null, taskId: null });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{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" />}
|
{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" />
|
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { aggregateDemoFlowResults, type DemoFlowSourceResult } from './demoFlowAggregation';
|
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||||
|
|
||||||
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
|
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 } };
|
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
|
||||||
@@ -65,6 +65,64 @@ describe('aggregateDemoFlowResults', () => {
|
|||||||
expect(model.tableRows).toHaveLength(4);
|
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('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
||||||
const model = aggregateDemoFlowResults([
|
const model = aggregateDemoFlowResults([
|
||||||
result(10, 1, 'completed', {
|
result(10, 1, 'completed', {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
||||||
|
|
||||||
|
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
|
||||||
|
'前后端平移差',
|
||||||
|
'ICP fitness',
|
||||||
|
'配准 RMSE',
|
||||||
|
]);
|
||||||
|
|
||||||
export type DemoFlowSourceResult = {
|
export type DemoFlowSourceResult = {
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -96,10 +102,28 @@ function hasOverlay(value: PluginResultLike['overlay3d']) {
|
|||||||
return Boolean(value && value.kind !== 'none');
|
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(
|
export function aggregateDemoFlowResults(
|
||||||
results: DemoFlowSourceResult[],
|
results: DemoFlowSourceResult[],
|
||||||
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||||
options: { includeAlgorithmGroups?: boolean } = {},
|
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
|
||||||
): DemoFlowAggregation {
|
): DemoFlowAggregation {
|
||||||
const sorted = results
|
const sorted = results
|
||||||
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
||||||
@@ -108,12 +132,18 @@ export function aggregateDemoFlowResults(
|
|||||||
const tableRows: DemoFlowTableRow[] = [];
|
const tableRows: DemoFlowTableRow[] = [];
|
||||||
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
||||||
const statusNotices: DemoFlowStatusNotice[] = [];
|
const statusNotices: DemoFlowStatusNotice[] = [];
|
||||||
|
const selectedTaskNos = options.selectedTaskNos
|
||||||
|
? new Set(options.selectedTaskNos.map(String))
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const { result, index } of sorted) {
|
for (const { result, index } of sorted) {
|
||||||
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
||||||
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
||||||
const algorithmName = getAlgorithmName(result);
|
const algorithmName = getAlgorithmName(result);
|
||||||
const measurements = filterVisibleMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
|
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) {
|
if (measurements.length > 0 && options.includeAlgorithmGroups) {
|
||||||
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { trpc } from '@/lib/trpc';
|
|||||||
import {
|
import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
Users,
|
||||||
Box,
|
|
||||||
Cloud,
|
Cloud,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -278,25 +277,24 @@ export default function Home() {
|
|||||||
|
|
||||||
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
|
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (selection.modelId == null) {
|
|
||||||
return (
|
|
||||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
<VehicleTree selection={selection} onSelect={setSelection} />
|
||||||
<div className="h-px bg-border my-1" />
|
{selection.modelId != null && (
|
||||||
<TaskManager
|
<>
|
||||||
modelId={selection.modelId}
|
<div className="h-px bg-border my-1" />
|
||||||
activeTaskId={selection.taskId}
|
<TaskManager
|
||||||
onSelectTask={taskId => setSelection(s => ({ ...s, taskId }))}
|
modelId={selection.modelId}
|
||||||
onLoadPointClouds={handleLoadPointClouds}
|
activeTaskId={selection.taskId}
|
||||||
onPcFilesSelectionChange={setSelectedPcFileIds}
|
onSelectTask={taskId => setSelection(s => ({ ...s, taskId }))}
|
||||||
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
onLoadPointClouds={handleLoadPointClouds}
|
||||||
onSelectTaskCad={handleSelectCad}
|
onPcFilesSelectionChange={setSelectedPcFileIds}
|
||||||
/>
|
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
||||||
</>
|
onSelectTaskCad={handleSelectCad}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,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">
|
<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-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
|
<h1 className="text-sm font-semibold">宏量点云处理软件</h1>
|
||||||
<Box className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeModel && (
|
{activeModel && (
|
||||||
@@ -475,7 +470,7 @@ export default function Home() {
|
|||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* 左侧面板 */}
|
{/* 左侧面板 */}
|
||||||
{showLeftPanel && (
|
{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()}
|
{renderLeftPanel()}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
|
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
||||||
- S3_BUCKET=pointcloud-data
|
- S3_BUCKET=pointcloud-data
|
||||||
- S3_REGION=us-east-1
|
- S3_REGION=us-east-1
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
# 数据目录路径
|
# 数据目录路径
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
- ALG_ROOT=/alg
|
- ALG_ROOT=/alg
|
||||||
# 应用配置
|
# 应用配置
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ MINIO_ROOT_PASSWORD=minioadmin123
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 应用标题
|
# 应用标题
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 可选配置
|
## 可选配置
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 算法集成规范(Route B · 通用接口接入)
|
# 算法集成规范(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`
|
## 4. 输出契约 — `result.json`
|
||||||
|
|
||||||
|
以下示例仅说明平台可接收的基础结构。新交付算法必须同时符合
|
||||||
|
[`alg/00_算法交付与输出规范/README.md`](../alg/00_算法交付与输出规范/README.md),
|
||||||
|
并为每个正式测量项提供 `taskNo`、`metricCode` 等结构化字段;算法中间量不得放入
|
||||||
|
`measurements`。
|
||||||
|
|
||||||
算法在 `outDir/result.json` 写最终结果:
|
算法在 `outDir/result.json` 写最终结果:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
|
|||||||
33
drizzle/0017_inspection_objects.sql
Normal file
33
drizzle/0017_inspection_objects.sql
Normal 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`);
|
||||||
@@ -50,6 +50,25 @@ export const vehicleModels = mysqlTable("vehicle_models", {
|
|||||||
export type VehicleModel = typeof vehicleModels.$inferSelect;
|
export type VehicleModel = typeof vehicleModels.$inferSelect;
|
||||||
export type InsertVehicleModel = typeof vehicleModels.$inferInsert;
|
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 文件 ─────────────────────────────────────────────────────────────
|
||||||
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
|
// 一个车型可关联多个 CAD 文件(车头、车身、转向架等),低频变更
|
||||||
|
|
||||||
@@ -60,12 +79,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
|
|||||||
format: varchar("format", { length: 32 }).notNull(),
|
format: varchar("format", { length: 32 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).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(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
||||||
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||||
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
||||||
|
|
||||||
@@ -75,6 +103,8 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).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(),
|
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||||
pointCount: bigint("pointCount", { mode: "number" }),
|
pointCount: bigint("pointCount", { mode: "number" }),
|
||||||
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||||
@@ -113,6 +143,7 @@ export type InsertTaskCadFile = typeof taskCadFiles.$inferInsert;
|
|||||||
export const inspectionTasks = mysqlTable("inspection_tasks", {
|
export const inspectionTasks = mysqlTable("inspection_tasks", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
modelId: int("modelId").notNull(),
|
modelId: int("modelId").notNull(),
|
||||||
|
inspectionObjectId: int("inspectionObjectId"),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
inspector: varchar("inspector", { length: 100 }),
|
inspector: varchar("inspector", { length: 100 }),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
|||||||
60
scripts/migrate-inspection-objects.ts
Normal file
60
scripts/migrate-inspection-objects.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
|||||||
|
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
|
import { getPlugin, adaptersDir, fixturesDir } from './plugins/registry.js';
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
type InspectionObjectContext,
|
||||||
|
} from './measurementCompatibility.js';
|
||||||
import type {
|
import type {
|
||||||
PluginManifest,
|
PluginManifest,
|
||||||
PluginProgressEvent,
|
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(
|
async function writeInputJson(
|
||||||
@@ -124,6 +135,7 @@ async function writeInputJson(
|
|||||||
cadPath: string | null,
|
cadPath: string | null,
|
||||||
inputPointCloudFileIds: number[],
|
inputPointCloudFileIds: number[],
|
||||||
inputCadFileIds: number[],
|
inputCadFileIds: number[],
|
||||||
|
inspectionContext: InspectionObjectContext,
|
||||||
) {
|
) {
|
||||||
const inputJson = {
|
const inputJson = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -133,6 +145,7 @@ async function writeInputJson(
|
|||||||
taskId,
|
taskId,
|
||||||
workDir,
|
workDir,
|
||||||
outDir: workDir,
|
outDir: workDir,
|
||||||
|
context: inspectionContext,
|
||||||
inputs: {
|
inputs: {
|
||||||
pointCloudPaths,
|
pointCloudPaths,
|
||||||
pointCloudFileIds: inputPointCloudFileIds,
|
pointCloudFileIds: inputPointCloudFileIds,
|
||||||
@@ -188,6 +201,10 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
|
|||||||
'demoFlowSourcePointCloudFileId',
|
'demoFlowSourcePointCloudFileId',
|
||||||
'demoFlowCadFileId',
|
'demoFlowCadFileId',
|
||||||
'demoFlowCancelledAt',
|
'demoFlowCancelledAt',
|
||||||
|
'demoFlowMode',
|
||||||
|
'demoFlowSelectedTaskNos',
|
||||||
|
'demoFlowSelectedTaskCount',
|
||||||
|
'demoFlowPluginTaskNos',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||||
@@ -330,7 +347,7 @@ export async function runAlgorithmJob(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
||||||
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
|
const { pointCloudRecords, pointCloudPaths, cadPath, inspectionContext } = await gatherInputs(
|
||||||
taskId,
|
taskId,
|
||||||
manifest,
|
manifest,
|
||||||
pointCloudFileIds,
|
pointCloudFileIds,
|
||||||
@@ -349,6 +366,7 @@ export async function runAlgorithmJob(
|
|||||||
cadPath,
|
cadPath,
|
||||||
inputPointCloudFileIds,
|
inputPointCloudFileIds,
|
||||||
inputCadFileIds,
|
inputCadFileIds,
|
||||||
|
inspectionContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
let exitCode: number | null = 0;
|
let exitCode: number | null = 0;
|
||||||
@@ -367,6 +385,28 @@ export async function runAlgorithmJob(
|
|||||||
: 'error';
|
: 'error';
|
||||||
|
|
||||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
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 = {
|
const resultData: PluginResultData = {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -378,12 +418,12 @@ export async function runAlgorithmJob(
|
|||||||
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
// 把 result.json 内容平展到 resultData 里,方便前端直接使用
|
||||||
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
schemaVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||||
message: result?.message,
|
message: result?.message,
|
||||||
measurements: result?.measurements,
|
measurements: normalizedMeasurements,
|
||||||
errorStats: result?.errorStats,
|
errorStats: result?.errorStats,
|
||||||
overlay3d: result?.overlay3d,
|
overlay3d: result?.overlay3d,
|
||||||
images: result?.images,
|
images: result?.images,
|
||||||
files: result?.files,
|
files: result?.files,
|
||||||
extra: result?.extra,
|
extra: normalized.extra,
|
||||||
error: finalStatus === 'error'
|
error: finalStatus === 'error'
|
||||||
? result?.message ?? `子进程退出码 ${exitCode}`
|
? result?.message ?? `子进程退出码 ${exitCode}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -393,7 +433,7 @@ export async function runAlgorithmJob(
|
|||||||
await db.updateTaskAnalysisResult(resultId, {
|
await db.updateTaskAnalysisResult(resultId, {
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
errorStats: result?.errorStats ?? null,
|
errorStats: result?.errorStats ?? null,
|
||||||
measurements: result?.measurements ?? null,
|
measurements: normalizedMeasurements ?? null,
|
||||||
resultData,
|
resultData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
126
server/analysisPlySample.test.ts
Normal file
126
server/analysisPlySample.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
312
server/db.ts
312
server/db.ts
@@ -6,7 +6,9 @@ import {
|
|||||||
users, InsertUser,
|
users, InsertUser,
|
||||||
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
||||||
vehicleModels, InsertVehicleModel, VehicleModel,
|
vehicleModels, InsertVehicleModel, VehicleModel,
|
||||||
|
inspectionObjects, InsertInspectionObject, InspectionObject,
|
||||||
modelCadFiles, InsertModelCadFile, ModelCadFile,
|
modelCadFiles, InsertModelCadFile, ModelCadFile,
|
||||||
|
modelCadInspectionObjects,
|
||||||
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
|
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
|
||||||
taskCadFiles, InsertTaskCadFile, TaskCadFile,
|
taskCadFiles, InsertTaskCadFile, TaskCadFile,
|
||||||
inspectionTasks, InsertInspectionTask, InspectionTask,
|
inspectionTasks, InsertInspectionTask, InspectionTask,
|
||||||
@@ -206,6 +208,192 @@ export async function deleteVehicleModel(id: number): Promise<void> {
|
|||||||
await db.delete(vehicleModels).where(eq(vehicleModels.id, id));
|
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 文件 ─────────────────────────────────────────────────────────────
|
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createModelCadFile(data: InsertModelCadFile): Promise<ModelCadFile> {
|
export async function createModelCadFile(data: InsertModelCadFile): Promise<ModelCadFile> {
|
||||||
@@ -224,6 +412,18 @@ export async function listModelCadFiles(modelId: number): Promise<ModelCadFile[]
|
|||||||
.orderBy(desc(modelCadFiles.createdAt));
|
.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> {
|
export async function getModelCadFileById(id: number): Promise<ModelCadFile | undefined> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
if (!db) return undefined;
|
||||||
@@ -234,6 +434,16 @@ export async function getModelCadFileById(id: number): Promise<ModelCadFile | un
|
|||||||
export async function deleteModelCadFile(id: number): Promise<void> {
|
export async function deleteModelCadFile(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
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));
|
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,9 +452,16 @@ export async function deleteModelCadFile(id: number): Promise<void> {
|
|||||||
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
|
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
const result = await db.insert(modelPointcloudFiles).values(data);
|
return db.transaction(async tx => {
|
||||||
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
|
if (data.inspectionObjectId != null) {
|
||||||
return row;
|
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>>();
|
const modelMetadataBackfills = new Map<number, Promise<ModelPointcloudFile>>();
|
||||||
@@ -283,6 +500,26 @@ export async function listModelPointcloudFiles(modelId: number): Promise<ModelPo
|
|||||||
return Promise.all(rows.map(backfillModelPointcloudMetadata));
|
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> {
|
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
if (!db) return undefined;
|
||||||
@@ -323,6 +560,15 @@ export async function updateModelPointcloudFileName(
|
|||||||
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
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));
|
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +626,9 @@ export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds:
|
|||||||
if (source.modelId !== task.modelId) {
|
if (source.modelId !== task.modelId) {
|
||||||
throw new Error("Cannot link CAD file from another model");
|
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({
|
const record = await createTaskCadFile({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -407,6 +656,49 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
|
|||||||
return row;
|
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> {
|
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
if (!db) return undefined;
|
||||||
@@ -482,6 +774,17 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
|
|||||||
await db.update(inspectionTasks).set(data).where(eq(inspectionTasks.id, id));
|
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> {
|
export async function deleteInspectionTask(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
@@ -631,6 +934,9 @@ export async function linkTaskPointcloudFilesFromModel(
|
|||||||
if (source.modelId !== task.modelId) {
|
if (source.modelId !== task.modelId) {
|
||||||
throw new Error("Cannot link pointcloud file from another model");
|
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({
|
const record = await createTaskPointcloudFile({
|
||||||
taskId,
|
taskId,
|
||||||
|
|||||||
84
server/demoFlow.test.ts
Normal file
84
server/demoFlow.test.ts
Normal 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
120
server/demoFlow.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,10 +3,15 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import * as db from './db';
|
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 { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||||
import { authenticateRequest } from './_core/auth.js';
|
import { authenticateRequest } from './_core/auth.js';
|
||||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from './measurementCompatibility.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -88,7 +93,19 @@ function formatFileSize(bytes: number): string {
|
|||||||
|
|
||||||
function safeResolve(base: string, filename: string): string | null {
|
function safeResolve(base: string, filename: string): string | null {
|
||||||
const resolved = path.resolve(base, decodeURIComponent(filename));
|
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 文件上传 ────────────────────────────────────────────────────────
|
// ─── 车型 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||||
@@ -97,8 +114,14 @@ function safeResolve(base: string, filename: string): string | null {
|
|||||||
|
|
||||||
router.post('/upload/cad/:modelId', requireFileWrite, 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);
|
const modelId = parseInt(req.params.modelId, 10);
|
||||||
if (!req.file || isNaN(modelId)) {
|
const inspectionObjectIds = String(req.body.inspectionObjectIds ?? '').split(',').map(Number).filter(Number.isFinite);
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
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;
|
const destPath = req.file.path;
|
||||||
@@ -110,6 +133,7 @@ router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'),
|
|||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
});
|
});
|
||||||
|
await db.setCadInspectionObjects(record.id, inspectionObjectIds);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -129,8 +153,14 @@ router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'),
|
|||||||
|
|
||||||
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, 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);
|
const modelId = parseInt(req.params.modelId, 10);
|
||||||
if (!req.file || isNaN(modelId)) {
|
const inspectionObjectId = Number(req.body.inspectionObjectId);
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
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 destPath = req.file.path;
|
||||||
@@ -140,6 +170,7 @@ router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.si
|
|||||||
name: req.file.originalname,
|
name: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
|
inspectionObjectId,
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -319,7 +350,6 @@ router.post('/sample/batch', requireFileRead, async (req, res) => {
|
|||||||
return res.send(empty);
|
return res.send(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
const budget = Math.min(totalPoints, 2000000);
|
|
||||||
const supportedExts = ['.ply', '.txt', '.xyz', '.pts'];
|
const supportedExts = ['.ply', '.txt', '.xyz', '.pts'];
|
||||||
|
|
||||||
const fileInfos = paths.map((fp) => ({
|
const fileInfos = paths.map((fp) => ({
|
||||||
@@ -334,66 +364,100 @@ router.post('/sample/batch', requireFileRead, async (req, res) => {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[FileRoutes] Batch sample: ${fileInfos.length} files ` +
|
`[FileRoutes] Batch sample: ${fileInfos.length} files ` +
|
||||||
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}`
|
`(${(totalBytes / 1e9).toFixed(2)} GB), budget=${totalPoints}`
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const quotaMap = new Map<string, number>();
|
const batch = await samplePointcloudFiles(supportedFiles.map((file) => file.filePath), totalPoints);
|
||||||
for (const f of supportedFiles) {
|
console.log(`[FileRoutes] Batch done: ${batch.pointCount} pts, ${(batch.buffer.length / 1e6).toFixed(1)} MB`);
|
||||||
const quota = totalBytes > 0
|
sendSampledPointcloudBatch(res, batch);
|
||||||
? 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 hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error)));
|
|
||||||
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 ?? Number.NaN, 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-Has-Error', hasError ? '1' : '0');
|
|
||||||
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
|
|
||||||
res.send(binBuf);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[FileRoutes] Batch sampling error:', err);
|
console.error('[FileRoutes] Batch sampling error:', err);
|
||||||
res.status(500).json({ error: 'Failed to sample files' });
|
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
|
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
|
||||||
|
|
||||||
@@ -503,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req,
|
|||||||
try {
|
try {
|
||||||
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
|
const result = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8'));
|
||||||
const existingRd = (row.resultData as Record<string, unknown>) ?? {};
|
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 = {
|
const mergedRd = {
|
||||||
...existingRd,
|
...existingRd,
|
||||||
message: result.message ?? existingRd.message,
|
message: result.message ?? existingRd.message,
|
||||||
measurements: result.measurements ?? existingRd.measurements,
|
measurements: normalized.measurements,
|
||||||
errorStats: result.errorStats ?? existingRd.errorStats,
|
errorStats: result.errorStats ?? existingRd.errorStats,
|
||||||
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
overlay3d: result.overlay3d ?? existingRd.overlay3d,
|
||||||
images: result.images ?? existingRd.images,
|
images: result.images ?? existingRd.images,
|
||||||
files: result.files ?? existingRd.files,
|
files: result.files ?? existingRd.files,
|
||||||
extra: result.extra ?? existingRd.extra,
|
extra: normalized.extra,
|
||||||
schemaVersion: result.schemaVersion ?? existingRd.schemaVersion,
|
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 });
|
res.json({ success: true, overlay3d: result.overlay3d });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: String(e) });
|
res.status(500).json({ error: String(e) });
|
||||||
|
|||||||
75
server/inspectionObject.test.ts
Normal file
75
server/inspectionObject.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,16 +5,10 @@ import type { TrpcContext } from "./_core/context";
|
|||||||
|
|
||||||
vi.mock("./db", () => ({
|
vi.mock("./db", () => ({
|
||||||
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
|
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
|
||||||
createInspectionTask: vi.fn(async (data) => ({
|
createInspectionTaskWithDefaults: vi.fn(async (data) => ({
|
||||||
id: 1,
|
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 },
|
||||||
modelId: data.modelId,
|
pointcloudFile: { id: 11, taskId: 1 },
|
||||||
name: data.name,
|
cadFile: { id: 12, taskId: 1 },
|
||||||
inspector: data.inspector ?? null,
|
|
||||||
notes: data.notes ?? null,
|
|
||||||
status: data.status,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
completedAt: null,
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -60,13 +54,15 @@ describe("inspection task router", () => {
|
|||||||
|
|
||||||
const result = await caller.inspectionTask.create({
|
const result = await caller.inspectionTask.create({
|
||||||
modelId: 1,
|
modelId: 1,
|
||||||
|
inspectionObjectId: 9,
|
||||||
name: " 首检任务 ",
|
name: " 首检任务 ",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.status).toBe("created");
|
expect(result.task.status).toBe("created");
|
||||||
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
|
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
|
||||||
expect(db.createInspectionTask).toHaveBeenCalledWith({
|
expect(db.createInspectionTaskWithDefaults).toHaveBeenCalledWith({
|
||||||
modelId: 1,
|
modelId: 1,
|
||||||
|
inspectionObjectId: 9,
|
||||||
name: "首检任务",
|
name: "首检任务",
|
||||||
inspector: null,
|
inspector: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -78,6 +74,7 @@ describe("inspection task router", () => {
|
|||||||
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
|
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
|
||||||
id: 1,
|
id: 1,
|
||||||
modelId: 1,
|
modelId: 1,
|
||||||
|
inspectionObjectId: 9,
|
||||||
name: "首检任务",
|
name: "首检任务",
|
||||||
inspector: null,
|
inspector: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -92,17 +89,18 @@ describe("inspection task router", () => {
|
|||||||
|
|
||||||
await expect(caller.inspectionTask.create({
|
await expect(caller.inspectionTask.create({
|
||||||
modelId: 1,
|
modelId: 1,
|
||||||
|
inspectionObjectId: 9,
|
||||||
name: "首检任务",
|
name: "首检任务",
|
||||||
})).rejects.toMatchObject({
|
})).rejects.toMatchObject({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "该车型下已存在同名检测任务。",
|
message: "该车型下已存在同名检测任务。",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(db.createInspectionTask).not.toHaveBeenCalled();
|
expect(db.createInspectionTaskWithDefaults).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
|
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",
|
code: "ER_DUP_ENTRY",
|
||||||
errno: 1062,
|
errno: 1062,
|
||||||
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
|
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
|
||||||
@@ -113,6 +111,7 @@ describe("inspection task router", () => {
|
|||||||
|
|
||||||
await expect(caller.inspectionTask.create({
|
await expect(caller.inspectionTask.create({
|
||||||
modelId: 1,
|
modelId: 1,
|
||||||
|
inspectionObjectId: 9,
|
||||||
name: "首检任务",
|
name: "首检任务",
|
||||||
})).rejects.toMatchObject({
|
})).rejects.toMatchObject({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
|
|||||||
134
server/measurementCompatibility.test.ts
Normal file
134
server/measurementCompatibility.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
261
server/measurementCompatibility.ts
Normal file
261
server/measurementCompatibility.ts
Normal 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>();
|
||||||
@@ -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_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))
|
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": "车钩面板距纵向中心的右偏转角",
|
"name": "车钩面板距纵向中心的右偏转角",
|
||||||
"value": round(offset_right, 6),
|
"value": round(offset_right, 6),
|
||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"baseline": 700.0,
|
"baseline": 700.0,
|
||||||
"tolerance": 1.0,
|
"tolerance": 1.0,
|
||||||
"status": _status_for(offset_right, 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": "车钩面板距参考面的右偏转角",
|
"name": "车钩面板距参考面的右偏转角",
|
||||||
"value": round(offset_ref, 6),
|
"value": round(offset_ref, 6),
|
||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"baseline": 375.0,
|
"baseline": 375.0,
|
||||||
"tolerance": 0.8,
|
"tolerance": 0.8,
|
||||||
"status": _status_for(offset_ref, 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",
|
"name": "最大 x 偏差 dx_y",
|
||||||
"value": dx_y,
|
"value": dx_y,
|
||||||
@@ -198,7 +228,7 @@ def main() -> int:
|
|||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"status": "info",
|
"status": "info",
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
|
|
||||||
payload_out = {
|
payload_out = {
|
||||||
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
||||||
|
|||||||
@@ -226,8 +226,17 @@ def main() -> int:
|
|||||||
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
||||||
if not result_template_path.is_file():
|
if not result_template_path.is_file():
|
||||||
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
|
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(
|
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),
|
data_root=str(data_root),
|
||||||
pointcloud_kind="noise",
|
pointcloud_kind="noise",
|
||||||
point_chunk_size=8192,
|
point_chunk_size=8192,
|
||||||
|
|||||||
24
server/plugins/adapters/test_demo_task_selection.py
Normal file
24
server/plugins/adapters/test_demo_task_selection.py
Normal 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()
|
||||||
@@ -19,6 +19,7 @@ v2.2 变更:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
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):
|
def _to_float(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -155,6 +168,13 @@ def main() -> int:
|
|||||||
if t.get("enabled", True)
|
if t.get("enabled", True)
|
||||||
}
|
}
|
||||||
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
|
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"
|
viz_dir = out_dir / "visualizations"
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
emit_progress(0.05, "加载挠度算法包 v1.2.1")
|
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)
|
pointcloud_path = first_pointcloud(payload)
|
||||||
if not pointcloud_path:
|
if not pointcloud_path:
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"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": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "profile",
|
"name": "profile",
|
||||||
|
|||||||
@@ -8,6 +8,29 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": false,
|
"requiresPointCloud": false,
|
||||||
"requiresCad": 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": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "useBatchDatasets",
|
"name": "useBatchDatasets",
|
||||||
|
|||||||
@@ -8,6 +8,25 @@
|
|||||||
"category": "尺寸测量",
|
"category": "尺寸测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"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",
|
"uiPanel": "train-measurement",
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"requiresCad": false,
|
||||||
|
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||||
|
"demoTasks": [
|
||||||
|
{ "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] }
|
||||||
|
],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "leftRoiPath",
|
"name": "leftRoiPath",
|
||||||
|
|||||||
@@ -68,6 +68,22 @@ export interface PluginRuntimeMock {
|
|||||||
|
|
||||||
export type PluginRuntime = PluginRuntimeProcess | 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) */
|
/** 插件 manifest(写在 server/plugins/manifests/*.json) */
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
|
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
|
||||||
@@ -89,6 +105,10 @@ export interface PluginManifest {
|
|||||||
requiresCad?: boolean;
|
requiresCad?: boolean;
|
||||||
/** 是否需要点云(默认 true) */
|
/** 是否需要点云(默认 true) */
|
||||||
requiresPointCloud?: boolean;
|
requiresPointCloud?: boolean;
|
||||||
|
/** “选择任务运行”模式的权威任务目录。 */
|
||||||
|
demoTasks?: PluginDemoTask[];
|
||||||
|
/** 选中任务传给适配器的方式。 */
|
||||||
|
demoSelection?: PluginDemoSelection;
|
||||||
runtime: PluginRuntime;
|
runtime: PluginRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,18 +125,30 @@ export interface PluginProgressEvent {
|
|||||||
|
|
||||||
/** 测量项(GenericResultView 渲染) */
|
/** 测量项(GenericResultView 渲染) */
|
||||||
export interface PluginMeasurement {
|
export interface PluginMeasurement {
|
||||||
|
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
|
||||||
|
taskNo?: string;
|
||||||
|
/** 跨版本稳定的测量项编码。 */
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** 同任务内的位置,例如左梁、右梁。 */
|
||||||
|
position?: string;
|
||||||
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
||||||
value: number | null;
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
/** 无法安全数值化的原始公差表达式。 */
|
/** 无法安全数值化的原始公差表达式。 */
|
||||||
toleranceLabel?: string;
|
toleranceLabel?: string;
|
||||||
|
/** 斜率类测量的基准长度(mm)。 */
|
||||||
|
baseline?: number;
|
||||||
/** pass / warning / fail / info */
|
/** pass / warning / fail / info */
|
||||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
warningLabel?: string;
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
|
/** 兼容层无法确定任务号时置为 true。 */
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
/** 兼容转换前的原始项目名,用于追溯。 */
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { samplePointcloudFile } from './pointcloudSample';
|
import { samplePointcloudFile, samplePointcloudFiles } from './pointcloudSample';
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
@@ -58,3 +58,32 @@ describe('samplePointcloudFile', () => {
|
|||||||
expect(points.map((point) => point.error)).toEqual([0.25, -0.5]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ 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 文本点云行 */
|
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
|
||||||
export async function randomSeekSample(
|
export async function randomSeekSample(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -356,3 +364,70 @@ export async function samplePointcloudFile(
|
|||||||
// .txt / .xyz / .pts / etc. — existing text line sampler
|
// .txt / .xyz / .pts / etc. — existing text line sampler
|
||||||
return randomSeekSample(filePath, fileSize, k);
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
|
|||||||
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
||||||
import { stepParseCacheDir } from "./stepParse.js";
|
import { stepParseCacheDir } from "./stepParse.js";
|
||||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||||
|
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
|
||||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from "./measurementCompatibility.js";
|
||||||
import {
|
import {
|
||||||
clearLoginFailures,
|
clearLoginFailures,
|
||||||
createSessionToken,
|
createSessionToken,
|
||||||
@@ -91,6 +97,7 @@ async function validateRegistrationRunInput(input: {
|
|||||||
if (!cad) {
|
if (!cad) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
||||||
}
|
}
|
||||||
|
await validateTaskInputCompatibility(input.taskId, pointcloud, cad);
|
||||||
|
|
||||||
const registeredChildren = pointcloudFiles
|
const registeredChildren = pointcloudFiles
|
||||||
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
|
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
|
||||||
@@ -104,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(
|
function latestRegisteredPointcloud(
|
||||||
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
|
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
|
||||||
sourcePointcloudFileId: number,
|
sourcePointcloudFileId: number,
|
||||||
@@ -284,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: {
|
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
sourcePointCloudFileId: number;
|
sourcePointCloudFileId: number;
|
||||||
cadFileId: number;
|
cadFileId: number;
|
||||||
registrationResultId: number;
|
registrationResultId: number;
|
||||||
measurementPluginIds: string[];
|
measurementJobs: DemoFlowMeasurementJob[];
|
||||||
demoFlowRunId: string;
|
demoFlowRunId: string;
|
||||||
|
metadata: DemoFlowMetadata;
|
||||||
}) {
|
}) {
|
||||||
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
||||||
if (registrationResult?.status !== "completed") {
|
if (registrationResult?.status !== "completed") {
|
||||||
@@ -306,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
|
for (let idx = 0; idx < input.measurementJobs.length; idx += 1) {
|
||||||
const pluginId = input.measurementPluginIds[idx];
|
const job = input.measurementJobs[idx];
|
||||||
|
const pluginId = job.pluginId;
|
||||||
const manifest = getPlugin(pluginId);
|
const manifest = getPlugin(pluginId);
|
||||||
await createAndEnqueuePluginJob({
|
await createAndEnqueuePluginJob({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
pluginId,
|
pluginId,
|
||||||
params: paramsForDemoPlugin(pluginId),
|
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
|
||||||
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
||||||
? [registered.id]
|
? [registered.id]
|
||||||
: [input.sourcePointCloudFileId],
|
: [input.sourcePointCloudFileId],
|
||||||
@@ -321,14 +365,86 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
|||||||
demoFlowRunId: input.demoFlowRunId,
|
demoFlowRunId: input.demoFlowRunId,
|
||||||
demoFlowRole: "measurement",
|
demoFlowRole: "measurement",
|
||||||
demoFlowIndex: idx + 2,
|
demoFlowIndex: idx + 2,
|
||||||
demoFlowTotal: input.measurementPluginIds.length + 1,
|
demoFlowTotal: input.measurementJobs.length + 1,
|
||||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
demoFlowCadFileId: input.cadFileId,
|
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({
|
export const appRouter = router({
|
||||||
system: systemRouter,
|
system: systemRouter,
|
||||||
|
|
||||||
@@ -581,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 文件 ─────────────────────────────────────────────────────────
|
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cadFile: router({
|
cadFile: router({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(z.object({ modelId: z.number() }))
|
.input(z.object({ modelId: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.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() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await db.deleteModelCadFile(input.id);
|
await db.deleteModelCadFile(input.id);
|
||||||
@@ -623,6 +811,14 @@ export const appRouter = router({
|
|||||||
return db.listModelPointcloudFiles(input.modelId);
|
return db.listModelPointcloudFiles(input.modelId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
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
|
rename: adminProcedure
|
||||||
.input(z.object({ id: z.number(), baseName: z.string() }))
|
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -640,7 +836,7 @@ export const appRouter = router({
|
|||||||
return db.updateModelPointcloudFileName(row.id, name);
|
return db.updateModelPointcloudFileName(row.id, name);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
delete: adminProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await db.deleteModelPointcloudFile(input.id);
|
await db.deleteModelPointcloudFile(input.id);
|
||||||
@@ -720,9 +916,23 @@ export const appRouter = router({
|
|||||||
return db.getInspectionTaskById(input.id);
|
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
|
create: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
modelId: z.number(),
|
modelId: z.number(),
|
||||||
|
inspectionObjectId: z.number(),
|
||||||
name: z.string().trim().min(1).max(255),
|
name: z.string().trim().min(1).max(255),
|
||||||
inspector: z.string().max(100).optional(),
|
inspector: z.string().max(100).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
@@ -734,8 +944,9 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await db.createInspectionTask({
|
return await db.createInspectionTaskWithDefaults({
|
||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
|
inspectionObjectId: input.inspectionObjectId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
inspector: input.inspector ?? null,
|
inspector: input.inspector ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
@@ -844,7 +1055,43 @@ export const appRouter = router({
|
|||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(z.object({ taskId: z.number() }))
|
.input(z.object({ taskId: z.number() }))
|
||||||
.query(async ({ input }) => {
|
.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
|
runAnalysis: protectedProcedure
|
||||||
@@ -1165,6 +1412,19 @@ export const appRouter = router({
|
|||||||
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
|
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
|
runDemoAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -1177,84 +1437,50 @@ export const appRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
||||||
const demoFlowRunId = randomUUID();
|
return startDemoFlow({
|
||||||
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({
|
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
pluginId: "vehicle-registration",
|
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
params: {},
|
cadFileId: input.cadFileId,
|
||||||
pointCloudFileIds: [input.sourcePointCloudFileId],
|
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
|
||||||
cadFileIds: [input.cadFileId],
|
metadata: { demoFlowMode: "all" },
|
||||||
resultData: {
|
});
|
||||||
demoFlowRunId,
|
}),
|
||||||
demoFlowRole: "registration",
|
|
||||||
demoFlowIndex: 1,
|
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
|
||||||
demoFlowTotal: measurementPluginIds.length + 1,
|
runDemoSelected: protectedProcedure
|
||||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
.input(z.object({
|
||||||
demoFlowCadFileId: input.cadFileId,
|
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 };
|
return { ...result, selectedTaskNos: selectedPlan.taskNos };
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
cancelDemoAll: protectedProcedure
|
cancelDemoAll: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user