Compare commits
16 Commits
6076063be0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e3dc7ebe21 | |||
| f879328562 | |||
| 4b9a2f4cd9 | |||
| 185dd36bf3 | |||
| ac1387debc | |||
| d33b280a93 | |||
| 9f4482bea1 | |||
| 29a69793db | |||
| 3db84a7fb2 | |||
| 66c7dd1b65 | |||
| 9262390037 | |||
| 33c5fb9b9c | |||
| 752ea3f944 | |||
| 8555aa2f2e | |||
| 683e4d6c07 | |||
| c1d4c1e4ce |
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,12 +33,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 设置构建时环境变量
|
# 设置构建时环境变量
|
||||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
ARG VITE_APP_TITLE="宏量点云处理软件"
|
||||||
ARG VITE_OAUTH_PORTAL_URL="disabled"
|
|
||||||
ARG VITE_APP_ID=""
|
|
||||||
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
||||||
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
|
|
||||||
ENV VITE_APP_ID=$VITE_APP_ID
|
|
||||||
|
|
||||||
# 构建应用
|
# 构建应用
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
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`;测量结果字段发生冲突时,以新规范为准。
|
||||||
@@ -322,6 +322,7 @@ class MeasurementPipeline:
|
|||||||
excel_output_path: str | None = None,
|
excel_output_path: str | None = None,
|
||||||
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
|
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
|
||||||
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
|
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
|
||||||
|
save_combined_visualization: bool = True,
|
||||||
no_visualize: bool = False,
|
no_visualize: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
batch_start_time = time.perf_counter()
|
batch_start_time = time.perf_counter()
|
||||||
@@ -443,7 +444,7 @@ class MeasurementPipeline:
|
|||||||
|
|
||||||
combined_visualization_output = None
|
combined_visualization_output = None
|
||||||
combined_pointclouds = []
|
combined_pointclouds = []
|
||||||
if combined_visualizations:
|
if save_combined_visualization and combined_visualizations:
|
||||||
combined_visualization_output = self._contour_combined_save_path(save_path)
|
combined_visualization_output = self._contour_combined_save_path(save_path)
|
||||||
self._save_contour_combined_pointcloud_stream(
|
self._save_contour_combined_pointcloud_stream(
|
||||||
combined_visualizations,
|
combined_visualizations,
|
||||||
@@ -627,6 +628,7 @@ class MeasurementPipeline:
|
|||||||
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
|
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
|
||||||
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
|
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
|
||||||
result_col = self._find_header_index(headers, ["测量结果"])
|
result_col = self._find_header_index(headers, ["测量结果"])
|
||||||
|
error_col = self._find_header_index(headers, ["误差结果"])
|
||||||
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
|
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
|
||||||
|
|
||||||
result_by_task = {
|
result_by_task = {
|
||||||
@@ -648,6 +650,7 @@ class MeasurementPipeline:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||||
|
output_row[error_col] = result_row.get("max")
|
||||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||||
table.append(output_row)
|
table.append(output_row)
|
||||||
used_task_ids.add(task_id)
|
used_task_ids.add(task_id)
|
||||||
@@ -659,6 +662,7 @@ class MeasurementPipeline:
|
|||||||
output_row = [""] * len(headers)
|
output_row = [""] * len(headers)
|
||||||
output_row[task_col] = task_id
|
output_row[task_col] = task_id
|
||||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||||
|
output_row[error_col] = result_row.get("max")
|
||||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||||
table.append(output_row)
|
table.append(output_row)
|
||||||
|
|
||||||
@@ -729,7 +733,9 @@ class MeasurementPipeline:
|
|||||||
|
|
||||||
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
|
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
|
||||||
status = str(row.get("status", "")).strip()
|
status = str(row.get("status", "")).strip()
|
||||||
if status and status != "成功":
|
if status in {"成功", "部分成功", "失败"}:
|
||||||
|
return status
|
||||||
|
if status:
|
||||||
return "失败"
|
return "失败"
|
||||||
|
|
||||||
value = self._row_measurement_mean(row)
|
value = self._row_measurement_mean(row)
|
||||||
|
|||||||
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import Mock
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from pipeline import MeasurementPipeline
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedContourRetentionTest(TestCase):
|
||||||
|
def make_pipeline(self, root: Path) -> MeasurementPipeline:
|
||||||
|
mesh = root / "mesh.ply"
|
||||||
|
pointcloud = root / "points.ply"
|
||||||
|
mesh.touch()
|
||||||
|
pointcloud.touch()
|
||||||
|
|
||||||
|
pipeline = MeasurementPipeline.__new__(MeasurementPipeline)
|
||||||
|
pipeline._resolve_contour_task_inputs = Mock(return_value=[{
|
||||||
|
"task_id": "1",
|
||||||
|
"sample_id": "sample",
|
||||||
|
"cad_path": None,
|
||||||
|
"mesh_path": mesh,
|
||||||
|
"pointcloud_path": pointcloud,
|
||||||
|
}])
|
||||||
|
pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply")
|
||||||
|
pipeline.run_contour = Mock(return_value={
|
||||||
|
"distance_stats": SimpleNamespace(max=0.5),
|
||||||
|
"distances": np.array([0.1, 0.2]),
|
||||||
|
"pointcloud": object(),
|
||||||
|
})
|
||||||
|
pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply")
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream = Mock()
|
||||||
|
pipeline._should_visualize = Mock(return_value=False)
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
def test_skips_combined_file_when_disabled(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
pipeline = self.make_pipeline(Path(temp))
|
||||||
|
result = pipeline.run_contour_batch(
|
||||||
|
all_tasks=True,
|
||||||
|
save_path=temp,
|
||||||
|
save_combined_visualization=False,
|
||||||
|
no_visualize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result["combined_visualization_output"])
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream.assert_not_called()
|
||||||
|
|
||||||
|
def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
pipeline = self.make_pipeline(Path(temp))
|
||||||
|
result = pipeline.run_contour_batch(
|
||||||
|
all_tasks=True,
|
||||||
|
save_path=temp,
|
||||||
|
save_combined_visualization=True,
|
||||||
|
no_visualize=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply")
|
||||||
|
pipeline._save_contour_combined_pointcloud_stream.assert_called_once()
|
||||||
@@ -173,6 +173,33 @@ def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
|
|||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def load_cad_mesh(cad_path, mesh_deflection=0.1):
|
||||||
|
"""根据后缀加载 CAD mesh:STEP 走 OCC 离散化,PLY 直接读取三角网格。"""
|
||||||
|
ext = os.path.splitext(cad_path)[1].lower()
|
||||||
|
|
||||||
|
if ext in (".step", ".stp"):
|
||||||
|
return step_to_open3d_mesh(cad_path, mesh_deflection=mesh_deflection)
|
||||||
|
|
||||||
|
if ext == ".ply":
|
||||||
|
print(f"[INFO] Loading CAD mesh from PLY: {cad_path}")
|
||||||
|
mesh = o3d.io.read_triangle_mesh(cad_path)
|
||||||
|
if len(mesh.vertices) == 0 or len(mesh.triangles) == 0:
|
||||||
|
raise RuntimeError(f"PLY CAD mesh 为空或读取失败: {cad_path}")
|
||||||
|
|
||||||
|
mesh.remove_duplicated_vertices()
|
||||||
|
mesh.remove_duplicated_triangles()
|
||||||
|
mesh.remove_degenerate_triangles()
|
||||||
|
mesh.compute_vertex_normals()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[OK] PLY CAD mesh loaded: {len(mesh.vertices)} vertices, "
|
||||||
|
f"{len(mesh.triangles)} triangles"
|
||||||
|
)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
raise RuntimeError(f"不支持的 CAD 格式: {cad_path}(仅支持 .step/.stp/.ply)")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 工具函数
|
# 工具函数
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -529,7 +556,7 @@ def translate_align_by_front_tail(
|
|||||||
cad_pcd = None
|
cad_pcd = None
|
||||||
|
|
||||||
if cad_pcd is None:
|
if cad_pcd is None:
|
||||||
cad_mesh = step_to_open3d_mesh(
|
cad_mesh = load_cad_mesh(
|
||||||
cad_path,
|
cad_path,
|
||||||
mesh_deflection=mesh_deflection
|
mesh_deflection=mesh_deflection
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import { Route, Switch } from "wouter";
|
import { Redirect, Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
import Login from "./pages/Login";
|
||||||
|
import ChangePassword from "./pages/ChangePassword";
|
||||||
|
import Users from "./pages/Users";
|
||||||
|
import { useAuth } from "./_core/hooks/useAuth";
|
||||||
|
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||||
|
|
||||||
|
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <DashboardLayoutSkeleton />;
|
||||||
|
if (!user) return <Redirect to="/login" />;
|
||||||
|
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
|
||||||
|
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
// make sure to consider if you need authentication for certain routes
|
// make sure to consider if you need authentication for certain routes
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={"/"} component={Home} />
|
<Route path="/login" component={Login} />
|
||||||
|
<Route path="/change-password" component={ChangePassword} />
|
||||||
|
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
|
||||||
|
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
|
|
||||||
const logoutMutation = trpc.auth.logout.useMutation({
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
await utils.auth.me.invalidate();
|
await utils.auth.me.invalidate();
|
||||||
}
|
}
|
||||||
}, [logoutMutation, utils]);
|
}, [logoutMutation, utils]);
|
||||||
|
|
||||||
const state = useMemo(() => {
|
const state = useMemo(() => {
|
||||||
localStorage.setItem(
|
|
||||||
"manus-runtime-user-info",
|
|
||||||
JSON.stringify(meQuery.data)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
user: meQuery.data ?? null,
|
user: meQuery.data ?? null,
|
||||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||||
isAuthenticated: Boolean(meQuery.data),
|
isAuthenticated: Boolean(meQuery.data),
|
||||||
|
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
|
||||||
|
isSuperAdmin: meQuery.data?.role === "super_admin",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
meQuery.data,
|
meQuery.data,
|
||||||
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname === redirectPath) return;
|
if (window.location.pathname === redirectPath) return;
|
||||||
|
|
||||||
window.location.href = redirectPath
|
window.location.href = redirectPath;
|
||||||
}, [
|
}, [
|
||||||
redirectOnUnauthenticated,
|
redirectOnUnauthenticated,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
|||||||
@@ -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,12 +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 || runDemoSelected.isPending;
|
||||||
|
|
||||||
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||||
@@ -231,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
|
||||||
@@ -256,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 =
|
||||||
@@ -336,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 });
|
||||||
@@ -352,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}
|
||||||
@@ -372,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>
|
||||||
@@ -490,7 +540,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
|
{/* 未配准提示:有点云文件且选了测量算法,但所选点云均未配准 */}
|
||||||
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && (
|
{selectedMeasIds.length > 0 && !hasRegisteredCloud && (taskPcFileCount ?? 0) > 0 && !demoFlowActive && (
|
||||||
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-[10px] text-amber-300">
|
<span className="text-[10px] text-amber-300">
|
||||||
@@ -522,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, sortMeasurements, statusClass, type Overlay3D } from './GenericResultView';
|
||||||
|
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
@@ -55,10 +57,28 @@ interface Props {
|
|||||||
/** 外部分析任务是否正在提交中 */
|
/** 外部分析任务是否正在提交中 */
|
||||||
externalPending?: boolean;
|
externalPending?: boolean;
|
||||||
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
|
||||||
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
onViewPluginOverlay?: (args: {
|
||||||
|
taskId: number;
|
||||||
|
resultId: number;
|
||||||
|
overlay: Overlay3D;
|
||||||
|
inputPointCloudFileIds?: number[];
|
||||||
|
inputCadFileIds?: number[];
|
||||||
|
}) => 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 }) {
|
||||||
@@ -102,23 +122,41 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeasurementTable({ items }: { items: Measurement[] }) {
|
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
||||||
|
const showDetails = displayMode === 'maximized';
|
||||||
|
const sortedItems = sortMeasurements(items);
|
||||||
return (
|
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>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
<th className="text-right py-1 font-normal">结果</th>
|
<th className="text-right py-1 font-normal">结果</th>
|
||||||
</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">
|
||||||
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
|
{m.legacyUnmapped || !m.taskNo ? (
|
||||||
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
|
<span className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help" title={`旧算法项目尚未确认任务号:${m.legacyOriginalName ?? m.name}`}>
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : m.taskNo}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{m.name}
|
||||||
|
{m.position && (
|
||||||
|
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
|
||||||
|
{m.position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
||||||
|
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
||||||
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
||||||
@@ -132,6 +170,7 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,8 +263,14 @@ 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_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
|
||||||
|
|
||||||
type AnalysisListItem =
|
type AnalysisListItem =
|
||||||
| { kind: 'result'; result: AnalysisResultRow }
|
| { kind: 'result'; result: AnalysisResultRow }
|
||||||
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
|
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
|
||||||
@@ -251,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +358,14 @@ function getDemoFlowTotal(results: AnalysisResultRow[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
||||||
|
const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState<Set<string>>(new Set());
|
||||||
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
||||||
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
||||||
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
||||||
@@ -393,10 +448,42 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResultCard(r: AnalysisResultRow, extraClassName = '') {
|
function toggleFlowResultCard(key: string) {
|
||||||
|
setCollapsedFlowResultCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactCard(key: string) {
|
||||||
|
setExpandedFlowArtifactCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactGroup(key: string) {
|
||||||
|
setExpandedFlowArtifactGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactExtra(key: string) {
|
||||||
|
setExpandedFlowArtifactExtras((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultCard(r: AnalysisResultRow, variant: ResultListVariant, extraClassName = '') {
|
||||||
const expanded = expandedIds.has(r.id);
|
const expanded = expandedIds.has(r.id);
|
||||||
const errorStats = r.errorStats as ErrorStats | null;
|
const errorStats = r.errorStats as ErrorStats | null;
|
||||||
const measurements = r.measurements as Measurement[] | null;
|
const measurements = filterVisibleMeasurements((r.measurements as Measurement[] | null) ?? []);
|
||||||
const resultTitle = getResultTitle(r, pluginNameById);
|
const resultTitle = getResultTitle(r, pluginNameById);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -437,7 +524,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<Play className="w-3 h-3 text-primary" />
|
<Play className="w-3 h-3 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isCancellableStatus(r.status) && (
|
{canWrite && isCancellableStatus(r.status) && (
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
||||||
title="停止"
|
title="停止"
|
||||||
@@ -447,13 +534,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<Square className="w-3 h-3" />
|
<Square className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{canWrite && <button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
||||||
@@ -478,10 +565,10 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||||
{measurements && 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>
|
||||||
<MeasurementTable items={measurements} />
|
<MeasurementTable items={measurements} displayMode={variant} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -669,6 +756,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
pluginId={r.pluginId ?? undefined}
|
pluginId={r.pluginId ?? undefined}
|
||||||
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
||||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||||
|
displayMode={variant}
|
||||||
onViewOverlay={onViewPluginOverlay}
|
onViewOverlay={onViewPluginOverlay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -677,20 +765,154 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFlowStatusNotice(notice: DemoFlowStatusNotice) {
|
||||||
|
const progress = notice.progress != null ? ` ${Math.round(notice.progress * 100)}%` : '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notice.resultId}
|
||||||
|
className={`rounded px-2 py-1 text-[10px] ${notice.status === 'error' ? 'bg-red-950/30 text-red-300' : notice.status === 'cancelled' ? 'bg-amber-950/30 text-amber-300' : 'bg-secondary/50 text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{notice.algorithmName}</span>
|
||||||
|
<span> · {getStatusLabel(notice.status)}{progress}</span>
|
||||||
|
{notice.detail && <span> · {notice.detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowArtifactGroup(artifact: DemoFlowArtifactGroup, cardKey: string) {
|
||||||
|
const overlay = normalizeOverlay3D(artifact.overlay);
|
||||||
|
const inputPointCloudFileIds = numberArrayFromUnknown(artifact.inputPointCloudFileIds);
|
||||||
|
const inputCadFileIds = numberArrayFromUnknown(artifact.inputCadFileIds);
|
||||||
|
const groupKey = `${cardKey}:${artifact.resultId}`;
|
||||||
|
const groupExpanded = expandedFlowArtifactGroups.has(groupKey);
|
||||||
|
const extraExpanded = expandedFlowArtifactExtras.has(groupKey);
|
||||||
|
const artifactCount = artifact.files.length + artifact.images.length
|
||||||
|
+ (overlay.kind !== 'none' ? 1 : 0)
|
||||||
|
+ (artifact.extra && Object.keys(artifact.extra).length > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={artifact.resultId} className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactGroup(groupKey)}
|
||||||
|
>
|
||||||
|
{groupExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-violet-400" />
|
||||||
|
<span className="text-[11px] font-medium text-foreground flex-1">{artifact.algorithmName}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{artifactCount} 项</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{groupExpanded && <div className="border-t border-border/40 p-2 space-y-2">
|
||||||
|
|
||||||
|
{artifact.files.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">报表下载</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{artifact.files.map((file) => (
|
||||||
|
<a
|
||||||
|
key={file.path}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(file.path)}`}
|
||||||
|
download
|
||||||
|
className="industrial-card p-1.5 flex items-center gap-1.5 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={file.path}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-3 h-3 flex-shrink-0 text-green-500" />
|
||||||
|
<span className="truncate">{file.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.images.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{artifact.images.map((image) => (
|
||||||
|
<a
|
||||||
|
key={image}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(image)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="industrial-card p-1 flex items-center gap-1 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={image}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{image}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overlay.kind !== 'none' && onViewPluginOverlay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
|
onClick={() => onViewPluginOverlay({
|
||||||
|
taskId,
|
||||||
|
resultId: artifact.resultId,
|
||||||
|
overlay,
|
||||||
|
inputPointCloudFileIds,
|
||||||
|
inputCadFileIds,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
在 3D 中查看叠加层
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.extra && Object.keys(artifact.extra).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => toggleFlowArtifactExtra(groupKey)}
|
||||||
|
>
|
||||||
|
{extraExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
原始字段
|
||||||
|
</button>
|
||||||
|
{extraExpanded && (
|
||||||
|
<pre className="industrial-card p-2 mt-1 text-[9px] font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(artifact.extra, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
||||||
const expanded = expandedFlowIds.has(group.runId);
|
const expanded = expandedFlowIds.has(group.runId);
|
||||||
const status = getDemoFlowStatus(group.results);
|
const status = getDemoFlowStatus(group.results);
|
||||||
const total = getDemoFlowTotal(group.results);
|
const total = getDemoFlowTotal(group.results);
|
||||||
const completed = group.results.filter((r) => r.status === 'completed').length;
|
const completed = group.results.filter((r) => r.status === 'completed').length;
|
||||||
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
||||||
const runningItems = group.results
|
const cardKey = `${variant}:${group.runId}`;
|
||||||
.filter((r) => r.status === 'processing' || r.status === 'pending')
|
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
||||||
.slice(0, 2)
|
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
|
||||||
.map((r) => {
|
const flowMeta = group.results.map(readDemoFlowMeta).find((meta) => meta?.mode) ?? null;
|
||||||
const data = asRecord(r.resultData);
|
const selectedTaskNos = flowMeta?.mode === 'selected' ? flowMeta.selectedTaskNos : undefined;
|
||||||
const progress = numberFromUnknown(data?.progress);
|
const selectedTaskCount = flowMeta?.selectedTaskCount ?? selectedTaskNos?.length ?? 0;
|
||||||
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
|
const flowTitle = flowMeta?.mode === 'selected'
|
||||||
});
|
? `全流程算法结果 · 自选 ${selectedTaskCount} 项`
|
||||||
|
: '全流程算法结果 · 全部任务';
|
||||||
|
const aggregated = aggregateDemoFlowResults(
|
||||||
|
group.results,
|
||||||
|
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
||||||
|
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS, selectedTaskNos },
|
||||||
|
);
|
||||||
|
const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS
|
||||||
|
? aggregated.tableRows
|
||||||
|
: sortMeasurements(aggregated.measurementRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
taskNo: row.measurement.taskNo,
|
||||||
|
position: row.measurement.position,
|
||||||
|
})));
|
||||||
|
const showMeasurementDetails = variant === 'maximized';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
||||||
@@ -700,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>
|
||||||
@@ -711,13 +933,110 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
||||||
{runningItems.length > 0 && (
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
<div className="text-[10px] text-muted-foreground truncate">
|
<button
|
||||||
运行中:{runningItems.join('、')}
|
type="button"
|
||||||
</div>
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
)}
|
onClick={() => toggleFlowResultCard(cardKey)}
|
||||||
<div className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
|
>
|
||||||
{group.results.map((result) => renderResultCard(result))}
|
{resultsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Ruler className="w-3.5 h-3.5 text-green-400" />
|
||||||
|
<span className="text-xs flex-1">算法结果</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.measurementRows.length} 项</span>
|
||||||
|
</button>
|
||||||
|
{resultsExpanded && (
|
||||||
|
<div className="border-t border-border/40">
|
||||||
|
{aggregated.statusNotices.length > 0 && (
|
||||||
|
<div className="space-y-1 p-2 pb-1">
|
||||||
|
{aggregated.statusNotices.map(renderFlowStatusNotice)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aggregated.tableRows.length === 0 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-center py-4">暂无测量结果</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[10px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b border-border/60">
|
||||||
|
<th className="text-left px-2 py-1.5 font-normal whitespace-nowrap">任务号</th>
|
||||||
|
<th className="text-left px-2 py-1.5 font-normal">项目</th>
|
||||||
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
|
||||||
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
|
||||||
|
<th className="text-right px-2 py-1.5 font-normal">结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedTableRows.map((row) => row.kind === 'algorithm' ? (
|
||||||
|
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
||||||
|
<td colSpan={showMeasurementDetails ? 5 : 3} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">
|
||||||
|
{row.measurement.legacyUnmapped || !row.measurement.taskNo ? (
|
||||||
|
<span
|
||||||
|
className="text-amber-400 underline decoration-dotted underline-offset-2 cursor-help"
|
||||||
|
title={`旧算法项目尚未确认任务号:${row.measurement.legacyOriginalName ?? row.measurement.name}`}
|
||||||
|
>
|
||||||
|
待确认
|
||||||
|
</span>
|
||||||
|
) : row.measurement.taskNo}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>
|
||||||
|
{row.measurement.name}
|
||||||
|
{row.measurement.position && (
|
||||||
|
<span className="ml-1.5 inline-flex rounded bg-secondary px-1 py-0.5 text-[9px] text-muted-foreground align-middle">
|
||||||
|
{row.measurement.position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
|
||||||
|
{row.measurement.baseline != null
|
||||||
|
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
||||||
|
: `${formatValue(row.measurement.value)} ${row.measurement.unit ?? ''}`}
|
||||||
|
</td>}
|
||||||
|
{showMeasurementDetails && <td className="px-2 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{row.measurement.toleranceLabel != null
|
||||||
|
? row.measurement.toleranceLabel
|
||||||
|
: row.measurement.tolerance != null
|
||||||
|
? row.measurement.baseline != null
|
||||||
|
? `${row.measurement.tolerance}/${row.measurement.baseline}mm`
|
||||||
|
: `±${row.measurement.tolerance}`
|
||||||
|
: '—'}
|
||||||
|
</td>}
|
||||||
|
<td className="px-2 text-right whitespace-nowrap">
|
||||||
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
||||||
|
{demoFlowStatusLabel(row.measurement)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactCard(cardKey)}
|
||||||
|
>
|
||||||
|
{artifactsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-xs flex-1">算法产物</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.artifactGroups.length} 组</span>
|
||||||
|
</button>
|
||||||
|
{artifactsExpanded && (
|
||||||
|
<div className="border-t border-border/40 p-2 space-y-3">
|
||||||
|
{aggregated.artifactGroups.length > 0
|
||||||
|
? aggregated.artifactGroups.map((artifact) => renderFlowArtifactGroup(artifact, cardKey))
|
||||||
|
: <div className="text-[10px] text-muted-foreground text-center py-3">暂无算法产物</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -731,7 +1050,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
{listItems.map((item) => (
|
{listItems.map((item) => (
|
||||||
item.kind === 'demoFlow'
|
item.kind === 'demoFlow'
|
||||||
? renderDemoFlowCard(item, variant)
|
? renderDemoFlowCard(item, variant)
|
||||||
: renderResultCard(item.result)
|
: renderResultCard(item.result, variant)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -752,36 +1071,44 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
>
|
>
|
||||||
<Maximize2 className="w-3 h-3" />
|
<Maximize2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* 产品策略:旧分析入口暂不在标题栏展示,保留实现便于后续恢复。
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
||||||
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
||||||
disabled={running}
|
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
||||||
title="误差统计分析"
|
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||||
>
|
<>
|
||||||
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
<button
|
||||||
{running ? '分析中…' : '误差分析'}
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
</button>
|
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
||||||
{onRunAssembly && (
|
disabled={running}
|
||||||
<button
|
title="误差统计分析"
|
||||||
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
>
|
||||||
onClick={onRunAssembly}
|
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
disabled={externalPending}
|
{running ? '分析中…' : '误差分析'}
|
||||||
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
|
</button>
|
||||||
>
|
{onRunAssembly && (
|
||||||
<Layers className="w-3 h-3" />
|
<button
|
||||||
装配检测
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
||||||
</button>
|
onClick={onRunAssembly}
|
||||||
)}
|
disabled={externalPending}
|
||||||
{onRunDeviation && (
|
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
|
||||||
<button
|
>
|
||||||
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
<Layers className="w-3 h-3" />
|
||||||
onClick={onRunDeviation}
|
装配检测
|
||||||
disabled={externalPending}
|
</button>
|
||||||
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
|
)}
|
||||||
>
|
{onRunDeviation && (
|
||||||
<Crosshair className="w-3 h-3" />
|
<button
|
||||||
偏差热力
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
|
||||||
</button>
|
onClick={onRunDeviation}
|
||||||
|
disabled={externalPending}
|
||||||
|
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
|
||||||
|
>
|
||||||
|
<Crosshair className="w-3 h-3" />
|
||||||
|
偏差热力
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -792,7 +1119,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
|
|
||||||
{!isLoading && results.length === 0 && (
|
{!isLoading && results.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
暂无分析结果,点击运行分析
|
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useAuth } from "@/_core/hooks/useAuth";
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
@@ -21,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getLoginUrl } from "@/const";
|
import { getLoginUrl } from "@/const";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
import { LayoutDashboard, PanelLeft, Users } from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
@@ -106,7 +98,6 @@ function DashboardLayoutContent({
|
|||||||
children,
|
children,
|
||||||
setSidebarWidth,
|
setSidebarWidth,
|
||||||
}: DashboardLayoutContentProps) {
|
}: DashboardLayoutContentProps) {
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
const { state, toggleSidebar } = useSidebar();
|
const { state, toggleSidebar } = useSidebar();
|
||||||
const isCollapsed = state === "collapsed";
|
const isCollapsed = state === "collapsed";
|
||||||
@@ -201,36 +192,6 @@ function DashboardLayoutContent({
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="p-3">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
<Avatar className="h-9 w-9 border shrink-0">
|
|
||||||
<AvatarFallback className="text-xs font-medium">
|
|
||||||
{user?.name?.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
|
||||||
<p className="text-sm font-medium truncate leading-none">
|
|
||||||
{user?.name || "-"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
|
||||||
{user?.email || "-"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={logout}
|
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Sign out</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
export function DashboardLayoutSkeleton() {
|
export function DashboardLayoutSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
|
|||||||
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<div className="flex items-center gap-3 px-2">
|
<div className="flex items-center gap-3 px-2">
|
||||||
<Skeleton className="h-8 w-8 rounded-md" />
|
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu items */}
|
{/* Menu items */}
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User profile area at bottom */}
|
{/* User profile area at bottom */}
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
<div className="absolute bottom-4 left-4 right-4">
|
||||||
<div className="flex items-center gap-3 px-1">
|
<div className="flex items-center gap-3 px-1">
|
||||||
<Skeleton className="h-9 w-9 rounded-full" />
|
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="bg-muted h-3 w-20" />
|
||||||
<Skeleton className="h-2 w-32" />
|
<Skeleton className="bg-muted h-2 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
|
|||||||
{/* Main content skeleton */}
|
{/* Main content skeleton */}
|
||||||
<div className="flex-1 p-4 space-y-4">
|
<div className="flex-1 p-4 space-y-4">
|
||||||
{/* Content blocks */}
|
{/* Content blocks */}
|
||||||
<Skeleton className="h-12 w-48 rounded-lg" />
|
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-64 rounded-xl" />
|
<Skeleton className="bg-muted h-64 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
211
client/src/components/DemoFlowOverlay.tsx
Normal file
211
client/src/components/DemoFlowOverlay.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
formatElapsedTime,
|
||||||
|
type DemoFlowState,
|
||||||
|
type DemoFlowStep,
|
||||||
|
type DemoFlowStepStatus,
|
||||||
|
} from '@/hooks/useDemoFlowState';
|
||||||
|
|
||||||
|
interface DemoFlowOverlayProps {
|
||||||
|
state: DemoFlowState;
|
||||||
|
pluginNameById?: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'processing':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
</div>);
|
||||||
|
case 'pending':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-slate-500/20 text-slate-400 flex items-center justify-center">
|
||||||
|
<Circle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'error':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'cancelled':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBorder(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'border-green-500/30 bg-green-500/5';
|
||||||
|
case 'processing': return 'border-blue-500/40 bg-blue-500/10 animate-pulse';
|
||||||
|
case 'pending': return 'border-slate-500/20 bg-slate-500/5';
|
||||||
|
case 'error': return 'border-red-500/30 bg-red-500/5';
|
||||||
|
case 'cancelled': return 'border-amber-500/30 bg-amber-500/5';
|
||||||
|
default: return 'border-border/40 bg-card/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLabel(step: DemoFlowStep, pluginNameById?: Map<string, string>): string {
|
||||||
|
if (step.role === 'registration') return '整车配准';
|
||||||
|
const name = step.pluginId ? (pluginNameById?.get(step.pluginId) ?? step.pluginId) : '测量算法';
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing': return '运行中';
|
||||||
|
case 'pending': return '排队中';
|
||||||
|
case 'completed': return '已完成';
|
||||||
|
case 'error': return '失败';
|
||||||
|
case 'cancelled': return '已停止';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
|
||||||
|
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const title = useMemo(
|
||||||
|
() => state.completedPointCount == null
|
||||||
|
? '点云数量暂不可用'
|
||||||
|
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
|
||||||
|
[state.completedPointCount],
|
||||||
|
);
|
||||||
|
const elapsedTime = formatElapsedTime(state.elapsedMs);
|
||||||
|
|
||||||
|
if (!state.active && state.overallStatus === 'idle') return null;
|
||||||
|
|
||||||
|
const isCollapsed = state.runId != null && collapsedRunIds.has(state.runId);
|
||||||
|
const setCurrentRunCollapsed = (collapsed: boolean) => {
|
||||||
|
if (state.runId == null) return;
|
||||||
|
setCollapsedRunIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (collapsed) next.add(state.runId!);
|
||||||
|
else next.delete(state.runId!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-72 max-w-[calc(100%-1.5rem)] items-center gap-3 border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-3 py-2.5 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[10px] font-medium" title={title}>{title}</div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
|
||||||
|
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
|
||||||
|
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="展开进度"
|
||||||
|
aria-label="展开进度"
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(false)}
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto mx-4 w-full max-w-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-5 shadow-2xl backdrop-blur-sm sm:px-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
|
{state.overallStatus === 'processing' ? (
|
||||||
|
<Scan className="w-4 h-4 text-primary animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<Cpu className="w-4 h-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="break-words text-sm font-medium">{title}</h3>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="收起进度"
|
||||||
|
aria-label="收起进度"
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(true)}
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总进度条 */}
|
||||||
|
<div className="h-1.5 w-full bg-secondary rounded-full overflow-hidden mb-5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤条 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{state.steps.map((step, idx) => {
|
||||||
|
const isCurrent = state.currentStep?.id === step.id;
|
||||||
|
const label = stepLabel(step, pluginNameById);
|
||||||
|
const showProgress = step.status === 'processing' && step.progress != null;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="relative flex items-start gap-3">
|
||||||
|
{idx !== state.steps.length - 1 && (
|
||||||
|
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 mt-0.5">{statusIcon(step.status)}</div>
|
||||||
|
<div className={`min-w-0 flex-1 rounded border p-2.5 ${statusBorder(step.status)} ${isCurrent ? 'ring-1 ring-primary/20' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground flex-shrink-0">
|
||||||
|
{step.status === 'processing' ? '运行中'
|
||||||
|
: step.status === 'pending' ? '排队中'
|
||||||
|
: step.status === 'completed' ? '已完成'
|
||||||
|
: step.status === 'error' ? '失败'
|
||||||
|
: step.status === 'cancelled' ? '已停止'
|
||||||
|
: step.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showProgress && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-500"
|
||||||
|
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
|
||||||
|
{Math.round((step.progress ?? 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.overallStatus === 'processing' && state.currentStep && (
|
||||||
|
<div className="mt-4 text-[10px] text-muted-foreground text-center">
|
||||||
|
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,8 +11,11 @@ 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;
|
||||||
value: number;
|
position?: string;
|
||||||
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
||||||
@@ -22,7 +25,10 @@ interface Measurement {
|
|||||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||||
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorStats {
|
interface ErrorStats {
|
||||||
@@ -38,7 +44,11 @@ interface ErrorStats {
|
|||||||
type Overlay3D =
|
type Overlay3D =
|
||||||
| { kind: 'none' }
|
| { kind: 'none' }
|
||||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||||
| { kind: 'ply'; file: string };
|
| { kind: 'ply'; file: string; files?: string[] };
|
||||||
|
|
||||||
|
type RawOverlay3D =
|
||||||
|
| Overlay3D
|
||||||
|
| { kind: 'vertexColors'; file?: string; url?: string; vertexCount?: number; dispMin?: number; dispMax?: number };
|
||||||
|
|
||||||
interface PluginResultLike {
|
interface PluginResultLike {
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
@@ -46,7 +56,9 @@ interface PluginResultLike {
|
|||||||
message?: string;
|
message?: string;
|
||||||
measurements?: Measurement[];
|
measurements?: Measurement[];
|
||||||
errorStats?: ErrorStats | null;
|
errorStats?: ErrorStats | null;
|
||||||
overlay3d?: Overlay3D;
|
overlay3d?: RawOverlay3D;
|
||||||
|
inputPointCloudFileIds?: unknown;
|
||||||
|
inputCadFileIds?: unknown;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
|
/** outDir 下的报表产物(相对路径,如 report.xlsx),前端展示下载入口 */
|
||||||
files?: (string | { name?: string; path?: string })[];
|
files?: (string | { name?: string; path?: string })[];
|
||||||
@@ -60,11 +72,19 @@ interface Props {
|
|||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
pluginName?: string;
|
pluginName?: string;
|
||||||
data: PluginResultLike;
|
data: PluginResultLike;
|
||||||
|
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
|
||||||
|
displayMode?: 'panel' | 'maximized';
|
||||||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||||||
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
onViewOverlay?: (overlay: {
|
||||||
|
taskId: number;
|
||||||
|
resultId: number;
|
||||||
|
overlay: Overlay3D;
|
||||||
|
inputPointCloudFileIds?: number[];
|
||||||
|
inputCadFileIds?: number[];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(s?: Measurement['status']) {
|
export function statusClass(s?: Measurement['status']) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return 'bg-green-900/40 text-green-400';
|
return 'bg-green-900/40 text-green-400';
|
||||||
@@ -77,12 +97,12 @@ function statusClass(s?: Measurement['status']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(m: Measurement) {
|
export function statusLabel(m: Measurement) {
|
||||||
switch (m.status) {
|
switch (m.status) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return m.passLabel ?? '合格';
|
return m.passLabel ?? '合格';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return '警告';
|
return m.warningLabel ?? '警告';
|
||||||
case 'fail':
|
case 'fail':
|
||||||
return m.failLabel ?? '超差';
|
return m.failLabel ?? '超差';
|
||||||
default:
|
default:
|
||||||
@@ -90,29 +110,102 @@ function statusLabel(m: Measurement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(v: number): string {
|
export function isReferenceMeasurement(measurement: { status?: unknown }): boolean {
|
||||||
|
return measurement.status === 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVisibleMeasurements<T extends { status?: unknown }>(measurements: readonly T[]): T[] {
|
||||||
|
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortMeasurements<T extends { taskNo?: unknown; position?: unknown }>(measurements: readonly T[]): T[] {
|
||||||
|
const positionOrder = (position: unknown) => position === '左梁' ? 0 : position === '右梁' ? 1 : 2;
|
||||||
|
return measurements
|
||||||
|
.map((measurement, index) => ({ measurement, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aNo = typeof a.measurement.taskNo === 'string' && /^\d+$/.test(a.measurement.taskNo)
|
||||||
|
? Number(a.measurement.taskNo)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const bNo = typeof b.measurement.taskNo === 'string' && /^\d+$/.test(b.measurement.taskNo)
|
||||||
|
? Number(b.measurement.taskNo)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
if (aNo !== bNo) return aNo - bNo;
|
||||||
|
const positionDelta = positionOrder(a.measurement.position) - positionOrder(b.measurement.position);
|
||||||
|
return positionDelta || a.index - b.index;
|
||||||
|
})
|
||||||
|
.map(({ measurement }) => measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(v: number | null): string {
|
||||||
|
if (v == null) return '—';
|
||||||
if (!Number.isFinite(v)) return '—';
|
if (!Number.isFinite(v)) return '—';
|
||||||
const abs = Math.abs(v);
|
const abs = Math.abs(v);
|
||||||
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
||||||
return v.toFixed(3);
|
return v.toFixed(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
||||||
|
return ids.length > 0 ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||||
|
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
||||||
|
if (overlay.kind === 'ply') {
|
||||||
|
const files = Array.isArray(overlay.files)
|
||||||
|
? overlay.files.filter((f): f is string => typeof f === 'string' && f.length > 0)
|
||||||
|
: undefined;
|
||||||
|
return files && files.length > 0
|
||||||
|
? { kind: 'ply', file: overlay.file, files }
|
||||||
|
: { kind: 'ply', file: overlay.file };
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyUrl = 'url' in overlay ? overlay.url : undefined;
|
||||||
|
const file = typeof overlay.file === 'string' && overlay.file.length > 0
|
||||||
|
? overlay.file
|
||||||
|
: typeof legacyUrl === 'string' && legacyUrl.length > 0
|
||||||
|
? legacyUrl
|
||||||
|
: undefined;
|
||||||
|
if (!file) return { kind: 'none' };
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof overlay.vertexCount === 'number' &&
|
||||||
|
typeof overlay.dispMin === 'number' &&
|
||||||
|
typeof overlay.dispMax === 'number'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'vertexColors',
|
||||||
|
file,
|
||||||
|
vertexCount: overlay.vertexCount,
|
||||||
|
dispMin: overlay.dispMin,
|
||||||
|
dispMax: overlay.dispMax,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ply', file };
|
||||||
|
}
|
||||||
|
|
||||||
export default function GenericResultView({
|
export default function GenericResultView({
|
||||||
taskId,
|
taskId,
|
||||||
resultId,
|
resultId,
|
||||||
pluginId,
|
pluginId,
|
||||||
pluginName,
|
pluginName,
|
||||||
data,
|
data,
|
||||||
|
displayMode = 'panel',
|
||||||
onViewOverlay,
|
onViewOverlay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
const errorStats = data.errorStats ?? null;
|
const errorStats = data.errorStats ?? null;
|
||||||
const measurements = data.measurements ?? [];
|
const measurements = sortMeasurements(filterVisibleMeasurements(data.measurements ?? []));
|
||||||
|
const showMeasurementDetails = displayMode === 'maximized';
|
||||||
const images = data.images ?? [];
|
const images = data.images ?? [];
|
||||||
const files = (data.files ?? []).map((f) =>
|
const files = (data.files ?? []).map((f) =>
|
||||||
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
||||||
);
|
);
|
||||||
const overlay = data.overlay3d ?? { kind: 'none' };
|
const overlay = normalizeOverlay3D(data.overlay3d);
|
||||||
|
const inputPointCloudFileIds = numberArrayFromUnknown(data.inputPointCloudFileIds);
|
||||||
|
const inputCadFileIds = numberArrayFromUnknown(data.inputCadFileIds);
|
||||||
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
|
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,27 +239,44 @@ 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>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||||
<th className="text-right py-1 font-normal">结果</th>
|
<th className="text-right py-1 font-normal">结果</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<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>
|
||||||
<td className="text-right font-mono">
|
{showMeasurementDetails && <td className="text-right font-mono">
|
||||||
{m.baseline != null
|
{m.baseline != null
|
||||||
? `${formatValue(m.value)}/${m.baseline}mm`
|
? `${formatValue(m.value)}/${m.baseline}mm`
|
||||||
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
||||||
</td>
|
</td>}
|
||||||
<td className="text-right text-muted-foreground">
|
{showMeasurementDetails && <td className="text-right text-muted-foreground">
|
||||||
{m.toleranceLabel != null
|
{m.toleranceLabel != null
|
||||||
? m.toleranceLabel
|
? m.toleranceLabel
|
||||||
: m.tolerance != null
|
: m.tolerance != null
|
||||||
@@ -174,7 +284,7 @@ export default function GenericResultView({
|
|||||||
? `${m.tolerance}/${m.baseline}mm`
|
? `${m.tolerance}/${m.baseline}mm`
|
||||||
: `±${m.tolerance}`
|
: `±${m.tolerance}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
||||||
{m.status === 'info' ? '参考' : statusLabel(m)}
|
{m.status === 'info' ? '参考' : statusLabel(m)}
|
||||||
@@ -184,6 +294,7 @@ export default function GenericResultView({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -191,7 +302,7 @@ export default function GenericResultView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
onClick={() => onViewOverlay({ taskId, resultId, overlay })}
|
onClick={() => onViewOverlay({ taskId, resultId, overlay, inputPointCloudFileIds, inputCadFileIds })}
|
||||||
>
|
>
|
||||||
<Box className="w-3 h-3" />
|
<Box className="w-3 h-3" />
|
||||||
在 3D 中查看叠加层
|
在 3D 中查看叠加层
|
||||||
|
|||||||
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>;
|
||||||
|
}
|
||||||
@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
|
|||||||
function getHeatmapLegendBounds(
|
function getHeatmapLegendBounds(
|
||||||
points: Point[],
|
points: Point[],
|
||||||
mode: 'points' | 'smoothHeatmap',
|
mode: 'points' | 'smoothHeatmap',
|
||||||
): { min: number; max: number; midTick: string } {
|
): { min: number; max: number; midTick: string } | null {
|
||||||
if (mode === 'smoothHeatmap') {
|
if (mode === 'smoothHeatmap') {
|
||||||
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
|
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
|
||||||
|
if (pointsWithError.length === 0) return null;
|
||||||
|
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
const midTick =
|
const midTick =
|
||||||
minError < 0 && maxError > 0
|
minError < 0 && maxError > 0
|
||||||
? '0'
|
? '0'
|
||||||
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
|
|||||||
let minE = Infinity;
|
let minE = Infinity;
|
||||||
let maxE = -Infinity;
|
let maxE = -Infinity;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
const e = points[i].error ?? 0;
|
const e = points[i].error;
|
||||||
|
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
|
||||||
if (e < minE) minE = e;
|
if (e < minE) minE = e;
|
||||||
if (e > maxE) maxE = e;
|
if (e > maxE) maxE = e;
|
||||||
}
|
}
|
||||||
|
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
|
||||||
if (!(maxE > minE)) {
|
if (!(maxE > minE)) {
|
||||||
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
||||||
}
|
}
|
||||||
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
|
|
||||||
const positions = new Float32Array(data.points.length * 3);
|
const positions = new Float32Array(data.points.length * 3);
|
||||||
const colors = new Float32Array(data.points.length * 3);
|
const colors = new Float32Array(data.points.length * 3);
|
||||||
|
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
|
||||||
|
const hasErrorValues = pointsWithError.length > 0;
|
||||||
|
|
||||||
let minError: number;
|
let minError: number;
|
||||||
let maxError: number;
|
let maxError: number;
|
||||||
if (errorRangeMode === 'percentile') {
|
if (errorRangeMode === 'percentile' && hasErrorValues) {
|
||||||
const r = displayErrorRangeSmoothHeatmap(data.points);
|
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
minError = r.minError;
|
minError = r.minError;
|
||||||
maxError = r.maxError;
|
maxError = r.maxError;
|
||||||
} else {
|
} else {
|
||||||
minError = Infinity;
|
minError = Infinity;
|
||||||
maxError = -Infinity;
|
maxError = -Infinity;
|
||||||
for (let i = 0; i < data.points.length; i++) {
|
for (let i = 0; i < data.points.length; i++) {
|
||||||
const err = data.points[i].error ?? 0;
|
const err = data.points[i].error;
|
||||||
|
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
|
||||||
if (err < minError) minError = err;
|
if (err < minError) minError = err;
|
||||||
if (err > maxError) maxError = err;
|
if (err > maxError) maxError = err;
|
||||||
}
|
}
|
||||||
@@ -420,8 +427,8 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
colors[i3] = point.r;
|
colors[i3] = point.r;
|
||||||
colors[i3 + 1] = point.g;
|
colors[i3 + 1] = point.g;
|
||||||
colors[i3 + 2] = point.b;
|
colors[i3 + 2] = point.b;
|
||||||
} else if (showHeatmap) {
|
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
|
||||||
const color = errorToColor(point.error ?? 0, minError, maxError);
|
const color = errorToColor(point.error!, minError, maxError);
|
||||||
colors[i3] = color.r;
|
colors[i3] = color.r;
|
||||||
colors[i3 + 1] = color.g;
|
colors[i3 + 1] = color.g;
|
||||||
colors[i3 + 2] = color.b;
|
colors[i3 + 2] = color.b;
|
||||||
@@ -1023,7 +1030,7 @@ const CADModel = memo(function CADModel({
|
|||||||
}
|
}
|
||||||
}, [useVertexColors, geometry]);
|
}, [useVertexColors, geometry]);
|
||||||
|
|
||||||
if (!visible && !useVertexColors) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
if (!geometry && !loading) return null;
|
if (!geometry && !loading) return null;
|
||||||
|
|
||||||
@@ -1165,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一键全流程演示扫描动画
|
||||||
|
interface DemoFlowScannerProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
|
||||||
|
const ringRef = useRef<THREE.Mesh>(null);
|
||||||
|
const sweepRef = useRef<THREE.Mesh>(null);
|
||||||
|
const particlesRef = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
|
||||||
|
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
|
||||||
|
|
||||||
|
const { positions, colors } = useMemo(() => {
|
||||||
|
const count = 120;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
|
||||||
|
positions[i * 3] = Math.cos(angle) * radius;
|
||||||
|
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
|
||||||
|
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
colors[i * 3] = 0.2;
|
||||||
|
colors[i * 3 + 1] = 0.75;
|
||||||
|
colors[i * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
|
return { positions, colors };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!active) return;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
if (ringRef.current) {
|
||||||
|
ringRef.current.rotation.z = -t * 0.3;
|
||||||
|
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
|
||||||
|
}
|
||||||
|
if (sweepRef.current) {
|
||||||
|
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
|
||||||
|
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
|
||||||
|
}
|
||||||
|
if (particlesRef.current) {
|
||||||
|
particlesRef.current.rotation.y = t * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* 旋转圆环 */}
|
||||||
|
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<ringGeometry args={[3.5, 3.7, 96]} />
|
||||||
|
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 扫描平面 */}
|
||||||
|
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<planeGeometry args={[7.6, 7.6, 48, 48]} />
|
||||||
|
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 粒子环 */}
|
||||||
|
<points ref={particlesRef}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||||
|
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
|
||||||
|
</points>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
||||||
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
||||||
|
|
||||||
@@ -1178,6 +1261,7 @@ interface SceneContentProps {
|
|||||||
pointCloudData?: PointCloudData | null;
|
pointCloudData?: PointCloudData | null;
|
||||||
pointSize: number;
|
pointSize: number;
|
||||||
showHeatmap: boolean;
|
showHeatmap: boolean;
|
||||||
|
showPointCloud: boolean;
|
||||||
pointCloudRenderMode: PointCloudRenderMode;
|
pointCloudRenderMode: PointCloudRenderMode;
|
||||||
enablePointPicking: boolean;
|
enablePointPicking: boolean;
|
||||||
showCADModel: boolean;
|
showCADModel: boolean;
|
||||||
@@ -1201,12 +1285,15 @@ interface SceneContentProps {
|
|||||||
/** ROI 坐标拾取模式 */
|
/** ROI 坐标拾取模式 */
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SceneContent = memo(function SceneContent({
|
const SceneContent = memo(function SceneContent({
|
||||||
pointCloudData,
|
pointCloudData,
|
||||||
pointSize,
|
pointSize,
|
||||||
showHeatmap,
|
showHeatmap,
|
||||||
|
showPointCloud,
|
||||||
pointCloudRenderMode,
|
pointCloudRenderMode,
|
||||||
enablePointPicking,
|
enablePointPicking,
|
||||||
showCADModel,
|
showCADModel,
|
||||||
@@ -1226,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
plyOverlayData = null,
|
plyOverlayData = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: SceneContentProps) {
|
}: SceneContentProps) {
|
||||||
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
||||||
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||||
@@ -1253,9 +1341,15 @@ const SceneContent = memo(function SceneContent({
|
|||||||
const cadShellHeatmapOn =
|
const cadShellHeatmapOn =
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
Boolean(pointCloudData && cadSurfaceGeometry) &&
|
Boolean(pointCloudData && cadSurfaceGeometry) &&
|
||||||
|
showCADModel &&
|
||||||
!cadHeatmapSuppressClient &&
|
!cadHeatmapSuppressClient &&
|
||||||
!useDirectColoring;
|
!useDirectColoring;
|
||||||
const showCadReferenceMesh = (showCADModel && !cadShellHeatmapOn) || useDirectColoring;
|
const showCadReferenceMesh = showCADModel && !cadShellHeatmapOn;
|
||||||
|
const showSmoothPointFallback =
|
||||||
|
pointCloudData &&
|
||||||
|
showPointCloud &&
|
||||||
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
|
(!cadSurfaceGeometry || cadHeatmapSuppressClient || !showCADModel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1292,7 +1386,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{!lightweightScene && <AmbientParticles />}
|
{!lightweightScene && <AmbientParticles />}
|
||||||
|
|
||||||
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
|
{/* Point cloud - 只有当有数据时才渲染,完全禁用事件以提高性能 */}
|
||||||
{pointCloudData && pointCloudRenderMode === 'points' && (
|
{pointCloudData && showPointCloud && pointCloudRenderMode === 'points' && (
|
||||||
<PointCloudMesh
|
<PointCloudMesh
|
||||||
data={pointCloudData}
|
data={pointCloudData}
|
||||||
pointSize={pointSize}
|
pointSize={pointSize}
|
||||||
@@ -1305,6 +1399,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{pointCloudData &&
|
{pointCloudData &&
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
cadSurfaceGeometry &&
|
cadSurfaceGeometry &&
|
||||||
|
showCADModel &&
|
||||||
!cadHeatmapSuppressClient &&
|
!cadHeatmapSuppressClient &&
|
||||||
!useDirectColoring && (
|
!useDirectColoring && (
|
||||||
<CadSurfaceHeatmapMesh
|
<CadSurfaceHeatmapMesh
|
||||||
@@ -1314,34 +1409,23 @@ const SceneContent = memo(function SceneContent({
|
|||||||
showWireframe={showWireframe}
|
showWireframe={showWireframe}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pointCloudData &&
|
{showSmoothPointFallback && (
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
|
||||||
cadSurfaceGeometry &&
|
|
||||||
cadHeatmapSuppressClient && (
|
|
||||||
<PointCloudMesh
|
|
||||||
data={pointCloudData}
|
|
||||||
pointSize={Math.min(pointSize * 2.8, 0.12)}
|
|
||||||
showHeatmap={showHeatmap}
|
|
||||||
enablePointPicking={false}
|
|
||||||
errorRangeMode="percentile"
|
|
||||||
pointOpacity={showHeatmap ? 0.38 : 0.55}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{pointCloudData && pointCloudRenderMode === 'smoothHeatmap' && !cadSurfaceGeometry && (
|
|
||||||
<>
|
<>
|
||||||
<PointCloudMesh
|
<PointCloudMesh
|
||||||
data={pointCloudData}
|
data={pointCloudData}
|
||||||
pointSize={Math.min(pointSize * 3.2, 0.14)}
|
pointSize={Math.min(pointSize * (cadSurfaceGeometry ? 2.8 : 3.2), cadSurfaceGeometry ? 0.12 : 0.14)}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
enablePointPicking={false}
|
enablePointPicking={false}
|
||||||
errorRangeMode="percentile"
|
errorRangeMode="percentile"
|
||||||
pointOpacity={showHeatmap ? 0.42 : 0.6}
|
pointOpacity={showHeatmap ? (cadSurfaceGeometry ? 0.38 : 0.42) : (cadSurfaceGeometry ? 0.55 : 0.6)}
|
||||||
/>
|
|
||||||
<SmoothHeatmapMesh
|
|
||||||
data={pointCloudData}
|
|
||||||
showHeatmap={showHeatmap}
|
|
||||||
showWireframe={showWireframe}
|
|
||||||
/>
|
/>
|
||||||
|
{!cadSurfaceGeometry && (
|
||||||
|
<SmoothHeatmapMesh
|
||||||
|
data={pointCloudData}
|
||||||
|
showHeatmap={showHeatmap}
|
||||||
|
showWireframe={showWireframe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1380,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<IndustrialGrid />
|
<IndustrialGrid />
|
||||||
|
|
||||||
|
{/* 一键全流程演示扫描动画 */}
|
||||||
|
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
|
||||||
|
|
||||||
{/* PLY 叠加层(kind=ply 插件结果) */}
|
{/* PLY 叠加层(kind=ply 插件结果) */}
|
||||||
{plyOverlayData && (
|
{plyOverlayData && (
|
||||||
<points>
|
<points>
|
||||||
@@ -1408,6 +1495,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
return prevProps.pointCloudData === nextProps.pointCloudData &&
|
return prevProps.pointCloudData === nextProps.pointCloudData &&
|
||||||
prevProps.pointSize === nextProps.pointSize &&
|
prevProps.pointSize === nextProps.pointSize &&
|
||||||
prevProps.showHeatmap === nextProps.showHeatmap &&
|
prevProps.showHeatmap === nextProps.showHeatmap &&
|
||||||
|
prevProps.showPointCloud === nextProps.showPointCloud &&
|
||||||
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
|
prevProps.pointCloudRenderMode === nextProps.pointCloudRenderMode &&
|
||||||
prevProps.enablePointPicking === nextProps.enablePointPicking &&
|
prevProps.enablePointPicking === nextProps.enablePointPicking &&
|
||||||
prevProps.showCADModel === nextProps.showCADModel &&
|
prevProps.showCADModel === nextProps.showCADModel &&
|
||||||
@@ -1419,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
|
|||||||
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
||||||
prevProps.dispRange === nextProps.dispRange &&
|
prevProps.dispRange === nextProps.dispRange &&
|
||||||
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
||||||
prevProps.roiPickMode === nextProps.roiPickMode;
|
prevProps.roiPickMode === nextProps.roiPickMode &&
|
||||||
|
prevProps.demoFlowActive === nextProps.demoFlowActive;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
||||||
@@ -1536,6 +1625,7 @@ async function fetchBatchSampled(
|
|||||||
|
|
||||||
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
||||||
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||||
|
const hasError = resp.headers.get('X-Has-Error') === '1';
|
||||||
const stride = hasColor ? 7 : 4;
|
const stride = hasColor ? 7 : 4;
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
const floats = new Float32Array(arrayBuffer);
|
const floats = new Float32Array(arrayBuffer);
|
||||||
@@ -1552,7 +1642,8 @@ async function fetchBatchSampled(
|
|||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const base = i * stride;
|
const base = i * stride;
|
||||||
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
||||||
const pt: Point = { x, y, z, error };
|
const pt: Point = { x, y, z };
|
||||||
|
if (hasError && Number.isFinite(error)) pt.error = error;
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
pt.r = floats[base + 4];
|
pt.r = floats[base + 4];
|
||||||
pt.g = floats[base + 5];
|
pt.g = floats[base + 5];
|
||||||
@@ -1578,6 +1669,7 @@ interface PointCloudViewerProps {
|
|||||||
*/
|
*/
|
||||||
loadKey?: number;
|
loadKey?: number;
|
||||||
showHeatmap?: boolean;
|
showHeatmap?: boolean;
|
||||||
|
showPointCloud?: boolean;
|
||||||
showCADModel?: boolean;
|
showCADModel?: boolean;
|
||||||
showWireframe?: boolean;
|
showWireframe?: boolean;
|
||||||
pointSize?: number;
|
pointSize?: number;
|
||||||
@@ -1614,7 +1706,7 @@ interface PointCloudViewerProps {
|
|||||||
overlay:
|
overlay:
|
||||||
| { kind: 'none' }
|
| { kind: 'none' }
|
||||||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||||||
| { kind: 'ply'; file: string };
|
| { kind: 'ply'; file: string; files?: string[] };
|
||||||
} | null;
|
} | null;
|
||||||
/**
|
/**
|
||||||
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
|
* ROI 坐标拾取模式。激活时,用户在 3D 视图中点击任意点,
|
||||||
@@ -1622,6 +1714,8 @@ interface PointCloudViewerProps {
|
|||||||
*/
|
*/
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PointCloudViewer({
|
export default function PointCloudViewer({
|
||||||
@@ -1630,6 +1724,7 @@ export default function PointCloudViewer({
|
|||||||
batchTaskId,
|
batchTaskId,
|
||||||
loadKey,
|
loadKey,
|
||||||
showHeatmap = true,
|
showHeatmap = true,
|
||||||
|
showPointCloud = true,
|
||||||
showCADModel = true,
|
showCADModel = true,
|
||||||
showWireframe = false,
|
showWireframe = false,
|
||||||
pointSize = 0.03,
|
pointSize = 0.03,
|
||||||
@@ -1650,6 +1745,7 @@ export default function PointCloudViewer({
|
|||||||
pluginOverlay = null,
|
pluginOverlay = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: PointCloudViewerProps) {
|
}: PointCloudViewerProps) {
|
||||||
// 点云数据状态
|
// 点云数据状态
|
||||||
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
||||||
@@ -1769,6 +1865,7 @@ export default function PointCloudViewer({
|
|||||||
|
|
||||||
// PLY 叠加层状态(kind=ply)
|
// PLY 叠加层状态(kind=ply)
|
||||||
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
|
const [plyOverlayData, setPlyOverlayData] = useState<{ positions: Float32Array; colors: Float32Array } | null>(null);
|
||||||
|
const [plyOverlayStatus, setPlyOverlayStatus] = useState<{ status: 'idle' | 'loading' | 'ready' | 'error'; message?: string }>({ status: 'idle' });
|
||||||
// 主点云的坐标变换参数,供 ply 叠加层对齐用
|
// 主点云的坐标变换参数,供 ply 叠加层对齐用
|
||||||
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
|
const pcTransformRef = useRef<{ cx: number; cy: number; cz: number; scale: number } | null>(null);
|
||||||
|
|
||||||
@@ -1776,32 +1873,58 @@ export default function PointCloudViewer({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
|
if (pluginOverlay == null || pluginOverlay.overlay.kind !== 'ply') {
|
||||||
setPlyOverlayData(null);
|
setPlyOverlayData(null);
|
||||||
|
setPlyOverlayStatus({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = pcTransformRef.current;
|
||||||
|
if (!t || !pointCloudData?.points?.length) {
|
||||||
|
setPlyOverlayData(null);
|
||||||
|
setPlyOverlayStatus({
|
||||||
|
status: 'error',
|
||||||
|
message: '请先加载本算法结果对应的点云文件,再查看 PLY 叠加层',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { taskId, resultId, overlay } = pluginOverlay;
|
const { taskId, resultId, overlay } = pluginOverlay;
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
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[] = (overlay as { files?: string[]; file: string }).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();
|
||||||
const floats = new Float32Array(buf);
|
const floats = new Float32Array(buf);
|
||||||
const count = Math.floor(floats.length / stride);
|
const count = Math.floor(floats.length / stride);
|
||||||
if (count === 0) return;
|
if (count === 0) {
|
||||||
|
setPlyOverlayStatus({ status: 'error', message: 'PLY 叠加层没有可渲染点' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const positions = new Float32Array(count * 3);
|
const positions = new Float32Array(count * 3);
|
||||||
const colors = new Float32Array(count * 3);
|
const colors = new Float32Array(count * 3);
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
@@ -1826,23 +1949,26 @@ export default function PointCloudViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 用主点云的坐标变换对齐叠加层坐标
|
// 用主点云的坐标变换对齐叠加层坐标
|
||||||
const t = pcTransformRef.current;
|
for (let i = 0; i < count; i++) {
|
||||||
if (t) {
|
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
|
||||||
for (let i = 0; i < count; i++) {
|
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
|
||||||
positions[i * 3] = (positions[i * 3] - t.cx) * t.scale;
|
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
|
||||||
positions[i * 3 + 1] = (positions[i * 3 + 1] - t.cy) * t.scale;
|
|
||||||
positions[i * 3 + 2] = (positions[i * 3 + 2] - t.cz) * t.scale;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setPlyOverlayData({ positions, colors });
|
setPlyOverlayData({ positions, colors });
|
||||||
|
setPlyOverlayStatus({ status: 'ready' });
|
||||||
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
|
console.log(`[PlyOverlay] loaded ${count} pts, hasColor=${hasColor}, transform=${JSON.stringify(t)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
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);
|
||||||
|
setPlyOverlayStatus({
|
||||||
|
status: 'error',
|
||||||
|
message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [pluginOverlay]);
|
}, [pluginOverlay, pointCloudData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
|
// 当插件叠加层激活时,跳过 legacy heatmap effect,避免与下面那个 effect 抢 ref
|
||||||
@@ -1947,16 +2073,22 @@ export default function PointCloudViewer({
|
|||||||
|
|
||||||
const heatmapLegendBounds = useMemo(() => {
|
const heatmapLegendBounds = useMemo(() => {
|
||||||
if (!showHeatmap) return null;
|
if (!showHeatmap) return null;
|
||||||
|
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
|
||||||
|
const hasVisibleCadHeatmap =
|
||||||
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
|
serverCadHeatmap.status === 'ready' &&
|
||||||
|
showCADModel;
|
||||||
const pts = pointCloudData?.points;
|
const pts = pointCloudData?.points;
|
||||||
if (!pts?.length) return null;
|
if (!hasVisibleCadHeatmap && (!showPointCloud || !pts?.length)) return null;
|
||||||
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
|
if (pointCloudRenderMode === 'smoothHeatmap' && serverCadHeatmap.status === 'ready') {
|
||||||
const lo = serverCadHeatmap.dispMin;
|
const lo = serverCadHeatmap.dispMin;
|
||||||
const hi = serverCadHeatmap.dispMax;
|
const hi = serverCadHeatmap.dispMax;
|
||||||
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
|
const midTick = lo < 0 && hi > 0 ? '0' : ((lo + hi) / 2).toFixed(3);
|
||||||
return { min: lo, max: hi, midTick };
|
return { min: lo, max: hi, midTick };
|
||||||
}
|
}
|
||||||
|
if (!pts?.length) return null;
|
||||||
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
|
return getHeatmapLegendBounds(pts, pointCloudRenderMode);
|
||||||
}, [showHeatmap, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
}, [showHeatmap, showPointCloud, showCADModel, pointCloudData, pointCloudRenderMode, serverCadHeatmap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
|
<div className={`relative ${className}`} style={{ background: 'oklch(0.08 0.006 240)' }}>
|
||||||
@@ -2010,6 +2142,29 @@ export default function PointCloudViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pluginOverlay?.overlay.kind === 'ply' && plyOverlayStatus.status !== 'idle' && plyOverlayStatus.status !== 'ready' && (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-3 shadow-lg backdrop-blur-sm flex items-center gap-3 max-w-sm">
|
||||||
|
{plyOverlayStatus.status === 'loading' && (
|
||||||
|
<svg className="animate-spin h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-foreground/90 font-medium">
|
||||||
|
{plyOverlayStatus.status === 'error' ? 'PLY 叠加层不可用' : '正在加载 PLY 叠加层…'}
|
||||||
|
</div>
|
||||||
|
{plyOverlayStatus.message && (
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{plyOverlayStatus.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 热力图点击拾取信息 */}
|
{/* 热力图点击拾取信息 */}
|
||||||
{heatmapPick && (
|
{heatmapPick && (
|
||||||
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
|
<div className="absolute left-3 bottom-14 z-20 rounded-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-4 py-2.5 shadow-lg backdrop-blur-sm max-w-[220px]">
|
||||||
@@ -2055,6 +2210,7 @@ export default function PointCloudViewer({
|
|||||||
pointCloudData={pointCloudData}
|
pointCloudData={pointCloudData}
|
||||||
pointSize={pointSize}
|
pointSize={pointSize}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
|
showPointCloud={showPointCloud}
|
||||||
pointCloudRenderMode={pointCloudRenderMode}
|
pointCloudRenderMode={pointCloudRenderMode}
|
||||||
enablePointPicking={enableErrorPicking}
|
enablePointPicking={enableErrorPicking}
|
||||||
showCADModel={showCADModel}
|
showCADModel={showCADModel}
|
||||||
@@ -2074,6 +2230,7 @@ export default function PointCloudViewer({
|
|||||||
plyOverlayData={plyOverlayData}
|
plyOverlayData={plyOverlayData}
|
||||||
roiPickMode={roiPickMode}
|
roiPickMode={roiPickMode}
|
||||||
onRoiPointPicked={onRoiPointPicked}
|
onRoiPointPicked={onRoiPointPicked}
|
||||||
|
demoFlowActive={demoFlowActive}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
|
|
||||||
export type TreeSelection = {
|
export type TreeSelection = {
|
||||||
seriesId: number | null;
|
seriesId: number | null;
|
||||||
@@ -25,9 +26,11 @@ interface Props {
|
|||||||
function EditableText({
|
function EditableText({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
|
canEdit: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
@@ -35,7 +38,7 @@ function EditableText({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
||||||
onDoubleClick={() => { setDraft(value); setEditing(true); }}
|
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,6 +63,7 @@ function EditableText({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleTree({ selection, onSelect }: Props) {
|
export default function VehicleTree({ selection, onSelect }: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
||||||
const [newSeriesName, setNewSeriesName] = useState('');
|
const [newSeriesName, setNewSeriesName] = useState('');
|
||||||
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<div className="industrial-panel p-2 space-y-1">
|
<div className="industrial-panel p-2 space-y-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="industrial-label">车系 / 车型 / 任务</span>
|
<span className="industrial-label">车系 / 车型 / 任务</span>
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => setAddingSeries(true)}
|
onClick={() => setAddingSeries(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" /> 车系
|
<Plus className="w-3 h-3" /> 车系
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addingSeries && (
|
{canWrite && addingSeries && (
|
||||||
<div className="flex items-center gap-1 pl-1">
|
<div className="flex items-center gap-1 pl-1">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -167,18 +171,16 @@ 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" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={series.name}
|
value={series.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary"
|
className="p-0.5 rounded hover:bg-secondary"
|
||||||
title="新增车型"
|
title="新增车型"
|
||||||
@@ -196,11 +198,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新增车型输入 */}
|
{/* 新增车型输入 */}
|
||||||
{addingModelFor === series.id && (
|
{canWrite && addingModelFor === series.id && (
|
||||||
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -258,9 +260,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={model.name}
|
value={model.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateModel.mutate({ id: model.id, name })}
|
onSave={name => updateModel.mutate({ id: model.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除车型"
|
title="删除车型"
|
||||||
@@ -271,7 +274,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +286,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
|
|
||||||
{seriesList.length === 0 && (
|
{seriesList.length === 0 && (
|
||||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||||
暂无车系,点击右上角新增
|
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
173
client/src/components/demoFlowAggregation.test.ts
Normal file
173
client/src/components/demoFlowAggregation.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { aggregateDemoFlowResults, demoFlowStatusLabel, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||||
|
|
||||||
|
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
|
||||||
|
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameOf = (item: DemoFlowSourceResult) => `算法${item.id}`;
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowResults', () => {
|
||||||
|
it('按全流程顺序合并测量项,并保留来源和算法内顺序', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B1', value: 2 }, { name: 'B2', value: 3 }] }),
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A1', value: 1 }] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => [row.measurement.name, row.resultId, row.flowIndex, row.measurementIndex])).toEqual([
|
||||||
|
['A1', 10, 1, 0],
|
||||||
|
['B1', 20, 2, 0],
|
||||||
|
['B2', 20, 2, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原样保留空值、公差文本、基准长度和自定义结论文案,并忽略空测量', () => {
|
||||||
|
const measurement = {
|
||||||
|
name: '斜率', value: null, unit: 'mm', tolerance: 0.5, toleranceLabel: '+8.5/-3', baseline: 1000,
|
||||||
|
status: 'warning' as const, passLabel: '达标', warningLabel: '复核', failLabel: '未达标',
|
||||||
|
};
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [measurement] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows).toHaveLength(1);
|
||||||
|
expect(model.measurementRows[0].measurement).toEqual(measurement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认不生成算法分组行,开启后按算法生成分组', () => {
|
||||||
|
const inputs = [
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A', value: 1 }] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B', value: 2 }] }),
|
||||||
|
];
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf).tableRows.every((row) => row.kind === 'measurement')).toBe(true);
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf, { includeAlgorithmGroups: true }).tableRows.map((row) => row.kind)).toEqual([
|
||||||
|
'algorithm', 'measurement', 'algorithm', 'measurement',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过滤参考项,同时保留其他状态和未设置状态的测量项', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '参考项', value: 0.1, status: 'info' },
|
||||||
|
{ name: '合格项', value: 1, status: 'pass' },
|
||||||
|
{ name: '警告项', value: 2, status: 'warning' },
|
||||||
|
{ name: '失败项', value: 3, status: 'fail' },
|
||||||
|
{ name: '无状态项', value: 4 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||||
|
'合格项', '警告项', '失败项', '无状态项',
|
||||||
|
]);
|
||||||
|
expect(model.tableRows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过滤全流程指定的中间指标,同时保持其他有效测量项的原顺序', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '车体长度', value: 100, status: 'pass' },
|
||||||
|
{ name: '前后端平移差', value: 0.1, status: 'pass' },
|
||||||
|
{ name: '车体宽度', value: 20, status: 'warning' },
|
||||||
|
{ name: 'ICP fitness', value: 0.98, status: 'pass' },
|
||||||
|
{ name: '配准 RMSE', value: 0.02, status: 'pass' },
|
||||||
|
{ name: '车体高度', value: 30, status: 'fail' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||||
|
'车体长度', '车体宽度', '车体高度',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自选模式只聚合选中的正式任务,任务 5 保留左右梁两个位置', () => {
|
||||||
|
const results = [result(1, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ taskNo: '2', name: '车体长度', value: 1 },
|
||||||
|
{ taskNo: '5', name: '挠度', position: '左梁', value: 2 },
|
||||||
|
{ taskNo: '5', name: '挠度', position: '右梁', value: 3 },
|
||||||
|
{ taskNo: '10', name: '纵向中心右偏转', value: 4 },
|
||||||
|
],
|
||||||
|
})];
|
||||||
|
const model = aggregateDemoFlowResults(results, () => '算法', { selectedTaskNos: [5, 10] });
|
||||||
|
expect(model.measurementRows.map((row) => [row.measurement.taskNo, row.measurement.position])).toEqual([
|
||||||
|
['5', '左梁'],
|
||||||
|
['5', '右梁'],
|
||||||
|
['10', undefined],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
|
||||||
|
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
|
||||||
|
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
|
||||||
|
expect(demoFlowStatusLabel({ status: 'fail' })).toBe('超差');
|
||||||
|
expect(demoFlowStatusLabel({ status: 'info' })).toBe('参考');
|
||||||
|
expect(demoFlowStatusLabel({})).toBe('参考');
|
||||||
|
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '通过项', value: 1, status: 'pass', passLabel: '通过' },
|
||||||
|
{ name: '警告项', value: 2, status: 'warning', warningLabel: '部分成功' },
|
||||||
|
{ name: '失败项', value: 3, status: 'fail', failLabel: '失败' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => demoFlowStatusLabel(row.measurement))).toEqual([
|
||||||
|
'合格', '警告', '超差',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '配准误差 p95', value: 0.1, status: 'info' },
|
||||||
|
{ name: '配准误差均值', value: 0.05, status: 'info' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf, { includeAlgorithmGroups: true });
|
||||||
|
|
||||||
|
expect(model.measurementRows).toEqual([]);
|
||||||
|
expect(model.tableRows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
|
||||||
|
}),
|
||||||
|
result(20, 2, 'completed', {
|
||||||
|
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.artifactGroups).toMatchObject([
|
||||||
|
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
|
||||||
|
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
|
||||||
|
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
|
||||||
|
result(30, 3, 'pending', { stage: '等待资源' }),
|
||||||
|
result(40, 4, 'error', { error: '输入无效' }),
|
||||||
|
result(50, 5, 'cancelled', {}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
|
||||||
|
expect(model.statusNotices).toMatchObject([
|
||||||
|
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
|
||||||
|
{ resultId: 30, status: 'pending', detail: '等待资源' },
|
||||||
|
{ resultId: 40, status: 'error', detail: '输入无效' },
|
||||||
|
{ resultId: 50, status: 'cancelled' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
197
client/src/components/demoFlowAggregation.ts
Normal file
197
client/src/components/demoFlowAggregation.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
||||||
|
|
||||||
|
const DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES = new Set([
|
||||||
|
'前后端平移差',
|
||||||
|
'ICP fitness',
|
||||||
|
'配准 RMSE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type DemoFlowSourceResult = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
measurementIndex: number;
|
||||||
|
measurement: Measurement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementGroupRow = {
|
||||||
|
kind: 'algorithm';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowTableRow = DemoFlowMeasurementRow | DemoFlowMeasurementGroupRow;
|
||||||
|
|
||||||
|
export type DemoFlowArtifactGroup = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
files: { label: string; path: string }[];
|
||||||
|
images: string[];
|
||||||
|
overlay: PluginResultLike['overlay3d'];
|
||||||
|
inputPointCloudFileIds: unknown;
|
||||||
|
inputCadFileIds: unknown;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowStatusNotice = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowAggregation = {
|
||||||
|
tableRows: DemoFlowTableRow[];
|
||||||
|
measurementRows: DemoFlowMeasurementRow[];
|
||||||
|
artifactGroups: DemoFlowArtifactGroup[];
|
||||||
|
statusNotices: DemoFlowStatusNotice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowIndex(result: DemoFlowSourceResult, fallback: number) {
|
||||||
|
return finiteNumber(asRecord(result.resultData)?.demoFlowIndex) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFiles(value: unknown): { label: string; path: string }[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
if (typeof item === 'string' && item.length > 0) return [{ label: item, path: item }];
|
||||||
|
const record = asRecord(item);
|
||||||
|
if (!record) return [];
|
||||||
|
const path = typeof record.path === 'string'
|
||||||
|
? record.path
|
||||||
|
: typeof record.name === 'string'
|
||||||
|
? record.name
|
||||||
|
: undefined;
|
||||||
|
if (!path) return [];
|
||||||
|
const label = typeof record.name === 'string' ? record.name : path;
|
||||||
|
return [{ label, path }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImages(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOverlay(value: PluginResultLike['overlay3d']) {
|
||||||
|
return Boolean(value && value.kind !== 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterDemoFlowMeasurements(measurements: readonly Measurement[]): Measurement[] {
|
||||||
|
return filterVisibleMeasurements(measurements)
|
||||||
|
.filter((measurement) => !DEMO_FLOW_HIDDEN_MEASUREMENT_NAMES.has(measurement.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): string {
|
||||||
|
switch (measurement.status) {
|
||||||
|
case 'pass':
|
||||||
|
return '合格';
|
||||||
|
case 'warning':
|
||||||
|
return '警告';
|
||||||
|
case 'fail':
|
||||||
|
return '超差';
|
||||||
|
default:
|
||||||
|
return '参考';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowResults(
|
||||||
|
results: DemoFlowSourceResult[],
|
||||||
|
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||||
|
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
|
||||||
|
): DemoFlowAggregation {
|
||||||
|
const sorted = results
|
||||||
|
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
||||||
|
.sort((a, b) => a.index - b.index || a.originalIndex - b.originalIndex);
|
||||||
|
const measurementRows: DemoFlowMeasurementRow[] = [];
|
||||||
|
const tableRows: DemoFlowTableRow[] = [];
|
||||||
|
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
||||||
|
const statusNotices: DemoFlowStatusNotice[] = [];
|
||||||
|
const selectedTaskNos = options.selectedTaskNos
|
||||||
|
? new Set(options.selectedTaskNos.map(String))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
for (const { result, index } of sorted) {
|
||||||
|
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
||||||
|
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
||||||
|
const algorithmName = getAlgorithmName(result);
|
||||||
|
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : [])
|
||||||
|
.filter((measurement) => !selectedTaskNos || (
|
||||||
|
typeof measurement.taskNo === 'string' && selectedTaskNos.has(measurement.taskNo)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (measurements.length > 0 && options.includeAlgorithmGroups) {
|
||||||
|
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
||||||
|
}
|
||||||
|
measurements.forEach((measurement, measurementIndex) => {
|
||||||
|
const row: DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement',
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
measurementIndex,
|
||||||
|
measurement,
|
||||||
|
};
|
||||||
|
measurementRows.push(row);
|
||||||
|
tableRows.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = normalizeFiles(data.files);
|
||||||
|
const images = normalizeImages(data.images);
|
||||||
|
const extra = asRecord(data.extra) ?? undefined;
|
||||||
|
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
|
||||||
|
artifactGroups.push({
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
files,
|
||||||
|
images,
|
||||||
|
overlay: data.overlay3d,
|
||||||
|
inputPointCloudFileIds: data.inputPointCloudFileIds,
|
||||||
|
inputCadFileIds: data.inputCadFileIds,
|
||||||
|
extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'completed') {
|
||||||
|
const progress = finiteNumber(data.progress);
|
||||||
|
const detail = result.status === 'error'
|
||||||
|
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
|
||||||
|
: typeof data.stage === 'string'
|
||||||
|
? data.stage
|
||||||
|
: typeof data.error === 'string'
|
||||||
|
? data.error
|
||||||
|
: undefined;
|
||||||
|
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tableRows, measurementRows, artifactGroups, statusNotices };
|
||||||
|
}
|
||||||
@@ -1,23 +1,2 @@
|
|||||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||||
|
export const getLoginUrl = () => "/login";
|
||||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
|
||||||
export const getLoginUrl = () => {
|
|
||||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
|
||||||
const appId = import.meta.env.VITE_APP_ID;
|
|
||||||
|
|
||||||
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
|
|
||||||
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
|
||||||
const state = btoa(redirectUri);
|
|
||||||
|
|
||||||
const url = new URL(`${oauthPortalUrl}/app-auth`);
|
|
||||||
url.searchParams.set("appId", appId || '');
|
|
||||||
url.searchParams.set("redirectUri", redirectUri);
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
url.searchParams.set("type", "signIn");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|||||||
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
aggregateDemoFlowState,
|
||||||
|
formatElapsedTime,
|
||||||
|
type AnalysisResultRow,
|
||||||
|
} from './useDemoFlowState';
|
||||||
|
|
||||||
|
const STARTED_AT = '2026-07-24T00:00:10.000Z';
|
||||||
|
|
||||||
|
function result(
|
||||||
|
overrides: Partial<AnalysisResultRow> & Pick<AnalysisResultRow, 'id' | 'status'>,
|
||||||
|
): AnalysisResultRow {
|
||||||
|
return {
|
||||||
|
taskId: 1,
|
||||||
|
type: 'plugin',
|
||||||
|
pluginId: 'vehicle-registration',
|
||||||
|
createdAt: STARTED_AT,
|
||||||
|
updatedAt: STARTED_AT,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 0.7,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowState', () => {
|
||||||
|
it('按源点云总点数和整体进度估算已完成点云量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'ready', pointCount: 1_000_000_000 }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallProgress).toBe(35);
|
||||||
|
expect(state.completedPointCount).toBe(350_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('元数据缺失时不生成虚假的点云数量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'pending', pointCount: null }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.sourcePointCount).toBeNull();
|
||||||
|
expect(state.completedPointCount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('运行中使用当前时间计时,结束后冻结在最后更新时间', () => {
|
||||||
|
const running = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
const completed = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: '2026-07-24T00:00:25.000Z',
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 1,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(running.elapsedMs).toBe(30_000);
|
||||||
|
expect(completed.elapsedMs).toBe(15_000);
|
||||||
|
expect(completed.endedAt).toBe(new Date('2026-07-24T00:00:25.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配准失败后把未创建步骤标为停止,不再聚合成排队中', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'error', updatedAt: '2026-07-24T00:00:25.000Z' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.active).toBe(false);
|
||||||
|
expect(state.overallStatus).toBe('error');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('停止标记可让已完成配准后的占位步骤和计时正确冻结', () => {
|
||||||
|
const cancelledAt = '2026-07-24T00:00:28.000Z';
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: cancelledAt,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
demoFlowCancelledAt: cancelledAt,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallStatus).toBe('cancelled');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
expect(state.elapsedMs).toBe(18_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatElapsedTime', () => {
|
||||||
|
it('小时超过 24 后继续累计', () => {
|
||||||
|
expect(formatElapsedTime((27 * 3600 + 2 * 60 + 3) * 1000)).toBe('27:02:03');
|
||||||
|
});
|
||||||
|
});
|
||||||
273
client/src/hooks/useDemoFlowState.ts
Normal file
273
client/src/hooks/useDemoFlowState.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
|
export type AnalysisResultRow = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number | null;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointcloudFileRow = {
|
||||||
|
id: number;
|
||||||
|
metadataStatus?: string | null;
|
||||||
|
pointCount?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampFromUnknown(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'string' && !(value instanceof Date)) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedTime(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface DemoFlowStep {
|
||||||
|
id: number;
|
||||||
|
index: number;
|
||||||
|
role: 'registration' | 'measurement';
|
||||||
|
pluginId: string | null;
|
||||||
|
status: DemoFlowStepStatus;
|
||||||
|
progress?: number;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoFlowState {
|
||||||
|
active: boolean;
|
||||||
|
runId: string | null;
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
steps: DemoFlowStep[];
|
||||||
|
overallStatus: DemoFlowStepStatus | 'idle';
|
||||||
|
currentStep: DemoFlowStep | null;
|
||||||
|
overallProgress: number;
|
||||||
|
sourcePointCloudFileId: number | null;
|
||||||
|
sourcePointCount: number | null;
|
||||||
|
completedPointCount: number | null;
|
||||||
|
startedAt: number | null;
|
||||||
|
endedAt: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATE: DemoFlowState = {
|
||||||
|
active: false,
|
||||||
|
runId: null,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
steps: [],
|
||||||
|
overallStatus: 'idle',
|
||||||
|
currentStep: null,
|
||||||
|
overallProgress: 0,
|
||||||
|
sourcePointCloudFileId: null,
|
||||||
|
sourcePointCount: null,
|
||||||
|
completedPointCount: null,
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: null,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isActiveStatus(step: DemoFlowStep): boolean {
|
||||||
|
return step.status === 'processing' || step.status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowState(
|
||||||
|
taskId: number | null,
|
||||||
|
results: AnalysisResultRow[],
|
||||||
|
pointcloudFiles: PointcloudFileRow[],
|
||||||
|
nowMs: number,
|
||||||
|
): DemoFlowState {
|
||||||
|
if (taskId == null || results.length === 0) return EMPTY_STATE;
|
||||||
|
|
||||||
|
const steps: DemoFlowStep[] = [];
|
||||||
|
let activeRunId: string | null = null;
|
||||||
|
let declaredTotal = 0;
|
||||||
|
let sourcePointCloudFileId: number | null = null;
|
||||||
|
let cancelledAt: number | null = null;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const data = asRecord(r.resultData);
|
||||||
|
if (!data) continue;
|
||||||
|
const runId = data.demoFlowRunId;
|
||||||
|
if (typeof runId !== 'string' || runId.length === 0) continue;
|
||||||
|
|
||||||
|
if (activeRunId == null) {
|
||||||
|
activeRunId = runId;
|
||||||
|
} else if (activeRunId !== runId) {
|
||||||
|
// 分析结果按 createdAt 倒序返回,只聚合最新的一次全流程。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = numberFromUnknown(data.demoFlowIndex) ?? steps.length + 1;
|
||||||
|
const roleRaw = data.demoFlowRole;
|
||||||
|
const role = roleRaw === 'registration' ? 'registration' : 'measurement';
|
||||||
|
const progress = numberFromUnknown(data.progress);
|
||||||
|
const stepTotal = numberFromUnknown(data.demoFlowTotal) ?? 0;
|
||||||
|
const sourceId = numberFromUnknown(data.demoFlowSourcePointCloudFileId);
|
||||||
|
const rowCancelledAt = timestampFromUnknown(data.demoFlowCancelledAt);
|
||||||
|
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
|
||||||
|
if (sourcePointCloudFileId == null && sourceId != null) sourcePointCloudFileId = sourceId;
|
||||||
|
if (rowCancelledAt != null && (cancelledAt == null || rowCancelledAt > cancelledAt)) {
|
||||||
|
cancelledAt = rowCancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
id: r.id,
|
||||||
|
index,
|
||||||
|
role,
|
||||||
|
pluginId: r.pluginId ?? null,
|
||||||
|
status: r.status as DemoFlowStepStatus,
|
||||||
|
progress,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0 || activeRunId == null) return EMPTY_STATE;
|
||||||
|
|
||||||
|
steps.sort((a, b) => a.index - b.index);
|
||||||
|
const actualSteps = [...steps];
|
||||||
|
const total = Math.max(actualSteps.length, declaredTotal);
|
||||||
|
const hasActiveActualStep = actualSteps.some(isActiveStatus);
|
||||||
|
const hasError = actualSteps.some((step) => step.status === 'error');
|
||||||
|
const hasCancelled = cancelledAt != null || actualSteps.some((step) => step.status === 'cancelled');
|
||||||
|
const terminalStatus: 'error' | 'cancelled' | null = !hasActiveActualStep
|
||||||
|
? hasError
|
||||||
|
? 'error'
|
||||||
|
: hasCancelled
|
||||||
|
? 'cancelled'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 链路已失败或停止时,尚未创建的步骤不会再执行,不能继续显示为“排队中”。
|
||||||
|
for (let index = actualSteps.length + 1; index <= total; index += 1) {
|
||||||
|
steps.push({
|
||||||
|
id: -index,
|
||||||
|
index,
|
||||||
|
role: index === 1 ? 'registration' : 'measurement',
|
||||||
|
pluginId: null,
|
||||||
|
status: terminalStatus == null ? 'pending' : 'cancelled',
|
||||||
|
createdAt: steps[0]?.createdAt ?? new Date(nowMs),
|
||||||
|
updatedAt: steps[0]?.updatedAt ?? new Date(nowMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = steps.filter((step) => step.status === 'completed').length;
|
||||||
|
let overallStatus: DemoFlowState['overallStatus'];
|
||||||
|
if (actualSteps.some((step) => step.status === 'processing')) overallStatus = 'processing';
|
||||||
|
else if (actualSteps.some((step) => step.status === 'pending')) overallStatus = 'pending';
|
||||||
|
else if (terminalStatus != null) overallStatus = terminalStatus;
|
||||||
|
else if (actualSteps.length === total && actualSteps.every((step) => step.status === 'completed')) {
|
||||||
|
overallStatus = 'completed';
|
||||||
|
} else {
|
||||||
|
overallStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = overallStatus === 'processing' || overallStatus === 'pending';
|
||||||
|
const currentStep =
|
||||||
|
steps.find((step) => step.status === 'processing') ??
|
||||||
|
steps.find((step) => step.status === 'pending') ??
|
||||||
|
actualSteps[actualSteps.length - 1] ??
|
||||||
|
null;
|
||||||
|
const doneProgress = total > 0 ? completed / total : 0;
|
||||||
|
const currentProgress = currentStep && isActiveStatus(currentStep) && total > 0
|
||||||
|
? Math.min(1, Math.max(0, currentStep.progress ?? 0)) / total
|
||||||
|
: 0;
|
||||||
|
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
|
||||||
|
|
||||||
|
const sourcePointcloud = sourcePointCloudFileId == null
|
||||||
|
? undefined
|
||||||
|
: pointcloudFiles.find((file) => file.id === sourcePointCloudFileId);
|
||||||
|
const sourcePointCount =
|
||||||
|
sourcePointcloud?.metadataStatus === 'ready'
|
||||||
|
&& typeof sourcePointcloud.pointCount === 'number'
|
||||||
|
&& Number.isFinite(sourcePointcloud.pointCount)
|
||||||
|
&& sourcePointcloud.pointCount >= 0
|
||||||
|
? sourcePointcloud.pointCount
|
||||||
|
: null;
|
||||||
|
const completedPointCount =
|
||||||
|
sourcePointCount == null ? null : sourcePointCount * (overallProgress / 100);
|
||||||
|
|
||||||
|
const startTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.createdAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const updateTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.updatedAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const startedAt = startTimestamps.length > 0 ? Math.min(...startTimestamps) : null;
|
||||||
|
const endedAt = active
|
||||||
|
? null
|
||||||
|
: cancelledAt ?? (updateTimestamps.length > 0 ? Math.max(...updateTimestamps) : startedAt);
|
||||||
|
const elapsedMs = startedAt == null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, (endedAt ?? nowMs) - startedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
runId: activeRunId,
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
steps,
|
||||||
|
overallStatus,
|
||||||
|
currentStep,
|
||||||
|
overallProgress,
|
||||||
|
sourcePointCloudFileId,
|
||||||
|
sourcePointCount,
|
||||||
|
completedPointCount,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoFlowState(taskId: number | null): DemoFlowState {
|
||||||
|
const { data: results = [] } = trpc.analysisResult.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null, refetchInterval: 3000 },
|
||||||
|
);
|
||||||
|
const { data: pointcloudFiles = [] } = trpc.pointcloudFile.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null },
|
||||||
|
);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const state = useMemo(
|
||||||
|
() => aggregateDemoFlowState(
|
||||||
|
taskId,
|
||||||
|
results as AnalysisResultRow[],
|
||||||
|
pointcloudFiles as PointcloudFileRow[],
|
||||||
|
nowMs,
|
||||||
|
),
|
||||||
|
[nowMs, pointcloudFiles, results, taskId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNowMs(Date.now());
|
||||||
|
if (!state.active) return;
|
||||||
|
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [state.active, state.runId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { getLoginUrl } from "./const";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
||||||
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
|
|||||||
|
|
||||||
if (!isUnauthorized) return;
|
if (!isUnauthorized) return;
|
||||||
|
|
||||||
window.location.href = getLoginUrl();
|
if (window.location.pathname !== "/login") window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
queryClient.getQueryCache().subscribe(event => {
|
queryClient.getQueryCache().subscribe(event => {
|
||||||
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
|
|||||||
if (event.type === "updated" && event.action.type === "error") {
|
if (event.type === "updated" && event.action.type === "error") {
|
||||||
const error = event.mutation.state.error;
|
const error = event.mutation.state.error;
|
||||||
redirectToLoginIfUnauthorized(error);
|
redirectToLoginIfUnauthorized(error);
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
|
||||||
|
toast.error(error.message || "无权执行此操作");
|
||||||
|
}
|
||||||
console.error("[API Mutation Error]", error);
|
console.error("[API Mutation Error]", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
48
client/src/pages/ChangePassword.tsx
Normal file
48
client/src/pages/ChangePassword.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Redirect, useLocation } from "wouter";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function ChangePassword() {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const change = trpc.auth.changePassword.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.auth.me.invalidate();
|
||||||
|
setLocation("/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loading && !user) return <Redirect to="/login" />;
|
||||||
|
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!mismatch) change.mutate({ currentPassword, newPassword });
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">修改密码</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
|
||||||
|
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
|
||||||
|
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
|
||||||
|
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
|
||||||
|
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import { getLoginUrl } from '@/const';
|
|
||||||
import PointCloudViewer from '@/components/PointCloudViewer';
|
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||||
|
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
||||||
|
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
|
||||||
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
||||||
import TaskManager from '@/components/TaskManager';
|
import TaskManager from '@/components/TaskManager';
|
||||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||||
@@ -9,22 +10,36 @@ import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
|||||||
import type { Overlay3D } from '@/components/GenericResultView';
|
import type { Overlay3D } from '@/components/GenericResultView';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
LogIn,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Box,
|
Users,
|
||||||
|
Cloud,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Settings,
|
Settings,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Crosshair,
|
|
||||||
Train,
|
Train,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { user, loading, isAuthenticated, logout } = useAuth();
|
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
|
||||||
|
|
||||||
|
// 本次使用时间只属于当前页面会话;刷新或重新登录后从零开始。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setSessionElapsedMs(Date.now() - startedAt);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
||||||
const [selection, setSelection] = useState<TreeSelection>({
|
const [selection, setSelection] = useState<TreeSelection>({
|
||||||
@@ -67,9 +82,19 @@ export default function Home() {
|
|||||||
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||||
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
||||||
|
|
||||||
|
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
|
||||||
|
const demoFlowState = useDemoFlowState(selection.taskId);
|
||||||
|
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
const pluginNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
|
||||||
|
return map;
|
||||||
|
}, [pluginList]);
|
||||||
|
|
||||||
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
||||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
const [showCADModel, setShowCADModel] = useState(true);
|
const [showPointCloud, setShowPointCloud] = useState(true);
|
||||||
|
const [showCADModel] = useState(true);
|
||||||
const [showWireframe, setShowWireframe] = useState(false);
|
const [showWireframe, setShowWireframe] = useState(false);
|
||||||
const [pointSize, setPointSize] = useState(0.03);
|
const [pointSize, setPointSize] = useState(0.03);
|
||||||
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
|
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
|
||||||
@@ -88,7 +113,7 @@ export default function Home() {
|
|||||||
resultId: number;
|
resultId: number;
|
||||||
overlay: Overlay3D;
|
overlay: Overlay3D;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
|
const [errorPickEnabled] = useState(false);
|
||||||
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
||||||
const [showRightPanel, setShowRightPanel] = useState(true);
|
const [showRightPanel, setShowRightPanel] = useState(true);
|
||||||
|
|
||||||
@@ -133,6 +158,7 @@ export default function Home() {
|
|||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
|
setShowHeatmap(false);
|
||||||
setSelectedPcFileIds([]);
|
setSelectedPcFileIds([]);
|
||||||
setSelectedTaskCadFileIds([]);
|
setSelectedTaskCadFileIds([]);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
@@ -217,6 +243,10 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
||||||
|
setHeatmapViewResultId(null);
|
||||||
|
setPluginOverlay(null);
|
||||||
|
setPointCloudRenderMode('points');
|
||||||
|
setShowHeatmap(false);
|
||||||
setPointCloudTaskId(taskId);
|
setPointCloudTaskId(taskId);
|
||||||
setPointCloudUrls([`__task:${taskId}`]);
|
setPointCloudUrls([`__task:${taskId}`]);
|
||||||
setPcLoading(true);
|
setPcLoading(true);
|
||||||
@@ -247,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,7 +310,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AlgorithmRunPanel
|
{canWrite && <AlgorithmRunPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
modelId={selection.modelId}
|
modelId={selection.modelId}
|
||||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||||
@@ -297,8 +326,8 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(null);
|
setRoiPickedPoint(null);
|
||||||
}}
|
}}
|
||||||
activeRoiPickTarget={activeRoiPickTarget}
|
activeRoiPickTarget={activeRoiPickTarget}
|
||||||
/>
|
/>}
|
||||||
<div className="h-px bg-border" />
|
{canWrite && <div className="h-px bg-border" />}
|
||||||
<AnalysisPanel
|
<AnalysisPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
||||||
@@ -306,13 +335,50 @@ export default function Home() {
|
|||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setHeatmapViewResultId(resultId);
|
setHeatmapViewResultId(resultId);
|
||||||
setPointCloudRenderMode('smoothHeatmap');
|
setPointCloudRenderMode('smoothHeatmap');
|
||||||
|
setShowHeatmap(true);
|
||||||
}}
|
}}
|
||||||
onViewPluginOverlay={({ taskId, resultId, overlay }) => {
|
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
|
||||||
|
const expectedIds = inputPointCloudFileIds ?? [];
|
||||||
|
const expectedFiles = expectedIds
|
||||||
|
.map((id) => taskPcFiles.find((f) => f.id === id))
|
||||||
|
.filter((f): f is NonNullable<typeof f> => f != null);
|
||||||
|
const expectedNames = expectedIds
|
||||||
|
.map((id) => taskPcFiles.find((f) => f.id === id)?.name ?? `#${id}`)
|
||||||
|
.join('、');
|
||||||
|
const expectedLabel = expectedFiles.length > 0 && expectedFiles.every((f) => (f as { source?: string }).source === 'registered')
|
||||||
|
? '配准后的点云'
|
||||||
|
: '本算法结果对应的点云文件';
|
||||||
|
const expectedHint = expectedNames ? `${expectedLabel}:${expectedNames}` : expectedLabel;
|
||||||
|
|
||||||
|
if (pointCloudTaskId == null || pointCloudUrls.length === 0) {
|
||||||
|
toast.error(`请先在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pcLoading) {
|
||||||
|
toast.warning('点云仍在加载中,请等待加载完成后再查看叠加层');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pointCloudTaskId !== taskId) {
|
||||||
|
toast.error(`当前 3D 视口加载的点云不属于该算法结果,请先在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expectedIds.length === 0) {
|
||||||
|
toast.warning('无法确认匹配关系,请加载该结果运行时使用的点云');
|
||||||
|
} else {
|
||||||
|
const loadedIds = selectedPcFileIds.length > 0
|
||||||
|
? selectedPcFileIds
|
||||||
|
: taskPcFiles.map((f) => f.id);
|
||||||
|
const missingIds = expectedIds.filter((id) => !loadedIds.includes(id));
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
toast.error(`当前加载的点云与该结果不匹配,请在左侧选择并加载${expectedHint}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay({ taskId, resultId, overlay });
|
setPluginOverlay({ taskId, resultId, overlay });
|
||||||
if (overlay.kind === 'vertexColors') {
|
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
||||||
setPointCloudRenderMode('smoothHeatmap');
|
setShowHeatmap(overlay.kind === 'vertexColors');
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
externalPending={runAnalysisJob.isPending}
|
externalPending={runAnalysisJob.isPending}
|
||||||
onRunAssembly={() => {
|
onRunAssembly={() => {
|
||||||
@@ -333,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 && (
|
||||||
@@ -374,33 +437,32 @@ export default function Home() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
|
{(pcLoading || cadLoading) && (
|
||||||
<span className={`status-led ${
|
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
|
||||||
pcLoading ? 'status-led-processing' :
|
<span className="status-led status-led-processing" />
|
||||||
cadLoading ? 'status-led-processing' :
|
<span className="text-xs text-muted-foreground">
|
||||||
'status-led-idle'
|
{pcLoading ? `加载点云 ${pcProgress}%` : `加载 CAD ${cadLoadProgress}%`}
|
||||||
}`} />
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
</div>
|
||||||
{pcLoading ? `加载点云 ${pcProgress}%` :
|
)}
|
||||||
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
|
|
||||||
'待机'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||||||
) : isAuthenticated ? (
|
) : user ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-xs text-muted-foreground">{user?.name || '用户'}</span>
|
<div className="text-right leading-tight">
|
||||||
<button className="industrial-btn py-1.5" onClick={() => logout()}>
|
<div>{user.username}</div>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<div className="whitespace-nowrap text-[10px] text-muted-foreground">
|
||||||
</button>
|
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
本次使用时间 <span className="font-mono tabular-nums">{formatElapsedTime(sessionElapsedMs)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
||||||
|
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
|
||||||
|
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
|
|
||||||
<LogIn className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -408,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>
|
||||||
)}
|
)}
|
||||||
@@ -439,6 +501,7 @@ export default function Home() {
|
|||||||
pointCloudUrls={pointCloudUrlsStable}
|
pointCloudUrls={pointCloudUrlsStable}
|
||||||
batchTaskId={batchTaskId ?? undefined}
|
batchTaskId={batchTaskId ?? undefined}
|
||||||
loadKey={pcLoadKey}
|
loadKey={pcLoadKey}
|
||||||
|
showPointCloud={showPointCloud}
|
||||||
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
|
selectedPcFileIds={selectedPcFileIds.length > 0 ? selectedPcFileIds : undefined}
|
||||||
onCadProgress={handleCadProgress}
|
onCadProgress={handleCadProgress}
|
||||||
onCadLoaded={handleCadLoaded}
|
onCadLoaded={handleCadLoaded}
|
||||||
@@ -456,6 +519,7 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(pt);
|
setRoiPickedPoint(pt);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
}}
|
}}
|
||||||
|
demoFlowActive={demoFlowState.active}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -470,17 +534,15 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
{/* 操作提示 */}
|
||||||
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
|
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
|
||||||
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && (
|
<span>左键旋转</span>
|
||||||
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span>
|
<span>右键平移</span>
|
||||||
)}
|
<span>滚轮缩放</span>
|
||||||
{errorPickEnabled && pointCloudRenderMode === 'points'
|
|
||||||
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
|
|
||||||
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 选中点信息 */}
|
{/* 选中点信息 */}
|
||||||
@@ -522,43 +584,38 @@ export default function Home() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`}
|
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showPointCloud ? 'industrial-btn-primary' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPointCloudRenderMode('points');
|
if (pointCloudRenderMode !== 'points') {
|
||||||
setHeatmapViewResultId(null);
|
setPointCloudRenderMode('points');
|
||||||
setPluginOverlay(null);
|
setHeatmapViewResultId(null);
|
||||||
|
setPluginOverlay(null);
|
||||||
|
setShowHeatmap(false);
|
||||||
|
setShowPointCloud(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowPointCloud(v => !v);
|
||||||
}}
|
}}
|
||||||
title="逐点显示(支持误差拾取)"
|
title={showPointCloud ? '隐藏点云' : '显示点云'}
|
||||||
>
|
>
|
||||||
<span className="text-[10px]">点云</span>
|
<Cloud className="w-3.5 h-3.5" />
|
||||||
|
<span>点云</span>
|
||||||
</button>
|
</button>
|
||||||
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
|
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
|
||||||
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
|
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
|
||||||
正在显示分析结果 #{heatmapViewResultId}
|
正在显示分析结果 #{heatmapViewResultId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{pointCloudRenderMode === 'smoothHeatmap' && (
|
||||||
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
<button
|
||||||
onClick={() => setShowHeatmap(v => !v)}
|
type="button"
|
||||||
>
|
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
||||||
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
onClick={() => setShowHeatmap(v => !v)}
|
||||||
<span className="ml-1">热力图</span>
|
>
|
||||||
</button>
|
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
<button
|
<span>热力图</span>
|
||||||
className={`industrial-btn py-1 ${showCADModel ? 'industrial-btn-primary' : ''}`}
|
</button>
|
||||||
onClick={() => setShowCADModel(v => !v)}
|
)}
|
||||||
>
|
|
||||||
<Box className="w-3.5 h-3.5" />
|
|
||||||
<span className="ml-1">CAD</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`industrial-btn py-1 ${errorPickEnabled ? 'industrial-btn-primary' : ''}`}
|
|
||||||
onClick={() => setErrorPickEnabled(v => !v)}
|
|
||||||
title="开启后左键点击显示该点误差"
|
|
||||||
>
|
|
||||||
<Crosshair className="w-3.5 h-3.5" />
|
|
||||||
<span className="ml-1">误差拾取</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-5 w-px bg-border" />
|
<div className="h-5 w-px bg-border" />
|
||||||
@@ -584,7 +641,7 @@ export default function Home() {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={100_000}
|
min={100_000}
|
||||||
max={5_000_000}
|
max={2_000_000}
|
||||||
step={100_000}
|
step={100_000}
|
||||||
value={renderPointBudget}
|
value={renderPointBudget}
|
||||||
onChange={e => setRenderPointBudget(Number(e.target.value))}
|
onChange={e => setRenderPointBudget(Number(e.target.value))}
|
||||||
|
|||||||
53
client/src/pages/Login.tsx
Normal file
53
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Redirect, useLocation } from "wouter";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const login = trpc.auth.login.useMutation({
|
||||||
|
onSuccess: async (signedInUser) => {
|
||||||
|
utils.auth.me.setData(undefined, signedInUser);
|
||||||
|
await utils.auth.me.invalidate();
|
||||||
|
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm industrial-panel p-8 space-y-5"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
login.mutate({ username, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">用户名</Label>
|
||||||
|
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
|
||||||
|
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
|
||||||
|
{login.isPending ? "正在登录…" : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/src/pages/Users.tsx
Normal file
68
client/src/pages/Users.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { ArrowLeft, Search, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
|
||||||
|
type Role = keyof typeof roleNames;
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [draftSearch, setDraftSearch] = useState("");
|
||||||
|
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
|
||||||
|
const list = trpc.users.list.useQuery(search ? { search } : {});
|
||||||
|
const refresh = () => utils.users.list.invalidate();
|
||||||
|
const onError = (error: { message: string }) => toast.error(error.message);
|
||||||
|
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
|
||||||
|
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
|
||||||
|
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
|
||||||
|
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
|
||||||
|
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6 space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="industrial-panel p-5">
|
||||||
|
<h2 className="font-medium mb-4">新建用户</h2>
|
||||||
|
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
|
||||||
|
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
|
||||||
|
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
|
||||||
|
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
|
||||||
|
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
|
||||||
|
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="industrial-panel p-5 space-y-4">
|
||||||
|
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
|
||||||
|
<tbody>{list.data?.map(user => (
|
||||||
|
<tr key={user.id} className="border-b border-border/60">
|
||||||
|
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
|
||||||
|
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
|
||||||
|
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
|
||||||
|
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
|
||||||
|
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
|
||||||
|
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
|
||||||
|
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,16 +44,15 @@ services:
|
|||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- 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=宏量点云处理软件
|
||||||
- VITE_OAUTH_PORTAL_URL=disabled
|
|
||||||
- VITE_APP_ID=
|
|
||||||
# 数据目录路径
|
# 数据目录路径
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
|
||||||
# 内网部署禁用OAuth,使用本地认证
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
|
||||||
# MinIO S3兼容存储配置
|
# MinIO S3兼容存储配置
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
@@ -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=宏量点云处理软件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 可选配置
|
## 可选配置
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ USE pointcloud_db;
|
|||||||
-- 用户表
|
-- 用户表
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
openId VARCHAR(64) NOT NULL UNIQUE,
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
passwordHash VARCHAR(255),
|
||||||
name TEXT,
|
name TEXT,
|
||||||
email VARCHAR(320),
|
email VARCHAR(320),
|
||||||
loginMethod VARCHAR(64),
|
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||||
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
|
isActive INT NOT NULL DEFAULT 1,
|
||||||
|
mustChangePassword INT NOT NULL DEFAULT 1,
|
||||||
|
sessionVersion INT NOT NULL DEFAULT 0,
|
||||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
lastLoginAt TIMESTAMP NULL,
|
||||||
INDEX idx_openId (openId),
|
|
||||||
INDEX idx_email (email(255))
|
INDEX idx_email (email(255))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -124,6 +126,22 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` (
|
|||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
|
||||||
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
`modelId` INT NOT NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`fileSize` BIGINT NOT NULL,
|
||||||
|
`filePath` VARCHAR(512) NOT NULL,
|
||||||
|
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
`pointCount` BIGINT NULL,
|
||||||
|
`pointAttributes` JSON NULL,
|
||||||
|
`pointDataFormat` VARCHAR(32) NULL,
|
||||||
|
`bytesPerPoint` INT NULL,
|
||||||
|
`metadataError` TEXT NULL,
|
||||||
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX `idx_model_pointcloud_modelId` (`modelId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`modelId` INT NOT NULL,
|
`modelId` INT NOT NULL,
|
||||||
@@ -143,6 +161,16 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
|||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
`fileSize` BIGINT NOT NULL,
|
`fileSize` BIGINT NOT NULL,
|
||||||
`filePath` VARCHAR(512) NOT NULL,
|
`filePath` VARCHAR(512) NOT NULL,
|
||||||
|
`source` VARCHAR(32) NOT NULL DEFAULT 'uploaded',
|
||||||
|
`sourceResultId` INT NULL,
|
||||||
|
`sourcePointcloudFileId` INT NULL,
|
||||||
|
`sourceModelPointcloudId` INT NULL,
|
||||||
|
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
`pointCount` BIGINT NULL,
|
||||||
|
`pointAttributes` JSON NULL,
|
||||||
|
`pointDataFormat` VARCHAR(32) NULL,
|
||||||
|
`bytesPerPoint` INT NULL,
|
||||||
|
`metadataError` TEXT NULL,
|
||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -158,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
|||||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 创建默认管理员用户 (内网部署使用)
|
|
||||||
INSERT INTO users (openId, name, email, role) VALUES
|
|
||||||
('admin-local', '系统管理员', 'admin@localhost', 'admin')
|
|
||||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
|
||||||
|
|
||||||
-- 输出初始化完成信息
|
-- 输出初始化完成信息
|
||||||
SELECT 'Database initialization completed successfully!' AS message;
|
SELECT 'Database initialization completed successfully!' AS message;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
22
drizzle/0015_local_users_and_roles.sql
Normal file
22
drizzle/0015_local_users_and_roles.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||||
|
ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`,
|
||||||
|
ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`,
|
||||||
|
ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`,
|
||||||
|
ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`,
|
||||||
|
ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`,
|
||||||
|
ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`;
|
||||||
|
|
||||||
|
UPDATE `users`
|
||||||
|
SET `username` = CONCAT('legacy_user_', `id`),
|
||||||
|
`isActive` = 0,
|
||||||
|
`mustChangePassword` = 1,
|
||||||
|
`sessionVersion` = `sessionVersion` + 1;
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY COLUMN `username` VARCHAR(64) NOT NULL,
|
||||||
|
ADD UNIQUE INDEX `users_username_unique` (`username`),
|
||||||
|
DROP INDEX `users_openId_unique`,
|
||||||
|
DROP COLUMN `openId`,
|
||||||
|
DROP COLUMN `loginMethod`,
|
||||||
|
DROP COLUMN `lastSignedIn`;
|
||||||
15
drizzle/0016_pointcloud_metadata.sql
Normal file
15
drizzle/0016_pointcloud_metadata.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
ALTER TABLE `model_pointcloud_files`
|
||||||
|
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN `pointCount` bigint NULL,
|
||||||
|
ADD COLUMN `pointAttributes` json NULL,
|
||||||
|
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||||
|
ADD COLUMN `bytesPerPoint` int NULL,
|
||||||
|
ADD COLUMN `metadataError` text NULL;
|
||||||
|
|
||||||
|
ALTER TABLE `task_pointcloud_files`
|
||||||
|
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN `pointCount` bigint NULL,
|
||||||
|
ADD COLUMN `pointAttributes` json NULL,
|
||||||
|
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||||
|
ADD COLUMN `bytesPerPoint` int NULL,
|
||||||
|
ADD COLUMN `metadataError` text NULL;
|
||||||
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`);
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||||
|
import type { PointAttribute } from "../shared/pointcloudMetadata";
|
||||||
|
|
||||||
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
// ─── 本地用户 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const users = mysqlTable("users", {
|
export const users = mysqlTable("users", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
username: varchar("username", { length: 64 }).notNull().unique(),
|
||||||
|
passwordHash: varchar("passwordHash", { length: 255 }),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
email: varchar("email", { length: 320 }),
|
email: varchar("email", { length: 320 }),
|
||||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
|
||||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
isActive: int("isActive").default(1).notNull(),
|
||||||
|
mustChangePassword: int("mustChangePassword").default(1).notNull(),
|
||||||
|
sessionVersion: int("sessionVersion").default(0).notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
lastLoginAt: timestamp("lastLoginAt"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -46,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 文件(车头、车身、转向架等),低频变更
|
||||||
|
|
||||||
@@ -56,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),
|
||||||
|
}));
|
||||||
|
|
||||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||||
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
||||||
|
|
||||||
@@ -71,6 +103,14 @@ 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(),
|
||||||
|
pointCount: bigint("pointCount", { mode: "number" }),
|
||||||
|
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||||
|
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
|
||||||
|
bytesPerPoint: int("bytesPerPoint"),
|
||||||
|
metadataError: text("metadataError"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,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"),
|
||||||
@@ -136,6 +177,12 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
|
|||||||
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
||||||
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
||||||
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
||||||
|
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||||
|
pointCount: bigint("pointCount", { mode: "number" }),
|
||||||
|
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||||
|
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
|
||||||
|
bytesPerPoint: int("bytesPerPoint"),
|
||||||
|
metadataError: text("metadataError"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise";
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
|
export type Measurement = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface CandidateRow extends RowDataPacket {
|
||||||
|
id: number;
|
||||||
|
taskId: number;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackfillPlan {
|
||||||
|
row: CandidateRow;
|
||||||
|
resultJsonPath: string;
|
||||||
|
originalResultJson: Record<string, unknown>;
|
||||||
|
measurements: Measurement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionSummary {
|
||||||
|
planned: number;
|
||||||
|
applied: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMeasurements<T extends Record<string, unknown>>(
|
||||||
|
value: T | null,
|
||||||
|
measurements: Measurement[],
|
||||||
|
): T & { measurements: Measurement[] } {
|
||||||
|
return { ...(value ?? ({} as T)), measurements };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executePlans(
|
||||||
|
plans: BackfillPlan[],
|
||||||
|
apply: boolean,
|
||||||
|
persist: (plan: BackfillPlan) => Promise<boolean>,
|
||||||
|
): Promise<ExecutionSummary> {
|
||||||
|
const summary: ExecutionSummary = {
|
||||||
|
planned: plans.length,
|
||||||
|
applied: 0,
|
||||||
|
skipped: apply ? 0 : plans.length,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
if (!apply) return summary;
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
try {
|
||||||
|
if (await persist(plan)) summary.applied += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.failed += 1;
|
||||||
|
console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function argumentValue(name: string): string | undefined {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(): string {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseMeasurements(
|
||||||
|
excelPath: string,
|
||||||
|
templatePath: string,
|
||||||
|
helperPath: string,
|
||||||
|
): Promise<Measurement[]> {
|
||||||
|
const python = process.env.PYTHON3_PATH || "/usr/bin/python3";
|
||||||
|
const { stdout } = await execFile(
|
||||||
|
python,
|
||||||
|
[helperPath, "--excel", excelPath, "--template", templatePath],
|
||||||
|
{ maxBuffer: 10 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(stdout) as unknown;
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
throw new Error("解析结果为空")
|
||||||
|
}
|
||||||
|
return parsed.filter((item): item is Measurement => !!item && typeof item === "object");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCandidates(pool: Pool, resultId?: number): Promise<CandidateRow[]> {
|
||||||
|
const filters = [
|
||||||
|
"pluginId = 'flatness-suite'",
|
||||||
|
"status = 'completed'",
|
||||||
|
"(measurements IS NULL OR JSON_LENGTH(measurements) = 0)",
|
||||||
|
];
|
||||||
|
const params: number[] = [];
|
||||||
|
if (resultId !== undefined) {
|
||||||
|
filters.push("id = ?");
|
||||||
|
params.push(resultId);
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query<CandidateRow[]>(
|
||||||
|
`SELECT id, taskId, measurements, resultData
|
||||||
|
FROM task_analysis_results
|
||||||
|
WHERE ${filters.join(" AND ")}
|
||||||
|
ORDER BY id`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPlans(
|
||||||
|
rows: CandidateRow[],
|
||||||
|
dataDir: string,
|
||||||
|
templatePath: string,
|
||||||
|
helperPath: string,
|
||||||
|
): Promise<{ plans: BackfillPlan[]; skipped: number }> {
|
||||||
|
const plans: BackfillPlan[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id));
|
||||||
|
const resultJsonPath = path.join(resultDir, "result.json");
|
||||||
|
const excelPath = path.join(resultDir, "contour.xlsx");
|
||||||
|
try {
|
||||||
|
const [resultText] = await Promise.all([
|
||||||
|
fs.readFile(resultJsonPath, "utf8"),
|
||||||
|
fs.access(excelPath),
|
||||||
|
]);
|
||||||
|
const resultJson = JSON.parse(resultText) as Record<string, unknown>;
|
||||||
|
const extra = resultJson.extra as Record<string, unknown> | undefined;
|
||||||
|
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | undefined;
|
||||||
|
if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") {
|
||||||
|
throw new Error("不是 contour_batch 批量结果")
|
||||||
|
}
|
||||||
|
const measurements = await parseMeasurements(excelPath, templatePath, helperPath);
|
||||||
|
plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements });
|
||||||
|
console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`);
|
||||||
|
} catch (error) {
|
||||||
|
skipped += 1;
|
||||||
|
console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { plans, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
|
||||||
|
const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`;
|
||||||
|
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||||
|
await fs.rename(temporaryPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise<string> {
|
||||||
|
const backupDir = path.join(dataDir, "maintenance-backups");
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`);
|
||||||
|
await writeJsonAtomically(backupPath, {
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
entries: plans.map((plan) => ({
|
||||||
|
id: plan.row.id,
|
||||||
|
taskId: plan.row.taskId,
|
||||||
|
resultJsonPath: plan.resultJsonPath,
|
||||||
|
measurements: plan.row.measurements,
|
||||||
|
resultData: plan.row.resultData,
|
||||||
|
resultJson: plan.originalResultJson,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return backupPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistPlan(pool: Pool, plan: BackfillPlan): Promise<boolean> {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements);
|
||||||
|
const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements);
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, updatedResultJson);
|
||||||
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
|
`UPDATE task_analysis_results
|
||||||
|
SET measurements = ?, resultData = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND pluginId = 'flatness-suite'
|
||||||
|
AND status = 'completed'
|
||||||
|
AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`,
|
||||||
|
[JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id],
|
||||||
|
);
|
||||||
|
if (result.affectedRows !== 1) {
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson);
|
||||||
|
await connection.rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await connection.rollback();
|
||||||
|
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupManifest {
|
||||||
|
entries: Array<{
|
||||||
|
id: number;
|
||||||
|
resultJsonPath: string;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: unknown;
|
||||||
|
resultJson: unknown;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup(pool: Pool, backupPath: string): Promise<void> {
|
||||||
|
const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest;
|
||||||
|
if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效")
|
||||||
|
for (const entry of manifest.entries) {
|
||||||
|
await writeJsonAtomically(entry.resultJsonPath, entry.resultJson);
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'",
|
||||||
|
[
|
||||||
|
entry.measurements == null ? null : JSON.stringify(entry.measurements),
|
||||||
|
entry.resultData == null ? null : JSON.stringify(entry.resultData),
|
||||||
|
entry.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
console.log(`[backfill] 已恢复结果 ${entry.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) throw new Error("缺少 DATABASE_URL")
|
||||||
|
const dataDir = process.env.DATA_DIR || "/data";
|
||||||
|
const algRoot = process.env.ALG_ROOT || "/alg";
|
||||||
|
const resultIdRaw = argumentValue("--result-id");
|
||||||
|
const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw);
|
||||||
|
if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数")
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
const restorePath = argumentValue("--restore");
|
||||||
|
const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx");
|
||||||
|
const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py");
|
||||||
|
const pool = mysql.createPool(databaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (restorePath) {
|
||||||
|
await restoreBackup(pool, restorePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = await listCandidates(pool, resultId);
|
||||||
|
const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath);
|
||||||
|
let backupPath: string | undefined;
|
||||||
|
if (apply && plans.length > 0) {
|
||||||
|
backupPath = await writeBackup(plans, dataDir);
|
||||||
|
console.log(`[backfill] 写入前备份: ${backupPath}`);
|
||||||
|
}
|
||||||
|
const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan));
|
||||||
|
summary.skipped += skipped;
|
||||||
|
console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`);
|
||||||
|
if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入");
|
||||||
|
if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||||
|
if (isMain) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[backfill] 失败:", error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
153
server/_core/auth.ts
Normal file
153
server/_core/auth.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||||
|
import { parse as parseCookieHeader } from "cookie";
|
||||||
|
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import type { User } from "../../drizzle/schema";
|
||||||
|
import * as db from "../db";
|
||||||
|
import { ENV } from "./env";
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback);
|
||||||
|
const PASSWORD_MIN_LENGTH = 8;
|
||||||
|
|
||||||
|
function sessionSecret() {
|
||||||
|
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
|
||||||
|
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(ENV.cookieSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePassword(password: string) {
|
||||||
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
|
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(password: string) {
|
||||||
|
validatePassword(password);
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await scrypt(password, salt, 64) as Buffer;
|
||||||
|
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, encoded: string | null) {
|
||||||
|
if (!encoded) return false;
|
||||||
|
const [scheme, saltValue, hashValue] = encoded.split("$");
|
||||||
|
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
|
||||||
|
try {
|
||||||
|
const expected = Buffer.from(hashValue, "base64url");
|
||||||
|
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
|
||||||
|
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
|
||||||
|
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
|
||||||
|
.sign(sessionSecret());
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionCookie(req: Request) {
|
||||||
|
if (!req.headers.cookie) return undefined;
|
||||||
|
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateRequest(req: Request): Promise<User | null> {
|
||||||
|
const token = readSessionCookie(req);
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
|
||||||
|
const userId = Number(payload.userId);
|
||||||
|
const sessionVersion = Number(payload.sessionVersion);
|
||||||
|
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
|
||||||
|
const user = await db.getUserById(userId);
|
||||||
|
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attempt = { failures: number[] };
|
||||||
|
const attempts = new Map<string, Attempt>();
|
||||||
|
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const RATE_MAX_FAILURES = 5;
|
||||||
|
|
||||||
|
function normalizedUsername(username: string) {
|
||||||
|
return username.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptKeys(ip: string, username: string) {
|
||||||
|
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentFailures(key: string, now = Date.now()) {
|
||||||
|
const record = attempts.get(key);
|
||||||
|
if (!record) return [];
|
||||||
|
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
|
||||||
|
if (record.failures.length === 0) attempts.delete(key);
|
||||||
|
return record.failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLoginRateLimited(ip: string, username: string) {
|
||||||
|
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordLoginFailure(ip: string, username: string) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of attemptKeys(ip, username)) {
|
||||||
|
const failures = recentFailures(key, now);
|
||||||
|
failures.push(now);
|
||||||
|
attempts.set(key, { failures });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLoginFailures(ip: string, username: string) {
|
||||||
|
for (const key of attemptKeys(ip, username)) attempts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLoginRateLimitForTests() {
|
||||||
|
attempts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBootstrapAdmin() {
|
||||||
|
if (await db.countActiveSuperAdmins() > 0) return;
|
||||||
|
const username = ENV.bootstrapAdminUsername.trim();
|
||||||
|
const password = ENV.bootstrapAdminPassword;
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
|
||||||
|
}
|
||||||
|
validatePassword(password);
|
||||||
|
const existing = await db.getUserByUsername(username);
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
if (existing) {
|
||||||
|
await db.updateUser(existing.id, {
|
||||||
|
passwordHash,
|
||||||
|
role: "super_admin",
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 1,
|
||||||
|
sessionVersion: existing.sessionVersion + 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await db.createUser({
|
||||||
|
username,
|
||||||
|
passwordHash,
|
||||||
|
name: "系统管理员",
|
||||||
|
role: "super_admin",
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 1,
|
||||||
|
sessionVersion: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicUser(user: User) {
|
||||||
|
const { passwordHash: _passwordHash, ...safe } = user;
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||||
import type { User } from "../../drizzle/schema";
|
import type { User } from "../../drizzle/schema";
|
||||||
import { sdk } from "./sdk";
|
import { authenticateRequest } from "./auth";
|
||||||
|
|
||||||
export type TrpcContext = {
|
export type TrpcContext = {
|
||||||
req: CreateExpressContextOptions["req"];
|
req: CreateExpressContextOptions["req"];
|
||||||
@@ -14,7 +14,7 @@ export async function createContext(
|
|||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
user = await sdk.authenticateRequest(opts.req);
|
user = await authenticateRequest(opts.req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Authentication is optional for public procedures.
|
// Authentication is optional for public procedures.
|
||||||
user = null;
|
user = null;
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import type { CookieOptions, Request } from "express";
|
import type { CookieOptions, Request } from "express";
|
||||||
|
|
||||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
||||||
|
|
||||||
function isIpAddress(host: string) {
|
|
||||||
// Basic IPv4 check and IPv6 presence detection.
|
|
||||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
|
||||||
return host.includes(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSecureRequest(req: Request) {
|
function isSecureRequest(req: Request) {
|
||||||
if (req.protocol === "https") return true;
|
if (req.protocol === "https") return true;
|
||||||
|
|
||||||
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
|
|||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "none",
|
sameSite: "lax",
|
||||||
secure: isSecureRequest(req),
|
secure: isSecureRequest(req),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
export const ENV = {
|
export const ENV = {
|
||||||
appId: process.env.VITE_APP_ID ?? "",
|
|
||||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
|
||||||
authMode: process.env.AUTH_MODE ?? "oauth",
|
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
|
||||||
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
|
|
||||||
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
|
|
||||||
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
|
|
||||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
|
||||||
isProduction: process.env.NODE_ENV === "production",
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic, setupVite } from "./vite";
|
||||||
import { fileRouter } from "../fileRoutes";
|
import { fileRouter } from "../fileRoutes";
|
||||||
|
import { ensureBootstrapAdmin } from "./auth";
|
||||||
|
|
||||||
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
await runMigrations();
|
||||||
await runMigrations();
|
await ensureBootstrapAdmin();
|
||||||
} catch (e) {
|
|
||||||
console.error("[Migration] Unexpected error, server will still start:", e);
|
|
||||||
}
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
// Configure body parser with larger size limit for file uploads
|
// Configure body parser with larger size limit for file uploads
|
||||||
@@ -128,8 +125,6 @@ async function startServer() {
|
|||||||
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
||||||
server.keepAliveTimeout = 0; // 长连接不超时
|
server.keepAliveTimeout = 0; // 长连接不超时
|
||||||
server.headersTimeout = 0; // 读请求头不超时
|
server.headersTimeout = 0; // 读请求头不超时
|
||||||
// OAuth callback under /api/oauth/callback
|
|
||||||
registerOAuthRoutes(app);
|
|
||||||
// 禁止代理(NPS 等)缓存 API 响应
|
// 禁止代理(NPS 等)缓存 API 响应
|
||||||
app.use('/api', (_req, res, next) => {
|
app.use('/api', (_req, res, next) => {
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|
||||||
import type { Express, Request, Response } from "express";
|
|
||||||
import * as db from "../db";
|
|
||||||
import { getSessionCookieOptions } from "./cookies";
|
|
||||||
import { sdk } from "./sdk";
|
|
||||||
|
|
||||||
function getQueryParam(req: Request, key: string): string | undefined {
|
|
||||||
const value = req.query[key];
|
|
||||||
return typeof value === "string" ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerOAuthRoutes(app: Express) {
|
|
||||||
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
|
|
||||||
const code = getQueryParam(req, "code");
|
|
||||||
const state = getQueryParam(req, "state");
|
|
||||||
|
|
||||||
if (!code || !state) {
|
|
||||||
res.status(400).json({ error: "code and state are required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
|
||||||
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
|
||||||
|
|
||||||
if (!userInfo.openId) {
|
|
||||||
res.status(400).json({ error: "openId missing from user info" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.upsertUser({
|
|
||||||
openId: userInfo.openId,
|
|
||||||
name: userInfo.name || null,
|
|
||||||
email: userInfo.email ?? null,
|
|
||||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
|
||||||
lastSignedIn: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
|
|
||||||
name: userInfo.name || "",
|
|
||||||
expiresInMs: ONE_YEAR_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cookieOptions = getSessionCookieOptions(req);
|
|
||||||
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
|
||||||
|
|
||||||
res.redirect(302, "/");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[OAuth] Callback failed", error);
|
|
||||||
res.status(500).json({ error: "OAuth callback failed" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|
||||||
import { ForbiddenError } from "@shared/_core/errors";
|
|
||||||
import axios, { type AxiosInstance } from "axios";
|
|
||||||
import { parse as parseCookieHeader } from "cookie";
|
|
||||||
import type { Request } from "express";
|
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
|
||||||
import type { User } from "../../drizzle/schema";
|
|
||||||
import * as db from "../db";
|
|
||||||
import { ENV } from "./env";
|
|
||||||
import type {
|
|
||||||
ExchangeTokenRequest,
|
|
||||||
ExchangeTokenResponse,
|
|
||||||
GetUserInfoResponse,
|
|
||||||
GetUserInfoWithJwtRequest,
|
|
||||||
GetUserInfoWithJwtResponse,
|
|
||||||
} from "./types/manusTypes";
|
|
||||||
// Utility function
|
|
||||||
const isNonEmptyString = (value: unknown): value is string =>
|
|
||||||
typeof value === "string" && value.length > 0;
|
|
||||||
|
|
||||||
export type SessionPayload = {
|
|
||||||
openId: string;
|
|
||||||
appId: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
|
||||||
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
|
|
||||||
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
|
|
||||||
|
|
||||||
class OAuthService {
|
|
||||||
constructor(private client: ReturnType<typeof axios.create>) {
|
|
||||||
const isLocalAuth = ENV.authMode === "local";
|
|
||||||
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
|
|
||||||
if (!ENV.oAuthServerUrl && !isLocalAuth) {
|
|
||||||
console.error(
|
|
||||||
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
|
|
||||||
);
|
|
||||||
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
|
|
||||||
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeState(state: string): string {
|
|
||||||
const redirectUri = atob(state);
|
|
||||||
return redirectUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTokenByCode(
|
|
||||||
code: string,
|
|
||||||
state: string
|
|
||||||
): Promise<ExchangeTokenResponse> {
|
|
||||||
const payload: ExchangeTokenRequest = {
|
|
||||||
clientId: ENV.appId,
|
|
||||||
grantType: "authorization_code",
|
|
||||||
code,
|
|
||||||
redirectUri: this.decodeState(state),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data } = await this.client.post<ExchangeTokenResponse>(
|
|
||||||
EXCHANGE_TOKEN_PATH,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfoByToken(
|
|
||||||
token: ExchangeTokenResponse
|
|
||||||
): Promise<GetUserInfoResponse> {
|
|
||||||
const { data } = await this.client.post<GetUserInfoResponse>(
|
|
||||||
GET_USER_INFO_PATH,
|
|
||||||
{
|
|
||||||
accessToken: token.accessToken,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createOAuthHttpClient = (): AxiosInstance =>
|
|
||||||
axios.create({
|
|
||||||
baseURL: ENV.oAuthServerUrl,
|
|
||||||
timeout: AXIOS_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
class SDKServer {
|
|
||||||
private readonly client: AxiosInstance;
|
|
||||||
private readonly oauthService: OAuthService;
|
|
||||||
|
|
||||||
constructor(client: AxiosInstance = createOAuthHttpClient()) {
|
|
||||||
this.client = client;
|
|
||||||
this.oauthService = new OAuthService(this.client);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLocalAuthMode() {
|
|
||||||
return ENV.authMode === "local";
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveLoginMethod(
|
|
||||||
platforms: unknown,
|
|
||||||
fallback: string | null | undefined
|
|
||||||
): string | null {
|
|
||||||
if (fallback && fallback.length > 0) return fallback;
|
|
||||||
if (!Array.isArray(platforms) || platforms.length === 0) return null;
|
|
||||||
const set = new Set<string>(
|
|
||||||
platforms.filter((p): p is string => typeof p === "string")
|
|
||||||
);
|
|
||||||
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
|
|
||||||
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
|
|
||||||
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
|
|
||||||
if (
|
|
||||||
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
|
|
||||||
set.has("REGISTERED_PLATFORM_AZURE")
|
|
||||||
)
|
|
||||||
return "microsoft";
|
|
||||||
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
|
|
||||||
const first = Array.from(set)[0];
|
|
||||||
return first ? first.toLowerCase() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange OAuth authorization code for access token
|
|
||||||
* @example
|
|
||||||
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
|
||||||
*/
|
|
||||||
async exchangeCodeForToken(
|
|
||||||
code: string,
|
|
||||||
state: string
|
|
||||||
): Promise<ExchangeTokenResponse> {
|
|
||||||
return this.oauthService.getTokenByCode(code, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user information using access token
|
|
||||||
* @example
|
|
||||||
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
|
||||||
*/
|
|
||||||
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
|
|
||||||
const data = await this.oauthService.getUserInfoByToken({
|
|
||||||
accessToken,
|
|
||||||
} as ExchangeTokenResponse);
|
|
||||||
const loginMethod = this.deriveLoginMethod(
|
|
||||||
(data as any)?.platforms,
|
|
||||||
(data as any)?.platform ?? data.platform ?? null
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...(data as any),
|
|
||||||
platform: loginMethod,
|
|
||||||
loginMethod,
|
|
||||||
} as GetUserInfoResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseCookies(cookieHeader: string | undefined) {
|
|
||||||
if (!cookieHeader) {
|
|
||||||
return new Map<string, string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseCookieHeader(cookieHeader);
|
|
||||||
return new Map(Object.entries(parsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSessionSecret() {
|
|
||||||
const secret = ENV.cookieSecret;
|
|
||||||
return new TextEncoder().encode(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a session token for a Manus user openId
|
|
||||||
* @example
|
|
||||||
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
|
|
||||||
*/
|
|
||||||
async createSessionToken(
|
|
||||||
openId: string,
|
|
||||||
options: { expiresInMs?: number; name?: string } = {}
|
|
||||||
): Promise<string> {
|
|
||||||
return this.signSession(
|
|
||||||
{
|
|
||||||
openId,
|
|
||||||
appId: ENV.appId,
|
|
||||||
name: options.name || "",
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async signSession(
|
|
||||||
payload: SessionPayload,
|
|
||||||
options: { expiresInMs?: number } = {}
|
|
||||||
): Promise<string> {
|
|
||||||
const issuedAt = Date.now();
|
|
||||||
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
|
||||||
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
|
||||||
const secretKey = this.getSessionSecret();
|
|
||||||
|
|
||||||
return new SignJWT({
|
|
||||||
openId: payload.openId,
|
|
||||||
appId: payload.appId,
|
|
||||||
name: payload.name,
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
|
||||||
.setExpirationTime(expirationSeconds)
|
|
||||||
.sign(secretKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifySession(
|
|
||||||
cookieValue: string | undefined | null
|
|
||||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
|
||||||
if (!cookieValue) {
|
|
||||||
console.warn("[Auth] Missing session cookie");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const secretKey = this.getSessionSecret();
|
|
||||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
|
||||||
algorithms: ["HS256"],
|
|
||||||
});
|
|
||||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isNonEmptyString(openId) ||
|
|
||||||
!isNonEmptyString(appId) ||
|
|
||||||
!isNonEmptyString(name)
|
|
||||||
) {
|
|
||||||
console.warn("[Auth] Session payload missing required fields");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openId,
|
|
||||||
appId,
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[Auth] Session verification failed", String(error));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserInfoWithJwt(
|
|
||||||
jwtToken: string
|
|
||||||
): Promise<GetUserInfoWithJwtResponse> {
|
|
||||||
const payload: GetUserInfoWithJwtRequest = {
|
|
||||||
jwtToken,
|
|
||||||
projectId: ENV.appId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
|
|
||||||
GET_USER_INFO_WITH_JWT_PATH,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginMethod = this.deriveLoginMethod(
|
|
||||||
(data as any)?.platforms,
|
|
||||||
(data as any)?.platform ?? data.platform ?? null
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...(data as any),
|
|
||||||
platform: loginMethod,
|
|
||||||
loginMethod,
|
|
||||||
} as GetUserInfoWithJwtResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
async authenticateRequest(req: Request): Promise<User> {
|
|
||||||
if (this.isLocalAuthMode()) {
|
|
||||||
const signedInAt = new Date();
|
|
||||||
const localOpenId = ENV.localDevOpenId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.upsertUser({
|
|
||||||
openId: localOpenId,
|
|
||||||
name: ENV.localDevName,
|
|
||||||
email: ENV.localDevEmail,
|
|
||||||
loginMethod: "local",
|
|
||||||
lastSignedIn: signedInAt,
|
|
||||||
});
|
|
||||||
const localUser = await db.getUserByOpenId(localOpenId);
|
|
||||||
if (localUser) return localUser;
|
|
||||||
} catch {
|
|
||||||
// DB not ready yet (e.g. MySQL still starting up); fall through to
|
|
||||||
// an in-memory guest user so the UI keeps working.
|
|
||||||
console.warn("[Auth] DB unavailable in local mode, using fallback user");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: return a transient in-memory user so requests don't fail
|
|
||||||
// while MySQL is warming up.
|
|
||||||
return {
|
|
||||||
id: 0,
|
|
||||||
openId: localOpenId,
|
|
||||||
name: ENV.localDevName ?? "Dev User",
|
|
||||||
email: ENV.localDevEmail ?? null,
|
|
||||||
loginMethod: "local",
|
|
||||||
role: "admin" as const,
|
|
||||||
createdAt: signedInAt,
|
|
||||||
updatedAt: signedInAt,
|
|
||||||
lastSignedIn: signedInAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular authentication flow
|
|
||||||
const cookies = this.parseCookies(req.headers.cookie);
|
|
||||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
|
||||||
const session = await this.verifySession(sessionCookie);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw ForbiddenError("Invalid session cookie");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUserId = session.openId;
|
|
||||||
const signedInAt = new Date();
|
|
||||||
let user = await db.getUserByOpenId(sessionUserId);
|
|
||||||
|
|
||||||
// If user not in DB, sync from OAuth server automatically
|
|
||||||
if (!user) {
|
|
||||||
try {
|
|
||||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
|
||||||
await db.upsertUser({
|
|
||||||
openId: userInfo.openId,
|
|
||||||
name: userInfo.name || null,
|
|
||||||
email: userInfo.email ?? null,
|
|
||||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
|
||||||
lastSignedIn: signedInAt,
|
|
||||||
});
|
|
||||||
user = await db.getUserByOpenId(userInfo.openId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Auth] Failed to sync user from OAuth:", error);
|
|
||||||
throw ForbiddenError("Failed to sync user info");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw ForbiddenError("User not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.upsertUser({
|
|
||||||
openId: user.openId,
|
|
||||||
lastSignedIn: signedInAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sdk = new SDKServer();
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import type { TrpcContext } from "./context";
|
import type { TrpcContext } from "./context";
|
||||||
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
|
|||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
const requireUser = t.middleware(async opts => {
|
const requireSession = t.middleware(async opts => {
|
||||||
const { ctx, next } = opts;
|
const { ctx, next } = opts;
|
||||||
|
|
||||||
if (!ctx.user) {
|
if (!ctx.user) {
|
||||||
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const protectedProcedure = t.procedure.use(requireUser);
|
export const sessionProcedure = t.procedure.use(requireSession);
|
||||||
|
|
||||||
|
const requireReadableUser = t.middleware(async opts => {
|
||||||
|
if (!opts.ctx.user) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||||
|
}
|
||||||
|
if (opts.ctx.user.mustChangePassword === 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||||
|
}
|
||||||
|
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||||
|
}
|
||||||
|
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const protectedProcedure = t.procedure.use(requireReadableUser);
|
||||||
|
|
||||||
export const adminProcedure = t.procedure.use(
|
export const adminProcedure = t.procedure.use(
|
||||||
t.middleware(async opts => {
|
t.middleware(async opts => {
|
||||||
const { ctx, next } = opts;
|
const { ctx, next } = opts;
|
||||||
|
|
||||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
if (!ctx.user) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||||
|
}
|
||||||
|
if (ctx.user.mustChangePassword === 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||||
|
}
|
||||||
|
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
|
||||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const superAdminProcedure = t.procedure.use(
|
||||||
|
t.middleware(async opts => {
|
||||||
|
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||||
|
if (opts.ctx.user.mustChangePassword === 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||||
|
}
|
||||||
|
if (opts.ctx.user.role !== "super_admin") {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||||
|
}
|
||||||
|
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// WebDev Auth TypeScript types
|
|
||||||
// Auto-generated from protobuf definitions
|
|
||||||
// Generated on: 2025-09-24T05:57:57.338Z
|
|
||||||
|
|
||||||
export interface AuthorizeRequest {
|
|
||||||
redirectUri: string;
|
|
||||||
projectId: string;
|
|
||||||
state: string;
|
|
||||||
responseType: string;
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthorizeResponse {
|
|
||||||
redirectUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExchangeTokenRequest {
|
|
||||||
grantType: string;
|
|
||||||
code: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret?: string;
|
|
||||||
redirectUri: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExchangeTokenResponse {
|
|
||||||
accessToken: string;
|
|
||||||
tokenType: string;
|
|
||||||
expiresIn: number;
|
|
||||||
refreshToken?: string;
|
|
||||||
scope: string;
|
|
||||||
idToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetUserInfoRequest {
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetUserInfoResponse {
|
|
||||||
openId: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
email?: string | null;
|
|
||||||
platform?: string | null;
|
|
||||||
loginMethod?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CanAccessRequest {
|
|
||||||
openId: string;
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CanAccessResponse {
|
|
||||||
canAccess: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetUserInfoWithJwtRequest {
|
|
||||||
jwtToken: string;
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetUserInfoWithJwtResponse {
|
|
||||||
openId: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
email?: string | null;
|
|
||||||
platform?: string | null;
|
|
||||||
loginMethod?: string | null;
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
@@ -122,6 +133,9 @@ async function writeInputJson(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
pointCloudPaths: string[],
|
pointCloudPaths: string[],
|
||||||
cadPath: string | null,
|
cadPath: string | null,
|
||||||
|
inputPointCloudFileIds: number[],
|
||||||
|
inputCadFileIds: number[],
|
||||||
|
inspectionContext: InspectionObjectContext,
|
||||||
) {
|
) {
|
||||||
const inputJson = {
|
const inputJson = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -131,9 +145,12 @@ async function writeInputJson(
|
|||||||
taskId,
|
taskId,
|
||||||
workDir,
|
workDir,
|
||||||
outDir: workDir,
|
outDir: workDir,
|
||||||
|
context: inspectionContext,
|
||||||
inputs: {
|
inputs: {
|
||||||
pointCloudPaths,
|
pointCloudPaths,
|
||||||
|
pointCloudFileIds: inputPointCloudFileIds,
|
||||||
cadPath,
|
cadPath,
|
||||||
|
cadFileIds: inputCadFileIds,
|
||||||
config: params,
|
config: params,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -183,6 +200,11 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
|
|||||||
'demoFlowTotal',
|
'demoFlowTotal',
|
||||||
'demoFlowSourcePointCloudFileId',
|
'demoFlowSourcePointCloudFileId',
|
||||||
'demoFlowCadFileId',
|
'demoFlowCadFileId',
|
||||||
|
'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>> {
|
||||||
@@ -325,12 +347,15 @@ 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,
|
||||||
cadFileIds,
|
cadFileIds,
|
||||||
);
|
);
|
||||||
|
const inputPointCloudFileIds = pointCloudRecords.map((r) => r.id);
|
||||||
|
const inputCadFileIds = Array.isArray(cadFileIds) ? cadFileIds.filter((id) => Number.isInteger(id)) : [];
|
||||||
|
|
||||||
const inputJsonPath = await writeInputJson(
|
const inputJsonPath = await writeInputJson(
|
||||||
workDir,
|
workDir,
|
||||||
taskId,
|
taskId,
|
||||||
@@ -339,6 +364,9 @@ export async function runAlgorithmJob(
|
|||||||
params,
|
params,
|
||||||
pointCloudPaths,
|
pointCloudPaths,
|
||||||
cadPath,
|
cadPath,
|
||||||
|
inputPointCloudFileIds,
|
||||||
|
inputCadFileIds,
|
||||||
|
inspectionContext,
|
||||||
);
|
);
|
||||||
|
|
||||||
let exitCode: number | null = 0;
|
let exitCode: number | null = 0;
|
||||||
@@ -357,21 +385,45 @@ 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,
|
||||||
progress: finalStatus === 'completed' ? 1 : undefined,
|
progress: finalStatus === 'completed' ? 1 : undefined,
|
||||||
stage: finalStatus === 'completed' ? '完成' : '失败',
|
stage: finalStatus === 'completed' ? '完成' : '失败',
|
||||||
exitCode,
|
exitCode,
|
||||||
|
inputPointCloudFileIds,
|
||||||
|
inputCadFileIds,
|
||||||
// 把 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,
|
||||||
@@ -381,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
57
server/analysisArtifacts.test.ts
Normal file
57
server/analysisArtifacts.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { cleanupAnalysisArtifacts, getAnalysisArtifactPaths } from "./analysisArtifacts";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function makeDataDir() {
|
||||||
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "analysis-artifacts-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupAnalysisArtifacts", () => {
|
||||||
|
it("deletes the plugin directory and all legacy files for only the requested result", async () => {
|
||||||
|
const dataDir = await makeDataDir();
|
||||||
|
const analysisDir = path.join(dataDir, "tasks", "7", "analysis");
|
||||||
|
await fs.promises.mkdir(path.join(analysisDir, "57"), { recursive: true });
|
||||||
|
await fs.promises.mkdir(path.join(analysisDir, "58"), { recursive: true });
|
||||||
|
const files = [
|
||||||
|
"heatmap_57.bin",
|
||||||
|
"deviation_57.bin",
|
||||||
|
"assembly_vertex_57_car-body.bin",
|
||||||
|
"assembly_vertex_57_bogie.bin",
|
||||||
|
"heatmap_58.bin",
|
||||||
|
"assembly_vertex_570_other.bin",
|
||||||
|
];
|
||||||
|
await Promise.all(files.map((name) => fs.promises.writeFile(path.join(analysisDir, name), name)));
|
||||||
|
|
||||||
|
await cleanupAnalysisArtifacts(7, 57, dataDir);
|
||||||
|
|
||||||
|
await expect(fs.promises.stat(path.join(analysisDir, "57"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
for (const name of files.slice(0, 4)) {
|
||||||
|
await expect(fs.promises.stat(path.join(analysisDir, name))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
}
|
||||||
|
await expect(fs.promises.stat(path.join(analysisDir, "58"))).resolves.toBeDefined();
|
||||||
|
await expect(fs.promises.stat(path.join(analysisDir, "heatmap_58.bin"))).resolves.toBeDefined();
|
||||||
|
await expect(fs.promises.stat(path.join(analysisDir, "assembly_vertex_570_other.bin"))).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid IDs before constructing a filesystem target", () => {
|
||||||
|
expect(() => getAnalysisArtifactPaths(7, Number.NaN, "/data")).toThrow("resultId");
|
||||||
|
expect(() => getAnalysisArtifactPaths(7, -1, "/data")).toThrow("resultId");
|
||||||
|
expect(() => getAnalysisArtifactPaths(1.5, 2, "/data")).toThrow("taskId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when artifacts are already absent", async () => {
|
||||||
|
const dataDir = await makeDataDir();
|
||||||
|
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
|
||||||
|
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
server/analysisArtifacts.ts
Normal file
68
server/analysisArtifacts.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export type AnalysisArtifactPaths = {
|
||||||
|
analysisDir: string;
|
||||||
|
resultDir: string;
|
||||||
|
legacyFiles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertNumericId(name: string, value: number): void {
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`${name} must be a positive safe integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnalysisArtifactPaths(
|
||||||
|
taskId: number,
|
||||||
|
resultId: number,
|
||||||
|
dataDir = process.env.DATA_DIR || "/data",
|
||||||
|
): AnalysisArtifactPaths {
|
||||||
|
assertNumericId("taskId", taskId);
|
||||||
|
assertNumericId("resultId", resultId);
|
||||||
|
|
||||||
|
const tasksDir = path.resolve(dataDir, "tasks");
|
||||||
|
const taskDir = path.resolve(tasksDir, String(taskId));
|
||||||
|
const analysisDir = path.resolve(taskDir, "analysis");
|
||||||
|
const resultDir = path.resolve(analysisDir, String(resultId));
|
||||||
|
|
||||||
|
if (path.dirname(taskDir) !== tasksDir || path.dirname(resultDir) !== analysisDir) {
|
||||||
|
throw new Error("analysis artifact path escaped its fixed root");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
analysisDir,
|
||||||
|
resultDir,
|
||||||
|
legacyFiles: [
|
||||||
|
path.join(analysisDir, `heatmap_${resultId}.bin`),
|
||||||
|
path.join(analysisDir, `deviation_${resultId}.bin`),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupAnalysisArtifacts(
|
||||||
|
taskId: number,
|
||||||
|
resultId: number,
|
||||||
|
dataDir = process.env.DATA_DIR || "/data",
|
||||||
|
): Promise<void> {
|
||||||
|
const paths = getAnalysisArtifactPaths(taskId, resultId, dataDir);
|
||||||
|
await fs.promises.rm(paths.resultDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(paths.analysisDir);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assemblyPrefix = `assembly_vertex_${resultId}_`;
|
||||||
|
const assemblyFiles = entries
|
||||||
|
.filter((name) => name.startsWith(assemblyPrefix) && name.endsWith(".bin"))
|
||||||
|
.map((name) => path.join(paths.analysisDir, name));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...paths.legacyFiles, ...assemblyFiles].map((file) =>
|
||||||
|
fs.promises.rm(file, { force: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
server/analysisResultDeletion.test.ts
Normal file
49
server/analysisResultDeletion.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as db from "./db";
|
||||||
|
import { cancelAlgorithmJob } from "./algorithmJob";
|
||||||
|
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
|
||||||
|
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
|
||||||
|
import { deleteAnalysisResult } from "./analysisResultDeletion";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getTaskAnalysisResultById: vi.fn(),
|
||||||
|
deleteTaskAnalysisResultWithDerivedPointclouds: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("./algorithmJob", () => ({ cancelAlgorithmJob: vi.fn() }));
|
||||||
|
vi.mock("./analysisJobControl", () => ({ cancelLegacyAnalysisJob: vi.fn() }));
|
||||||
|
vi.mock("./analysisArtifacts", () => ({ cleanupAnalysisArtifacts: vi.fn() }));
|
||||||
|
|
||||||
|
const row = { id: 57, taskId: 7 } as Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>;
|
||||||
|
|
||||||
|
describe("deleteAnalysisResult", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValue(row);
|
||||||
|
vi.mocked(cleanupAnalysisArtifacts).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels jobs, removes files, then transactionally cascades derived pointcloud rows", async () => {
|
||||||
|
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
|
||||||
|
expect(cancelAlgorithmJob).toHaveBeenCalledWith(57);
|
||||||
|
expect(cancelLegacyAnalysisJob).toHaveBeenCalledWith(57);
|
||||||
|
expect(cleanupAnalysisArtifacts).toHaveBeenCalledWith(7, 57);
|
||||||
|
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).toHaveBeenCalledWith(57);
|
||||||
|
expect(vi.mocked(cleanupAnalysisArtifacts).mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps database rows when filesystem cleanup fails", async () => {
|
||||||
|
vi.mocked(cleanupAnalysisArtifacts).mockRejectedValueOnce(new Error("permission denied"));
|
||||||
|
await expect(deleteAnalysisResult(57)).rejects.toThrow("permission denied");
|
||||||
|
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats deletion of an already missing result as success", async () => {
|
||||||
|
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValueOnce(undefined);
|
||||||
|
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
|
||||||
|
expect(cleanupAnalysisArtifacts).not.toHaveBeenCalled();
|
||||||
|
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
18
server/analysisResultDeletion.ts
Normal file
18
server/analysisResultDeletion.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as db from "./db";
|
||||||
|
import { cancelAlgorithmJob } from "./algorithmJob";
|
||||||
|
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
|
||||||
|
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
|
||||||
|
|
||||||
|
export async function deleteAnalysisResult(id: number): Promise<{ success: true }> {
|
||||||
|
const row = await db.getTaskAnalysisResultById(id);
|
||||||
|
if (!row) return { success: true };
|
||||||
|
|
||||||
|
cancelAlgorithmJob(id);
|
||||||
|
cancelLegacyAnalysisJob(id);
|
||||||
|
|
||||||
|
// 文件删除失败时不触碰数据库,保留记录以便用户重试。
|
||||||
|
await cleanupAnalysisArtifacts(row.taskId, row.id);
|
||||||
|
await db.deleteTaskAnalysisResultWithDerivedPointclouds(id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
|
|||||||
|
|
||||||
const user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
openId: "sample-user",
|
username: "sample-user",
|
||||||
|
passwordHash: null,
|
||||||
email: "sample@example.com",
|
email: "sample@example.com",
|
||||||
name: "Sample User",
|
name: "Sample User",
|
||||||
loginMethod: "manus",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
lastSignedIn: new Date(),
|
lastLoginAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx: TrpcContext = {
|
const ctx: TrpcContext = {
|
||||||
@@ -54,7 +57,7 @@ describe("auth.logout", () => {
|
|||||||
expect(clearedCookies[0]?.options).toMatchObject({
|
expect(clearedCookies[0]?.options).toMatchObject({
|
||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: "none",
|
sameSite: "lax",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|||||||
32
server/auth.security.test.ts
Normal file
32
server/auth.security.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
isLoginRateLimited,
|
||||||
|
recordLoginFailure,
|
||||||
|
resetLoginRateLimitForTests,
|
||||||
|
verifyPassword,
|
||||||
|
} from "./_core/auth";
|
||||||
|
|
||||||
|
describe("本地密码认证", () => {
|
||||||
|
beforeEach(() => resetLoginRateLimitForTests());
|
||||||
|
|
||||||
|
it("使用带随机盐的 scrypt 哈希校验密码", async () => {
|
||||||
|
const first = await hashPassword("correct-password");
|
||||||
|
const second = await hashPassword("correct-password");
|
||||||
|
expect(first).toMatch(/^scrypt\$/);
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
await expect(verifyPassword("correct-password", first)).resolves.toBe(true);
|
||||||
|
await expect(verifyPassword("wrong-password", first)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝少于 8 位的密码", async () => {
|
||||||
|
await expect(hashPassword("short")).rejects.toThrow("至少需要 8 位");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("分别按 IP 和用户名限制连续失败", () => {
|
||||||
|
for (let i = 0; i < 5; i += 1) recordLoginFailure("10.0.0.1", "alice");
|
||||||
|
expect(isLoginRateLimited("10.0.0.1", "another-user")).toBe(true);
|
||||||
|
expect(isLoginRateLimited("another-ip", "ALICE")).toBe(true);
|
||||||
|
expect(isLoginRateLimited("another-ip", "bob")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
server/bootstrap-admin.test.ts
Normal file
37
server/bootstrap-admin.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
countActiveSuperAdmins: vi.fn(),
|
||||||
|
getUserByUsername: vi.fn(),
|
||||||
|
createUser: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as db from "./db";
|
||||||
|
import { ensureBootstrapAdmin } from "./_core/auth";
|
||||||
|
import { ENV } from "./_core/env";
|
||||||
|
|
||||||
|
describe("引导超级管理员", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
ENV.bootstrapAdminUsername = "bootstrap-test";
|
||||||
|
ENV.bootstrapAdminPassword = "bootstrap-password";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不存在有效超级管理员时只创建一次", async () => {
|
||||||
|
vi.mocked(db.countActiveSuperAdmins).mockResolvedValueOnce(0).mockResolvedValueOnce(1);
|
||||||
|
vi.mocked(db.getUserByUsername).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(db.createUser).mockResolvedValue({} as never);
|
||||||
|
await ensureBootstrapAdmin();
|
||||||
|
await ensureBootstrapAdmin();
|
||||||
|
expect(db.createUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(db.createUser).toHaveBeenCalledWith(expect.objectContaining({ role: "super_admin", isActive: 1, mustChangePassword: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("无有效超级管理员且缺少变量时启动失败", async () => {
|
||||||
|
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(0);
|
||||||
|
ENV.bootstrapAdminUsername = "";
|
||||||
|
ENV.bootstrapAdminPassword = "";
|
||||||
|
await expect(ensureBootstrapAdmin()).rejects.toThrow("BOOTSTRAP_ADMIN_USERNAME");
|
||||||
|
});
|
||||||
|
});
|
||||||
569
server/db.ts
569
server/db.ts
@@ -1,18 +1,20 @@
|
|||||||
import { eq, desc, inArray, sql, and } from "drizzle-orm";
|
import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import type { ResultSetHeader } from "mysql2";
|
import type { ResultSetHeader } from "mysql2";
|
||||||
|
import { inspectPointcloudMetadata } from "./pointcloudMetadata.js";
|
||||||
import {
|
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,
|
||||||
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
||||||
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
@@ -80,60 +82,67 @@ export async function getDb() {
|
|||||||
|
|
||||||
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
export async function getUserById(id: number) {
|
||||||
if (!user.openId) throw new Error("User openId is required for upsert");
|
|
||||||
|
|
||||||
const db = await getDb();
|
|
||||||
if (!db) {
|
|
||||||
console.warn("[Database] Cannot upsert user: database not available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const values: InsertUser = { openId: user.openId };
|
|
||||||
const updateSet: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
const textFields = ["name", "email", "loginMethod"] as const;
|
|
||||||
type TextField = (typeof textFields)[number];
|
|
||||||
|
|
||||||
const assignNullable = (field: TextField) => {
|
|
||||||
const value = user[field];
|
|
||||||
if (value === undefined) return;
|
|
||||||
const normalized = value ?? null;
|
|
||||||
values[field] = normalized;
|
|
||||||
updateSet[field] = normalized;
|
|
||||||
};
|
|
||||||
textFields.forEach(assignNullable);
|
|
||||||
|
|
||||||
if (user.lastSignedIn !== undefined) {
|
|
||||||
values.lastSignedIn = user.lastSignedIn;
|
|
||||||
updateSet.lastSignedIn = user.lastSignedIn;
|
|
||||||
}
|
|
||||||
if (user.role !== undefined) {
|
|
||||||
values.role = user.role;
|
|
||||||
updateSet.role = user.role;
|
|
||||||
} else if (user.openId === ENV.ownerOpenId) {
|
|
||||||
values.role = 'admin';
|
|
||||||
updateSet.role = 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
|
||||||
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
|
||||||
|
|
||||||
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Database] Failed to upsert user:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserByOpenId(openId: string) {
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
if (!db) return undefined;
|
||||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||||
return result[0];
|
return result[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const result = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(search?: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return [];
|
||||||
|
const term = search?.trim();
|
||||||
|
if (!term) return db.select().from(users).orderBy(desc(users.createdAt));
|
||||||
|
const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
||||||
|
return db.select().from(users)
|
||||||
|
.where(or(like(users.username, pattern), like(users.name, pattern), like(users.email, pattern)))
|
||||||
|
.orderBy(desc(users.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: InsertUser) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
const result = await db.insert(users).values(data);
|
||||||
|
return getUserById(getMysqlInsertId(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: number, data: Partial<InsertUser>) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(users).set(data).where(eq(users.id, id));
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementUserSessionVersion(id: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(users)
|
||||||
|
.set({ sessionVersion: sql`${users.sessionVersion} + 1` })
|
||||||
|
.where(eq(users.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countActiveSuperAdmins() {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users)
|
||||||
|
.where(and(eq(users.role, "super_admin"), eq(users.isActive, 1)));
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 车系 ─────────────────────────────────────────────────────────────────────
|
// ─── 车系 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
|
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
|
||||||
@@ -199,6 +208,192 @@ export async function deleteVehicleModel(id: number): Promise<void> {
|
|||||||
await db.delete(vehicleModels).where(eq(vehicleModels.id, id));
|
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> {
|
||||||
@@ -217,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;
|
||||||
@@ -227,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,17 +452,72 @@ 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>>();
|
||||||
|
|
||||||
|
async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||||||
|
if (row.metadataStatus !== "pending") return row;
|
||||||
|
const existing = modelMetadataBackfills.get(row.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const backfill = (async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return row;
|
||||||
|
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
|
||||||
|
await db.update(modelPointcloudFiles)
|
||||||
|
.set(metadata)
|
||||||
|
.where(and(
|
||||||
|
eq(modelPointcloudFiles.id, row.id),
|
||||||
|
eq(modelPointcloudFiles.metadataStatus, "pending"),
|
||||||
|
));
|
||||||
|
return { ...row, ...metadata };
|
||||||
|
})();
|
||||||
|
modelMetadataBackfills.set(row.id, backfill);
|
||||||
|
try {
|
||||||
|
return await backfill;
|
||||||
|
} finally {
|
||||||
|
modelMetadataBackfills.delete(row.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
|
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db.select().from(modelPointcloudFiles)
|
const rows = await db.select().from(modelPointcloudFiles)
|
||||||
.where(eq(modelPointcloudFiles.modelId, modelId))
|
.where(eq(modelPointcloudFiles.modelId, modelId))
|
||||||
.orderBy(desc(modelPointcloudFiles.createdAt));
|
.orderBy(desc(modelPointcloudFiles.createdAt));
|
||||||
|
return Promise.all(rows.map(backfillModelPointcloudMetadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModelPointcloudFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise<ModelPointcloudFile[]> {
|
||||||
|
const rows = await listModelPointcloudFiles(modelId);
|
||||||
|
return rows.filter(row => row.inspectionObjectId === inspectionObjectId && row.status === "available");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignModelPointcloudInspectionObject(id: number, inspectionObjectId: number): Promise<ModelPointcloudFile> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
return db.transaction(async tx => {
|
||||||
|
const [object] = await tx.select().from(inspectionObjects)
|
||||||
|
.where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share");
|
||||||
|
const [file] = await tx.select().from(modelPointcloudFiles)
|
||||||
|
.where(eq(modelPointcloudFiles.id, id)).limit(1).for("update");
|
||||||
|
if (!file || !object || file.modelId !== object.modelId) throw new Error("Inspection object does not belong to file model");
|
||||||
|
await tx.update(modelPointcloudFiles).set({ inspectionObjectId }).where(eq(modelPointcloudFiles.id, id));
|
||||||
|
const [updated] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
||||||
@@ -255,9 +527,48 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findModelPointcloudFileByModelAndName(
|
||||||
|
modelId: number,
|
||||||
|
name: string,
|
||||||
|
excludeId?: number,
|
||||||
|
): Promise<ModelPointcloudFile | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const conditions = [
|
||||||
|
eq(modelPointcloudFiles.modelId, modelId),
|
||||||
|
sql`lower(${modelPointcloudFiles.name}) = lower(${name})`,
|
||||||
|
];
|
||||||
|
if (excludeId !== undefined) {
|
||||||
|
conditions.push(sql`${modelPointcloudFiles.id} <> ${excludeId}`);
|
||||||
|
}
|
||||||
|
const [row] = await db.select().from(modelPointcloudFiles).where(and(...conditions)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModelPointcloudFileName(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
): Promise<ModelPointcloudFile> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(modelPointcloudFiles).set({ name }).where(eq(modelPointcloudFiles.id, id));
|
||||||
|
const row = await getModelPointcloudFileById(id);
|
||||||
|
if (!row) throw new Error("Model pointcloud file disappeared after update");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,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,
|
||||||
@@ -342,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;
|
||||||
@@ -417,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;
|
||||||
@@ -448,6 +816,7 @@ export async function createRegisteredPointcloudFile(
|
|||||||
): Promise<TaskPointcloudFile> {
|
): Promise<TaskPointcloudFile> {
|
||||||
const fs = await import('node:fs');
|
const fs = await import('node:fs');
|
||||||
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
||||||
|
const metadata = inspectPointcloudMetadata(filePath, name);
|
||||||
return createTaskPointcloudFile({
|
return createTaskPointcloudFile({
|
||||||
taskId,
|
taskId,
|
||||||
name,
|
name,
|
||||||
@@ -456,15 +825,81 @@ export async function createRegisteredPointcloudFile(
|
|||||||
source: 'registered',
|
source: 'registered',
|
||||||
sourceResultId,
|
sourceResultId,
|
||||||
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
||||||
|
...metadata,
|
||||||
} as InsertTaskPointcloudFile);
|
} as InsertTaskPointcloudFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taskMetadataBackfills = new Map<number, Promise<TaskPointcloudFile>>();
|
||||||
|
|
||||||
|
async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise<TaskPointcloudFile> {
|
||||||
|
if (row.metadataStatus !== "pending") return row;
|
||||||
|
const existing = taskMetadataBackfills.get(row.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const backfill = (async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return row;
|
||||||
|
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
|
||||||
|
await db.update(taskPointcloudFiles)
|
||||||
|
.set(metadata)
|
||||||
|
.where(and(
|
||||||
|
eq(taskPointcloudFiles.id, row.id),
|
||||||
|
eq(taskPointcloudFiles.metadataStatus, "pending"),
|
||||||
|
));
|
||||||
|
return { ...row, ...metadata };
|
||||||
|
})();
|
||||||
|
taskMetadataBackfills.set(row.id, backfill);
|
||||||
|
try {
|
||||||
|
return await backfill;
|
||||||
|
} finally {
|
||||||
|
taskMetadataBackfills.delete(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
|
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db.select().from(taskPointcloudFiles)
|
const rows = await db.select().from(taskPointcloudFiles)
|
||||||
.where(eq(taskPointcloudFiles.taskId, taskId))
|
.where(eq(taskPointcloudFiles.taskId, taskId))
|
||||||
.orderBy(taskPointcloudFiles.createdAt);
|
.orderBy(taskPointcloudFiles.createdAt);
|
||||||
|
return Promise.all(rows.map(backfillTaskPointcloudMetadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskPointcloudFileById(id: number): Promise<TaskPointcloudFile | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTaskPointcloudFileByTaskAndName(
|
||||||
|
taskId: number,
|
||||||
|
name: string,
|
||||||
|
excludeId?: number,
|
||||||
|
): Promise<TaskPointcloudFile | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const conditions = [
|
||||||
|
eq(taskPointcloudFiles.taskId, taskId),
|
||||||
|
sql`lower(${taskPointcloudFiles.name}) = lower(${name})`,
|
||||||
|
];
|
||||||
|
if (excludeId !== undefined) {
|
||||||
|
conditions.push(sql`${taskPointcloudFiles.id} <> ${excludeId}`);
|
||||||
|
}
|
||||||
|
const [row] = await db.select().from(taskPointcloudFiles).where(and(...conditions)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTaskPointcloudFileName(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
): Promise<TaskPointcloudFile> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.update(taskPointcloudFiles).set({ name }).where(eq(taskPointcloudFiles.id, id));
|
||||||
|
const row = await getTaskPointcloudFileById(id);
|
||||||
|
if (!row) throw new Error("Task pointcloud file disappeared after update");
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
|
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
|
||||||
@@ -499,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,
|
||||||
@@ -507,6 +945,12 @@ export async function linkTaskPointcloudFilesFromModel(
|
|||||||
filePath: source.filePath,
|
filePath: source.filePath,
|
||||||
source: "model_library",
|
source: "model_library",
|
||||||
sourceModelPointcloudId: source.id,
|
sourceModelPointcloudId: source.id,
|
||||||
|
metadataStatus: source.metadataStatus,
|
||||||
|
pointCount: source.pointCount,
|
||||||
|
pointAttributes: source.pointAttributes,
|
||||||
|
pointDataFormat: source.pointDataFormat,
|
||||||
|
bytesPerPoint: source.bytesPerPoint,
|
||||||
|
metadataError: source.metadataError,
|
||||||
} as InsertTaskPointcloudFile);
|
} as InsertTaskPointcloudFile);
|
||||||
inserted.push(record);
|
inserted.push(record);
|
||||||
existingSourceIds.add(id);
|
existingSourceIds.add(id);
|
||||||
@@ -580,3 +1024,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除分析结果及其派生的已配准点云记录。
|
||||||
|
* 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。
|
||||||
|
*/
|
||||||
|
export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.sourceResultId, id));
|
||||||
|
await tx.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
84
server/demoFlow.test.ts
Normal file
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 };
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type RequestHandler } from 'express';
|
||||||
import * as fs from 'fs';
|
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 { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||||
|
import {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from './measurementCompatibility.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -48,6 +55,33 @@ const diskUpload = multer({
|
|||||||
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requireFileRead: RequestHandler = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||||
|
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||||
|
res.locals.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireFileWrite: RequestHandler = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||||
|
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||||
|
if (user.role !== 'admin' && user.role !== 'super_admin') {
|
||||||
|
return res.status(403).json({ error: '无权执行此操作' });
|
||||||
|
}
|
||||||
|
res.locals.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -59,17 +93,35 @@ 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 文件上传 ────────────────────────────────────────────────────────
|
||||||
// POST /api/files/upload/cad/:modelId
|
// POST /api/files/upload/cad/:modelId
|
||||||
// multipart/form-data field: file
|
// multipart/form-data field: file
|
||||||
|
|
||||||
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
|
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||||
const modelId = parseInt(req.params.modelId, 10);
|
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;
|
||||||
@@ -81,6 +133,7 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
|
|||||||
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,
|
||||||
@@ -98,18 +151,27 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
|
|||||||
// POST /api/files/upload/model-pointcloud/:modelId
|
// POST /api/files/upload/model-pointcloud/:modelId
|
||||||
// multipart/form-data field: file
|
// multipart/form-data field: file
|
||||||
|
|
||||||
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
|
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||||
const modelId = parseInt(req.params.modelId, 10);
|
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;
|
||||||
|
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||||
const record = await db.createModelPointcloudFile({
|
const record = await db.createModelPointcloudFile({
|
||||||
modelId,
|
modelId,
|
||||||
name: req.file.originalname,
|
name: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
|
inspectionObjectId,
|
||||||
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -126,7 +188,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
|
|||||||
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||||
// POST /api/files/upload/task-cad/:taskId
|
// POST /api/files/upload/task-cad/:taskId
|
||||||
|
|
||||||
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
|
router.post('/upload/task-cad/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
if (!req.file || isNaN(taskId)) {
|
if (!req.file || isNaN(taskId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||||
@@ -157,7 +219,7 @@ router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, r
|
|||||||
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
|
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/task-cad/:taskId/:filename
|
// GET /api/files/read/task-cad/:taskId/:filename
|
||||||
|
|
||||||
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
router.get('/read/task-cad/:taskId/:filename', requireFileRead, (req, res) => {
|
||||||
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
|
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
|
||||||
const filePath = safeResolve(cadDir, req.params.filename);
|
const filePath = safeResolve(cadDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||||
@@ -168,18 +230,20 @@ router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
|||||||
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
|
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
|
||||||
// POST /api/files/upload/pointcloud/:taskId
|
// POST /api/files/upload/pointcloud/:taskId
|
||||||
|
|
||||||
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
|
router.post('/upload/pointcloud/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
if (!req.file || isNaN(taskId)) {
|
if (!req.file || isNaN(taskId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const destPath = req.file.path;
|
const destPath = req.file.path;
|
||||||
|
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||||
const record = await db.createTaskPointcloudFile({
|
const record = await db.createTaskPointcloudFile({
|
||||||
taskId,
|
taskId,
|
||||||
name: req.file.originalname,
|
name: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -196,7 +260,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
|
|||||||
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/cad/:modelId/:filename
|
// GET /api/files/read/cad/:modelId/:filename
|
||||||
|
|
||||||
router.get('/read/cad/:modelId/:filename', (req, res) => {
|
router.get('/read/cad/:modelId/:filename', requireFileRead, (req, res) => {
|
||||||
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
|
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
|
||||||
const filePath = safeResolve(cadDir, req.params.filename);
|
const filePath = safeResolve(cadDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||||
@@ -207,7 +271,7 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
|
|||||||
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
|
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/model-pointcloud/:modelId/:filename
|
// GET /api/files/read/model-pointcloud/:modelId/:filename
|
||||||
|
|
||||||
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => {
|
||||||
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
||||||
const filePath = safeResolve(pcDir, req.params.filename);
|
const filePath = safeResolve(pcDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||||
@@ -218,7 +282,7 @@ router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
|||||||
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
|
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/pointcloud/:taskId/:filename
|
// GET /api/files/read/pointcloud/:taskId/:filename
|
||||||
|
|
||||||
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (req, res) => {
|
||||||
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
|
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
|
||||||
const filePath = safeResolve(pcDir, req.params.filename);
|
const filePath = safeResolve(pcDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||||
@@ -229,7 +293,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
|||||||
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// DELETE /api/files/delete/cad/:id
|
// DELETE /api/files/delete/cad/:id
|
||||||
|
|
||||||
router.delete('/delete/cad/:id', async (req, res) => {
|
router.delete('/delete/cad/:id', requireFileWrite, async (req, res) => {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||||
|
|
||||||
@@ -245,7 +309,7 @@ router.delete('/delete/cad/:id', async (req, res) => {
|
|||||||
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
|
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
|
||||||
// DELETE /api/files/delete/pointcloud/:id
|
// DELETE /api/files/delete/pointcloud/:id
|
||||||
|
|
||||||
router.delete('/delete/pointcloud/:id', async (req, res) => {
|
router.delete('/delete/pointcloud/:id', requireFileWrite, async (req, res) => {
|
||||||
const id = parseInt(req.params.id, 10);
|
const id = parseInt(req.params.id, 10);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||||
await db.deleteTaskPointcloudFile(id);
|
await db.deleteTaskPointcloudFile(id);
|
||||||
@@ -254,13 +318,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
|
|||||||
|
|
||||||
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
||||||
// POST /api/files/sample/batch
|
// POST /api/files/sample/batch
|
||||||
// Body: { taskId: number, totalPoints?: number }
|
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
|
||||||
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
|
|
||||||
|
|
||||||
router.post('/sample/batch', async (req, res) => {
|
router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||||
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
|
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
|
||||||
taskId?: number;
|
taskId?: number;
|
||||||
filePaths?: string[];
|
|
||||||
totalPoints?: number;
|
totalPoints?: number;
|
||||||
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
||||||
fileIds?: number[];
|
fileIds?: number[];
|
||||||
@@ -268,7 +330,7 @@ router.post('/sample/batch', async (req, res) => {
|
|||||||
|
|
||||||
let paths: string[] = [];
|
let paths: string[] = [];
|
||||||
|
|
||||||
if (taskId != null) {
|
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
|
||||||
let records = await db.listTaskPointcloudFiles(taskId);
|
let records = await db.listTaskPointcloudFiles(taskId);
|
||||||
// 当前端传入 fileIds 时只采样选中的文件
|
// 当前端传入 fileIds 时只采样选中的文件
|
||||||
if (Array.isArray(fileIds) && fileIds.length > 0) {
|
if (Array.isArray(fileIds) && fileIds.length > 0) {
|
||||||
@@ -276,10 +338,8 @@ router.post('/sample/batch', async (req, res) => {
|
|||||||
records = records.filter((r) => idSet.has(r.id));
|
records = records.filter((r) => idSet.has(r.id));
|
||||||
}
|
}
|
||||||
paths = records.map((r) => r.filePath);
|
paths = records.map((r) => r.filePath);
|
||||||
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
|
|
||||||
paths = rawPaths;
|
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json({ error: 'Provide taskId or filePaths' });
|
return res.status(400).json({ error: '请提供有效的 taskId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paths.length === 0) {
|
if (paths.length === 0) {
|
||||||
@@ -290,7 +350,6 @@ router.post('/sample/batch', 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) => ({
|
||||||
@@ -305,68 +364,104 @@ router.post('/sample/batch', 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 hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
|
|
||||||
const bytesPerPoint = hasColor ? 28 : 16;
|
|
||||||
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
|
|
||||||
let offset = 0;
|
|
||||||
for (const pts of sampledPerFile) {
|
|
||||||
for (const p of pts) {
|
|
||||||
binBuf.writeFloatLE(p.x, offset);
|
|
||||||
binBuf.writeFloatLE(p.y, offset + 4);
|
|
||||||
binBuf.writeFloatLE(p.z, offset + 8);
|
|
||||||
binBuf.writeFloatLE(p.error, offset + 12);
|
|
||||||
if (hasColor) {
|
|
||||||
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
|
|
||||||
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
|
|
||||||
binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24);
|
|
||||||
}
|
|
||||||
offset += bytesPerPoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileStats = fileInfos.map((f, i) => ({
|
|
||||||
name: f.name,
|
|
||||||
sizeGB: +(f.size / 1e9).toFixed(3),
|
|
||||||
sampled: sampledPerFile[i].length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`);
|
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
|
||||||
res.setHeader('X-Point-Count', totalSampled);
|
|
||||||
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
|
|
||||||
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
|
|
||||||
res.send(binBuf);
|
|
||||||
} 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
|
||||||
|
|
||||||
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||||
@@ -402,7 +497,7 @@ router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
|||||||
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
|
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
|
||||||
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
|
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
|
||||||
|
|
||||||
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
|
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', requireFileRead, async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
const filename = req.params.filename;
|
const filename = req.params.filename;
|
||||||
@@ -432,7 +527,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
|
|||||||
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
||||||
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
|
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
|
||||||
|
|
||||||
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
router.get('/read/analysis/:taskId/:resultId/*', requireFileRead, async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
|
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
|
||||||
@@ -455,7 +550,7 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
|||||||
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
|
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
|
||||||
// POST /api/files/resync/analysis/:taskId/:resultId
|
// POST /api/files/resync/analysis/:taskId/:resultId
|
||||||
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
|
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
|
||||||
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req, res) => {
|
||||||
const taskId = parseInt(req.params.taskId, 10);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||||
@@ -472,18 +567,33 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
|||||||
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) });
|
||||||
@@ -494,7 +604,7 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
|||||||
// POST /api/files/plugins/reload
|
// POST /api/files/plugins/reload
|
||||||
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
||||||
// 用于更新算法版本后立即生效。
|
// 用于更新算法版本后立即生效。
|
||||||
router.post('/plugins/reload', (_req, res) => {
|
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const plugins = reloadPluginRegistry();
|
const plugins = reloadPluginRegistry();
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
62
server/flatnessSuiteBackfill.test.ts
Normal file
62
server/flatnessSuiteBackfill.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
executePlans,
|
||||||
|
mergeMeasurements,
|
||||||
|
type BackfillPlan,
|
||||||
|
} from "../scripts/backfill-flatness-suite-measurements";
|
||||||
|
|
||||||
|
function plan(id: number): BackfillPlan {
|
||||||
|
return {
|
||||||
|
row: { id, taskId: 24, measurements: [], resultData: { kind: "plugin", marker: "kept" } } as BackfillPlan["row"],
|
||||||
|
resultJsonPath: `/tmp/${id}/result.json`,
|
||||||
|
originalResultJson: { status: "completed", extra: { module: "contour_batch" } },
|
||||||
|
measurements: [{
|
||||||
|
name: "任务11 · 车钩面板的平整度",
|
||||||
|
value: null,
|
||||||
|
unit: "mm",
|
||||||
|
toleranceLabel: "0.5mm",
|
||||||
|
status: "fail",
|
||||||
|
passLabel: "成功",
|
||||||
|
warningLabel: "部分成功",
|
||||||
|
failLabel: "失败",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("flatness-suite 历史回填", () => {
|
||||||
|
it("dry-run 不调用持久化", async () => {
|
||||||
|
const persist = vi.fn(async () => true);
|
||||||
|
const summary = await executePlans([plan(234)], false, persist);
|
||||||
|
expect(persist).not.toHaveBeenCalled();
|
||||||
|
expect(summary).toEqual({ planned: 1, applied: 0, skipped: 1, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apply 由持久化层守卫实现幂等", async () => {
|
||||||
|
const applied = new Set<number>();
|
||||||
|
const persist = vi.fn(async (item: BackfillPlan) => {
|
||||||
|
if (applied.has(item.row.id)) return false;
|
||||||
|
applied.add(item.row.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
expect((await executePlans([plan(234)], true, persist)).applied).toBe(1);
|
||||||
|
const second = await executePlans([plan(234)], true, persist);
|
||||||
|
expect(second.applied).toBe(0);
|
||||||
|
expect(second.skipped).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("异常记录不阻断其他记录", async () => {
|
||||||
|
const persist = vi.fn(async (item: BackfillPlan) => {
|
||||||
|
if (item.row.id === 1) throw new Error("bad excel");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const summary = await executePlans([plan(1), plan(2)], true, persist);
|
||||||
|
expect(summary).toEqual({ planned: 2, applied: 1, skipped: 0, failed: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("扩展字段完整进入 resultData 且保留已有字段", () => {
|
||||||
|
const item = plan(234);
|
||||||
|
const merged = mergeMeasurements(item.row.resultData, item.measurements);
|
||||||
|
expect(merged.marker).toBe("kept");
|
||||||
|
expect(merged.measurements).toEqual(item.measurements);
|
||||||
|
});
|
||||||
|
});
|
||||||
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,
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -23,14 +17,17 @@ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
|||||||
function createAuthContext(): TrpcContext {
|
function createAuthContext(): TrpcContext {
|
||||||
const user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
openId: "test-user",
|
username: "test-user",
|
||||||
|
passwordHash: null,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
loginMethod: "manus",
|
role: "admin",
|
||||||
role: "user",
|
isActive: 1,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
lastSignedIn: new Date(),
|
lastLoginAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -57,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,
|
||||||
@@ -75,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,
|
||||||
@@ -89,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'",
|
||||||
@@ -110,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>();
|
||||||
63
server/permissions.test.ts
Normal file
63
server/permissions.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { User } from "../drizzle/schema";
|
||||||
|
import type { TrpcContext } from "./_core/context";
|
||||||
|
import { protectedProcedure, router, superAdminProcedure } from "./_core/trpc";
|
||||||
|
|
||||||
|
const testRouter = router({
|
||||||
|
read: protectedProcedure.query(() => "read-ok"),
|
||||||
|
write: protectedProcedure.mutation(() => "write-ok"),
|
||||||
|
manageUsers: superAdminProcedure.query(() => "users-ok"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function user(role: User["role"], mustChangePassword = 0): User {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
username: `${role}-account`,
|
||||||
|
passwordHash: "unused",
|
||||||
|
name: role,
|
||||||
|
email: null,
|
||||||
|
role,
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword,
|
||||||
|
sessionVersion: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastLoginAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(currentUser: User | null): TrpcContext {
|
||||||
|
return { user: currentUser, req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("三角色权限矩阵", () => {
|
||||||
|
it("未登录不能读取", async () => {
|
||||||
|
await expect(testRouter.createCaller(context(null)).read()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("首次改密前不能读取业务数据", async () => {
|
||||||
|
await expect(testRouter.createCaller(context(user("super_admin", 1))).read()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通用户可读但不可写,也不可管理用户", async () => {
|
||||||
|
const caller = testRouter.createCaller(context(user("user")));
|
||||||
|
await expect(caller.read()).resolves.toBe("read-ok");
|
||||||
|
await expect(caller.write()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("管理员可读写但不可管理用户", async () => {
|
||||||
|
const caller = testRouter.createCaller(context(user("admin")));
|
||||||
|
await expect(caller.read()).resolves.toBe("read-ok");
|
||||||
|
await expect(caller.write()).resolves.toBe("write-ok");
|
||||||
|
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("超级管理员拥有全部权限", async () => {
|
||||||
|
const caller = testRouter.createCaller(context(user("super_admin")));
|
||||||
|
await expect(caller.read()).resolves.toBe("read-ok");
|
||||||
|
await expect(caller.write()).resolves.toBe("write-ok");
|
||||||
|
await expect(caller.manageUsers()).resolves.toBe("users-ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"),
|
||||||
|
|||||||
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""flatness-suite 批量测量结果的模板关联与旧报表解析。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import posixpath
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from zipfile import BadZipFile, ZipFile
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
_SHEET_NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||||
|
_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||||
|
|
||||||
|
|
||||||
|
def _column_index(reference: str) -> int:
|
||||||
|
letters = "".join(character for character in reference if character.isalpha())
|
||||||
|
if not letters:
|
||||||
|
raise ValueError(f"无效的 Excel 单元格引用: {reference}")
|
||||||
|
result = 0
|
||||||
|
for character in letters.upper():
|
||||||
|
result = result * 26 + ord(character) - ord("A") + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value(cell: ET.Element, shared_strings: list[str]) -> Any:
|
||||||
|
cell_type = cell.attrib.get("t")
|
||||||
|
if cell_type == "inlineStr":
|
||||||
|
return "".join(text.text or "" for text in cell.findall(".//a:t", _SHEET_NS))
|
||||||
|
|
||||||
|
value_node = cell.find("a:v", _SHEET_NS)
|
||||||
|
if value_node is None or value_node.text is None:
|
||||||
|
return ""
|
||||||
|
raw = value_node.text
|
||||||
|
if cell_type == "s":
|
||||||
|
try:
|
||||||
|
return shared_strings[int(raw)]
|
||||||
|
except (IndexError, ValueError) as exc:
|
||||||
|
raise ValueError("Excel 共享字符串索引无效") from exc
|
||||||
|
if cell_type in {"str", "e"}:
|
||||||
|
return raw
|
||||||
|
if cell_type == "b":
|
||||||
|
return raw == "1"
|
||||||
|
try:
|
||||||
|
number = float(raw)
|
||||||
|
return int(number) if number.is_integer() else number
|
||||||
|
except ValueError:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def read_xlsx_rows(path: str | Path) -> tuple[str, list[list[Any]]]:
|
||||||
|
"""只用标准库读取 xlsx 的第一个工作表。"""
|
||||||
|
workbook_path = Path(path)
|
||||||
|
if not workbook_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Excel 文件不存在: {workbook_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ZipFile(workbook_path) as xlsx:
|
||||||
|
names = set(xlsx.namelist())
|
||||||
|
shared_strings: list[str] = []
|
||||||
|
if "xl/sharedStrings.xml" in names:
|
||||||
|
shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml"))
|
||||||
|
shared_strings = [
|
||||||
|
"".join(text.text or "" for text in item.findall(".//a:t", _SHEET_NS))
|
||||||
|
for item in shared_root.findall("a:si", _SHEET_NS)
|
||||||
|
]
|
||||||
|
|
||||||
|
workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml"))
|
||||||
|
first_sheet = workbook_root.find("a:sheets/a:sheet", _SHEET_NS)
|
||||||
|
if first_sheet is None:
|
||||||
|
raise ValueError(f"Excel 中没有工作表: {workbook_path}")
|
||||||
|
sheet_name = first_sheet.attrib.get("name", "Sheet1")
|
||||||
|
relation_id = first_sheet.attrib.get(f"{{{_REL_NS}}}id")
|
||||||
|
if not relation_id:
|
||||||
|
raise ValueError(f"Excel 工作表关系缺失: {workbook_path}")
|
||||||
|
|
||||||
|
rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels"))
|
||||||
|
relation_targets = {
|
||||||
|
relation.attrib["Id"]: relation.attrib["Target"] for relation in rels_root
|
||||||
|
}
|
||||||
|
target = relation_targets.get(relation_id)
|
||||||
|
if not target:
|
||||||
|
raise ValueError(f"Excel 工作表文件关系缺失: {workbook_path}")
|
||||||
|
sheet_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join("xl", target))
|
||||||
|
if sheet_path not in names:
|
||||||
|
raise ValueError(f"Excel 工作表文件不存在: {sheet_path}")
|
||||||
|
|
||||||
|
sheet_root = ET.fromstring(xlsx.read(sheet_path))
|
||||||
|
sparse_rows: list[dict[int, Any]] = []
|
||||||
|
max_column = 0
|
||||||
|
for row in sheet_root.findall("a:sheetData/a:row", _SHEET_NS):
|
||||||
|
values: dict[int, Any] = {}
|
||||||
|
for cell in row.findall("a:c", _SHEET_NS):
|
||||||
|
column = _column_index(cell.attrib.get("r", ""))
|
||||||
|
values[column] = _cell_value(cell, shared_strings)
|
||||||
|
max_column = max(max_column, column)
|
||||||
|
if values:
|
||||||
|
sparse_rows.append(values)
|
||||||
|
except (BadZipFile, KeyError, ET.ParseError) as exc:
|
||||||
|
raise ValueError(f"无法解析 Excel 文件 {workbook_path}: {exc}") from exc
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
[row.get(column, "") for column in range(1, max_column + 1)]
|
||||||
|
for row in sparse_rows
|
||||||
|
]
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(f"Excel 工作表为空: {workbook_path}")
|
||||||
|
return sheet_name, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _header_index(headers: list[Any], candidates: tuple[str, ...]) -> int:
|
||||||
|
normalized = [str(header).strip() for header in headers]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in normalized:
|
||||||
|
return normalized.index(candidate)
|
||||||
|
raise ValueError(f"Excel 缺少列: {'/'.join(candidates)}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_id(value: Any) -> str:
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
text = str(value).strip()
|
||||||
|
return text[:-2] if text.endswith(".0") and text[:-2].isdigit() else text
|
||||||
|
|
||||||
|
|
||||||
|
def _label(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_measurement_template(path: str | Path) -> dict[str, dict[str, str]]:
|
||||||
|
"""读取任务号对应的项点名称与原始公差文本。"""
|
||||||
|
_, rows = read_xlsx_rows(path)
|
||||||
|
headers = rows[0]
|
||||||
|
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
|
||||||
|
name_column = _header_index(headers, ("项点名称", "项目名称", "项目"))
|
||||||
|
tolerance_column = _header_index(headers, ("公差要求", "公差"))
|
||||||
|
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for row in rows[1:]:
|
||||||
|
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
|
||||||
|
if not task_id:
|
||||||
|
continue
|
||||||
|
item_name = _label(row[name_column] if name_column < len(row) else "")
|
||||||
|
if not item_name:
|
||||||
|
raise ValueError(f"模板任务 {task_id} 缺少项点名称")
|
||||||
|
result[task_id] = {
|
||||||
|
"item_name": item_name,
|
||||||
|
"tolerance_label": _label(row[tolerance_column] if tolerance_column < len(row) else ""),
|
||||||
|
}
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"模板中没有测量任务: {path}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_legacy_batch_rows(path: str | Path) -> list[dict[str, Any]]:
|
||||||
|
"""读取旧版技术汇总表中的任务号、平均值和状态。"""
|
||||||
|
_, rows = read_xlsx_rows(path)
|
||||||
|
headers = rows[0]
|
||||||
|
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
|
||||||
|
mean_column = _header_index(headers, ("平均值", "mean", "mean_dist", "average_distance"))
|
||||||
|
status_column = _header_index(headers, ("状态", "测量结论", "结论"))
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
|
||||||
|
if not task_id:
|
||||||
|
continue
|
||||||
|
result.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"mean": row[mean_column] if mean_column < len(row) else None,
|
||||||
|
"status": row[status_column] if status_column < len(row) else "",
|
||||||
|
})
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"旧版 Excel 中没有批量结果: {path}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_float(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if math.isfinite(number) else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_measurements(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
template_path: str | Path,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
template = read_measurement_template(template_path)
|
||||||
|
status_fields = {
|
||||||
|
"成功": ("pass", "成功"),
|
||||||
|
"部分成功": ("warning", "部分成功"),
|
||||||
|
"失败": ("fail", "失败"),
|
||||||
|
}
|
||||||
|
measurements: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
task_id = normalize_task_id(row.get("task_id", ""))
|
||||||
|
if not task_id:
|
||||||
|
raise ValueError("批量结果包含空任务号")
|
||||||
|
template_row = template.get(task_id)
|
||||||
|
if template_row is None:
|
||||||
|
raise ValueError(f"模板中找不到任务 {task_id}")
|
||||||
|
raw_status = str(row.get("status", "")).strip()
|
||||||
|
if raw_status not in status_fields:
|
||||||
|
raise ValueError(f"任务 {task_id} 的状态无效: {raw_status or '<空>'}")
|
||||||
|
status, _ = status_fields[raw_status]
|
||||||
|
measurements.append({
|
||||||
|
"name": f"任务{task_id} · {template_row['item_name']}",
|
||||||
|
"value": _finite_float(row.get("mean")),
|
||||||
|
"unit": "mm",
|
||||||
|
"toleranceLabel": template_row["tolerance_label"],
|
||||||
|
"status": status,
|
||||||
|
"passLabel": "成功",
|
||||||
|
"warningLabel": "部分成功",
|
||||||
|
"failLabel": "失败",
|
||||||
|
})
|
||||||
|
return measurements
|
||||||
|
|
||||||
|
|
||||||
|
def measurements_from_batch_result(result: dict[str, Any], template_path: str | Path) -> list[dict[str, Any]]:
|
||||||
|
rows = result.get("rows")
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise ValueError("run_contour_batch 返回值缺少 rows")
|
||||||
|
return build_measurements(rows, template_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="从旧版 flatness-suite Excel 构造通用 measurements")
|
||||||
|
parser.add_argument("--excel", required=True)
|
||||||
|
parser.add_argument("--template", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
measurements = build_measurements(read_legacy_batch_rows(args.excel), args.template)
|
||||||
|
print(json.dumps(measurements, ensure_ascii=False, allow_nan=False))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -31,6 +31,7 @@ from _plugin_io import (
|
|||||||
write_error_result,
|
write_error_result,
|
||||||
write_result,
|
write_result,
|
||||||
)
|
)
|
||||||
|
from flatness_suite_measurements import measurements_from_batch_result
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: object) -> float | None:
|
def _safe_float(v: object) -> float | None:
|
||||||
@@ -40,6 +41,25 @@ def _safe_float(v: object) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _config_bool(config: dict, key: str, default: bool = False) -> bool:
|
||||||
|
value = config.get(key, default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_temporary_artifacts(out_dir: Path) -> None:
|
||||||
|
# 仅删除适配器自己创建、且不会写入 result.json 的固定临时文件。
|
||||||
|
# 不使用通配符,避免误删算法结果或用户要求保留的产物。
|
||||||
|
for name in ("_roi_cropped.ply",):
|
||||||
|
try:
|
||||||
|
(out_dir / name).unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"[flatness-suite] 清理临时文件失败 {name}: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path:
|
def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path:
|
||||||
"""根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。
|
"""根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。
|
||||||
|
|
||||||
@@ -191,34 +211,45 @@ def main() -> int:
|
|||||||
if use_batch_mode and module == "contour":
|
if use_batch_mode and module == "contour":
|
||||||
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
|
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
|
||||||
data_root = alg_dir / "datasets"
|
data_root = alg_dir / "datasets"
|
||||||
# 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定
|
# combined_contour.ply 可能包含完整整车点云,单次可达十几 GB。
|
||||||
context_pc = (inputs.get("contextPointCloudPath")
|
# 平台页面只使用各面板 *_contour.ply,因此默认从源头跳过组合文件;
|
||||||
or pc_path_raw
|
# 仅在用户显式开启时才生成,并作为可下载产物登记。
|
||||||
or None)
|
keep_combined_contour = _config_bool(config, "keepCombinedContour", False)
|
||||||
|
context_pc = None
|
||||||
|
if keep_combined_contour:
|
||||||
|
context_pc = (config.get("contextPointCloudPath")
|
||||||
|
or inputs.get("contextPointCloudPath")
|
||||||
|
or pc_path_raw
|
||||||
|
or None)
|
||||||
save_dir = str(out_dir / "contour_batch")
|
save_dir = str(out_dir / "contour_batch")
|
||||||
excel_path = str(out_dir / "contour.xlsx")
|
excel_path = str(out_dir / "contour.xlsx")
|
||||||
|
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
||||||
|
if not result_template_path.is_file():
|
||||||
|
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
|
||||||
|
selected_task_nos = config.get("demoTaskNos")
|
||||||
|
selected_task_ids = (
|
||||||
|
[str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()]
|
||||||
|
if isinstance(selected_task_nos, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if selected_task_ids == []:
|
||||||
|
raise RuntimeError("请至少选择一个轮廓测量任务")
|
||||||
res = pipeline.run_contour_batch(
|
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,
|
||||||
triangle_chunk_size=512,
|
triangle_chunk_size=512,
|
||||||
save_path=save_dir,
|
save_path=save_dir,
|
||||||
excel_output_path=excel_path,
|
excel_output_path=excel_path,
|
||||||
|
result_template_path=str(result_template_path),
|
||||||
combined_context_pointcloud_path=context_pc,
|
combined_context_pointcloud_path=context_pc,
|
||||||
|
save_combined_visualization=keep_combined_contour,
|
||||||
no_visualize=True,
|
no_visualize=True,
|
||||||
)
|
)
|
||||||
emit_progress(0.95, "整理批量结果")
|
emit_progress(0.95, "整理批量结果")
|
||||||
batch_stats = res.get("batch_stats") or {}
|
measurements = measurements_from_batch_result(res, result_template_path)
|
||||||
measurements: list[dict] = []
|
|
||||||
for task_id, task_res in (res.get("results") or {}).items():
|
|
||||||
s = task_res.get("distance_stats") or {}
|
|
||||||
if s:
|
|
||||||
measurements.append({
|
|
||||||
"name": f"任务{task_id} 轮廓偏差均值",
|
|
||||||
"value": round(float(s.get("mean", 0)), 4),
|
|
||||||
"unit": "mm", "status": "info",
|
|
||||||
})
|
|
||||||
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
|
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
|
||||||
panel_plys = sorted([
|
panel_plys = sorted([
|
||||||
f"contour_batch/{p.name}"
|
f"contour_batch/{p.name}"
|
||||||
@@ -229,12 +260,19 @@ def main() -> int:
|
|||||||
overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys}
|
overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys}
|
||||||
else:
|
else:
|
||||||
overlay3d = {"kind": "none"}
|
overlay3d = {"kind": "none"}
|
||||||
|
combined_rel = "contour_batch/combined_contour.ply"
|
||||||
|
result_files = [combined_rel] if keep_combined_contour and (out_dir / combined_rel).is_file() else []
|
||||||
write_result(out_dir, {
|
write_result(out_dir, {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": f"批量完成 {len(res.get('results', {}))} 个面板测量",
|
"message": f"批量完成 {len(res.get('rows') or [])} 个面板测量",
|
||||||
"measurements": measurements,
|
"measurements": measurements,
|
||||||
"overlay3d": overlay3d,
|
"overlay3d": overlay3d,
|
||||||
"extra": {"module": "contour_batch", "excelPath": "contour.xlsx"},
|
"files": result_files,
|
||||||
|
"extra": {
|
||||||
|
"module": "contour_batch",
|
||||||
|
"excelPath": "contour.xlsx",
|
||||||
|
"combinedContourKept": bool(result_files),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -394,7 +432,7 @@ def main() -> int:
|
|||||||
overlay3d: dict = {"kind": "none"}
|
overlay3d: dict = {"kind": "none"}
|
||||||
for ply_key in ("contourPly", "curvaturePly"):
|
for ply_key in ("contourPly", "curvaturePly"):
|
||||||
if ply_key in extra:
|
if ply_key in extra:
|
||||||
overlay3d = {"kind": "vertexColors", "url": extra[ply_key]}
|
overlay3d = {"kind": "ply", "file": extra[ply_key]}
|
||||||
break
|
break
|
||||||
|
|
||||||
if skipped:
|
if skipped:
|
||||||
@@ -430,6 +468,8 @@ def main() -> int:
|
|||||||
extra={"traceback": traceback.format_exc()},
|
extra={"traceback": traceback.format_exc()},
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
finally:
|
||||||
|
_cleanup_temporary_artifacts(out_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
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()
|
||||||
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from flatness_suite_run import _cleanup_temporary_artifacts, _config_bool
|
||||||
|
from flatness_suite_measurements import (
|
||||||
|
measurements_from_batch_result,
|
||||||
|
read_legacy_batch_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE = (
|
||||||
|
Path(__file__).parents[3]
|
||||||
|
/ "alg"
|
||||||
|
/ "平整度、平面度、轮廓测量v1.5"
|
||||||
|
/ "excel"
|
||||||
|
/ "测量结果.xlsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlatnessSuiteRetentionConfigTest(TestCase):
|
||||||
|
def test_combined_contour_defaults_to_disabled(self) -> None:
|
||||||
|
self.assertFalse(_config_bool({}, "keepCombinedContour", False))
|
||||||
|
|
||||||
|
def test_explicit_boolean_or_string_can_enable_retention(self) -> None:
|
||||||
|
self.assertTrue(_config_bool({"keepCombinedContour": True}, "keepCombinedContour"))
|
||||||
|
self.assertTrue(_config_bool({"keepCombinedContour": "true"}, "keepCombinedContour"))
|
||||||
|
self.assertFalse(_config_bool({"keepCombinedContour": "false"}, "keepCombinedContour"))
|
||||||
|
|
||||||
|
def test_cleanup_removes_only_known_temporary_artifacts(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp:
|
||||||
|
out_dir = Path(temp)
|
||||||
|
temporary = out_dir / "_roi_cropped.ply"
|
||||||
|
result = out_dir / "contour.ply"
|
||||||
|
temporary.touch()
|
||||||
|
result.touch()
|
||||||
|
|
||||||
|
_cleanup_temporary_artifacts(out_dir)
|
||||||
|
|
||||||
|
self.assertFalse(temporary.exists())
|
||||||
|
self.assertTrue(result.exists())
|
||||||
|
|
||||||
|
|
||||||
|
class FlatnessSuiteBatchMeasurementTest(TestCase):
|
||||||
|
def test_uses_top_level_rows_instead_of_nested_sample_results(self) -> None:
|
||||||
|
result = {
|
||||||
|
"rows": [{"task_id": "11", "mean": 1.25, "status": "成功"}],
|
||||||
|
"results": {
|
||||||
|
"11": {
|
||||||
|
"11": {"distance_stats": {"mean": 999}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements = measurements_from_batch_result(result, TEMPLATE)
|
||||||
|
|
||||||
|
self.assertEqual(len(measurements), 1)
|
||||||
|
self.assertEqual(measurements[0]["name"], "任务11 · 车钩面板的平整度")
|
||||||
|
self.assertEqual(measurements[0]["value"], 1.25)
|
||||||
|
self.assertEqual(measurements[0]["toleranceLabel"], "0.5mm")
|
||||||
|
|
||||||
|
def test_maps_success_partial_success_failure_and_empty_value(self) -> None:
|
||||||
|
result = {
|
||||||
|
"rows": [
|
||||||
|
{"task_id": 11, "mean": 0.1, "status": "成功"},
|
||||||
|
{"task_id": 13, "mean": 0.2, "status": "部分成功"},
|
||||||
|
{"task_id": 17, "mean": None, "status": "失败"},
|
||||||
|
],
|
||||||
|
"results": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements = measurements_from_batch_result(result, TEMPLATE)
|
||||||
|
|
||||||
|
self.assertEqual([item["status"] for item in measurements], ["pass", "warning", "fail"])
|
||||||
|
self.assertEqual(measurements[0]["passLabel"], "成功")
|
||||||
|
self.assertEqual(measurements[1]["warningLabel"], "部分成功")
|
||||||
|
self.assertEqual(measurements[2]["failLabel"], "失败")
|
||||||
|
self.assertIsNone(measurements[2]["value"])
|
||||||
|
|
||||||
|
def test_missing_template_has_explicit_error(self) -> None:
|
||||||
|
with self.assertRaisesRegex(FileNotFoundError, "Excel 文件不存在"):
|
||||||
|
measurements_from_batch_result(
|
||||||
|
{"rows": [{"task_id": 11, "mean": 0.1, "status": "成功"}]},
|
||||||
|
Path("missing-template.xlsx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_new_template_is_not_misread_as_legacy_summary(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "平均值"):
|
||||||
|
read_legacy_batch_rows(TEMPLATE)
|
||||||
|
|
||||||
|
def test_malformed_excel_has_explicit_error(self) -> None:
|
||||||
|
with TemporaryDirectory() as temp:
|
||||||
|
invalid = Path(temp) / "contour.xlsx"
|
||||||
|
invalid.write_text("not an xlsx", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "无法解析 Excel"):
|
||||||
|
read_legacy_batch_rows(invalid)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def main() -> int:
|
|||||||
pc_path = first_pointcloud(payload)
|
pc_path = first_pointcloud(payload)
|
||||||
cad_path = inputs.get("cadPath")
|
cad_path = inputs.get("cadPath")
|
||||||
if not cad_path:
|
if not cad_path:
|
||||||
raise RuntimeError("整车配准需要 STEP CAD 文件,请在任务下上传并选中 CAD。")
|
raise RuntimeError("整车配准需要 CAD 文件(STEP 或 PLY mesh),请在任务下上传并选中 CAD。")
|
||||||
|
|
||||||
axis = str(config.get("axis") or "x")
|
axis = str(config.get("axis") or "x")
|
||||||
crop_ratio = float(config.get("cropRatio") or 0.01)
|
crop_ratio = float(config.get("cropRatio") or 0.01)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -3,11 +3,34 @@
|
|||||||
"name": "平整度 / 平面度 / 轮廓测量",
|
"name": "平整度 / 平面度 / 轮廓测量",
|
||||||
"shortName": "flatness-suite",
|
"shortName": "flatness-suite",
|
||||||
"description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
|
"description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"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",
|
||||||
@@ -15,6 +38,13 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "keepCombinedContour",
|
||||||
|
"label": "保留组合轮廓点云(可能占用十几 GB)",
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "默认关闭:页面使用各面板热力点云,不需要组合文件。仅在需要离线下载包含整车背景的 combined_contour.ply 时开启。"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "module",
|
"name": "module",
|
||||||
"label": "测量模块",
|
"label": "测量模块",
|
||||||
@@ -47,10 +77,10 @@
|
|||||||
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
|
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
|
||||||
{
|
{
|
||||||
"name": "contextPointCloudPath",
|
"name": "contextPointCloudPath",
|
||||||
"label": "整车背景点云路径(可视化合并用)",
|
"label": "整车背景点云路径(仅保留组合轮廓点云时使用)",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": null,
|
"default": null,
|
||||||
"description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。"
|
"description": "仅开启“保留组合轮廓点云”时生效。可填写容器内完整点云路径;留空时使用本次选择的点云作为背景。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"runtime": {
|
"runtime": {
|
||||||
|
|||||||
@@ -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,12 +125,30 @@ export interface PluginProgressEvent {
|
|||||||
|
|
||||||
/** 测量项(GenericResultView 渲染) */
|
/** 测量项(GenericResultView 渲染) */
|
||||||
export interface PluginMeasurement {
|
export interface PluginMeasurement {
|
||||||
|
/** 检验任务号;新协议与 metricCode 必须成对提供。 */
|
||||||
|
taskNo?: string;
|
||||||
|
/** 跨版本稳定的测量项编码。 */
|
||||||
|
metricCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
/** 同任务内的位置,例如左梁、右梁。 */
|
||||||
|
position?: string;
|
||||||
|
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
||||||
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
|
/** 无法安全数值化的原始公差表达式。 */
|
||||||
|
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;
|
||||||
|
warningLabel?: string;
|
||||||
|
failLabel?: string;
|
||||||
|
/** 兼容层无法确定任务号时置为 true。 */
|
||||||
|
legacyUnmapped?: boolean;
|
||||||
|
/** 兼容转换前的原始项目名,用于追溯。 */
|
||||||
|
legacyOriginalName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
||||||
@@ -141,6 +179,8 @@ export type PluginOverlay3D =
|
|||||||
/** 着色 PLY 叠加文件(同样相对 outDir) */
|
/** 着色 PLY 叠加文件(同样相对 outDir) */
|
||||||
kind: 'ply';
|
kind: 'ply';
|
||||||
file: string;
|
file: string;
|
||||||
|
/** 批量结果可包含多个 PLY 叠加文件 */
|
||||||
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */
|
/** 算法写出的 result.json(与 INTEGRATION.md 描述对齐) */
|
||||||
@@ -177,4 +217,8 @@ export interface PluginResultData extends Partial<PluginResult> {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** 子进程已结束时的退出码 */
|
/** 子进程已结束时的退出码 */
|
||||||
exitCode?: number | null;
|
exitCode?: number | null;
|
||||||
|
/** 本次算法运行实际使用的任务点云文件 ID,用于前端叠加层匹配校验 */
|
||||||
|
inputPointCloudFileIds?: number[];
|
||||||
|
/** 本次算法运行前端选择的任务 CAD 文件 ID,用于后续匹配/追溯 */
|
||||||
|
inputCadFileIds?: number[];
|
||||||
}
|
}
|
||||||
|
|||||||
125
server/pointcloudMetadata.test.ts
Normal file
125
server/pointcloudMetadata.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
inspectPointcloudMetadata,
|
||||||
|
MAX_PLY_HEADER_BYTES,
|
||||||
|
parsePlyHeader,
|
||||||
|
PointcloudMetadataParseError,
|
||||||
|
} from "./pointcloudMetadata";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (temporaryDirectories.length > 0) {
|
||||||
|
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function header(lines: string[], newline = "\n") {
|
||||||
|
return Buffer.from([...lines, "end_header", ""].join(newline), "ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parsePlyHeader", () => {
|
||||||
|
it("解析 ASCII PLY,并以 element vertex 作为精确点数", () => {
|
||||||
|
const result = parsePlyHeader(header([
|
||||||
|
"ply",
|
||||||
|
"format ascii 1.0",
|
||||||
|
"element vertex 1000000000",
|
||||||
|
"property float x",
|
||||||
|
"property float y",
|
||||||
|
"property double z",
|
||||||
|
"element face 999",
|
||||||
|
"property list uchar int vertex_indices",
|
||||||
|
]));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
pointCount: 1_000_000_000,
|
||||||
|
pointDataFormat: "ascii",
|
||||||
|
bytesPerPoint: 16,
|
||||||
|
pointAttributes: [
|
||||||
|
{ name: "x", type: "float32", bytes: 4 },
|
||||||
|
{ name: "y", type: "float32", bytes: 4 },
|
||||||
|
{ name: "z", type: "float64", bytes: 8 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["binary_little_endian", "binary_little_endian"],
|
||||||
|
["binary_big_endian", "binary_big_endian"],
|
||||||
|
] as const)("解析 %s PLY", (format, expected) => {
|
||||||
|
const result = parsePlyHeader(header([
|
||||||
|
"ply",
|
||||||
|
`format ${format} 1.0`,
|
||||||
|
"element vertex 42",
|
||||||
|
"property int16 x",
|
||||||
|
"property ushort intensity",
|
||||||
|
"property uchar red",
|
||||||
|
]));
|
||||||
|
expect(result.pointDataFormat).toBe(expected);
|
||||||
|
expect(result.bytesPerPoint).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持 CRLF 文件头和类型别名", () => {
|
||||||
|
const result = parsePlyHeader(header([
|
||||||
|
"ply",
|
||||||
|
"format ascii 1.0",
|
||||||
|
"comment generated",
|
||||||
|
"element vertex 3",
|
||||||
|
"property char classification",
|
||||||
|
"property uint confidence",
|
||||||
|
"property float32 temperature",
|
||||||
|
], "\r\n"));
|
||||||
|
expect(result.pointAttributes).toEqual([
|
||||||
|
{ name: "classification", type: "int8", bytes: 1 },
|
||||||
|
{ name: "confidence", type: "uint32", bytes: 4 },
|
||||||
|
{ name: "temperature", type: "float32", bytes: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝无效文件头", () => {
|
||||||
|
expect(() => parsePlyHeader(Buffer.from("not-ply\n"))).toThrow(PointcloudMetadataParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝未知的顶点属性类型", () => {
|
||||||
|
expect(() => parsePlyHeader(header([
|
||||||
|
"ply",
|
||||||
|
"format ascii 1.0",
|
||||||
|
"element vertex 1",
|
||||||
|
"property half x",
|
||||||
|
]))).toThrow("未知的顶点属性类型");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝列表型顶点属性,但忽略其他 element 的列表属性", () => {
|
||||||
|
expect(() => parsePlyHeader(header([
|
||||||
|
"ply",
|
||||||
|
"format ascii 1.0",
|
||||||
|
"element vertex 1",
|
||||||
|
"property list uchar float samples",
|
||||||
|
]))).toThrow("列表型顶点属性");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非 PLY 格式直接标记为 unsupported,不读取文件", () => {
|
||||||
|
expect(inspectPointcloudMetadata("/path/does/not/exist.txt", "cloud.txt")).toEqual({
|
||||||
|
metadataStatus: "unsupported",
|
||||||
|
pointCount: null,
|
||||||
|
pointAttributes: null,
|
||||||
|
pointDataFormat: null,
|
||||||
|
bytesPerPoint: null,
|
||||||
|
metadataError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("只读取 16KB 文件头,超长文件头标记为解析失败", () => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "pointcloud-metadata-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
const filePath = join(directory, "oversized.ply");
|
||||||
|
writeFileSync(filePath, `ply\nformat ascii 1.0\ncomment ${"x".repeat(MAX_PLY_HEADER_BYTES)}\nend_header\n`);
|
||||||
|
|
||||||
|
const result = inspectPointcloudMetadata(filePath, "oversized.ply");
|
||||||
|
expect(result.metadataStatus).toBe("error");
|
||||||
|
expect(result.metadataError).toContain("16KB");
|
||||||
|
});
|
||||||
|
});
|
||||||
153
server/pointcloudMetadata.ts
Normal file
153
server/pointcloudMetadata.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type {
|
||||||
|
PointAttribute,
|
||||||
|
PointcloudDataFormat,
|
||||||
|
PointcloudMetadataValues,
|
||||||
|
} from "../shared/pointcloudMetadata";
|
||||||
|
|
||||||
|
export const MAX_PLY_HEADER_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
type ScalarType = { type: string; bytes: number };
|
||||||
|
|
||||||
|
const PLY_SCALAR_TYPES: Record<string, ScalarType> = {
|
||||||
|
char: { type: "int8", bytes: 1 },
|
||||||
|
int8: { type: "int8", bytes: 1 },
|
||||||
|
uchar: { type: "uint8", bytes: 1 },
|
||||||
|
uint8: { type: "uint8", bytes: 1 },
|
||||||
|
short: { type: "int16", bytes: 2 },
|
||||||
|
int16: { type: "int16", bytes: 2 },
|
||||||
|
ushort: { type: "uint16", bytes: 2 },
|
||||||
|
uint16: { type: "uint16", bytes: 2 },
|
||||||
|
int: { type: "int32", bytes: 4 },
|
||||||
|
int32: { type: "int32", bytes: 4 },
|
||||||
|
uint: { type: "uint32", bytes: 4 },
|
||||||
|
uint32: { type: "uint32", bytes: 4 },
|
||||||
|
float: { type: "float32", bytes: 4 },
|
||||||
|
float32: { type: "float32", bytes: 4 },
|
||||||
|
double: { type: "float64", bytes: 8 },
|
||||||
|
float64: { type: "float64", bytes: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PointcloudMetadataParseError extends Error {}
|
||||||
|
|
||||||
|
export type ParsedPlyMetadata = {
|
||||||
|
pointCount: number;
|
||||||
|
pointAttributes: PointAttribute[];
|
||||||
|
pointDataFormat: PointcloudDataFormat;
|
||||||
|
bytesPerPoint: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 解析内存中的 PLY 文件头。调用方应保证输入不超过 MAX_PLY_HEADER_BYTES。 */
|
||||||
|
export function parsePlyHeader(buffer: Buffer): ParsedPlyMetadata {
|
||||||
|
const text = buffer.toString("latin1");
|
||||||
|
const endMatch = /(?:^|\r?\n)end_header(?:\r?\n|$)/.exec(text);
|
||||||
|
if (!endMatch) {
|
||||||
|
throw new PointcloudMetadataParseError("未在 16KB 内找到 PLY 文件头结束标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerEnd = endMatch.index + endMatch[0].length;
|
||||||
|
const lines = text.slice(0, headerEnd).split(/\r?\n/).map((line) => line.trim());
|
||||||
|
if (lines[0] !== "ply") {
|
||||||
|
throw new PointcloudMetadataParseError("不是有效的 PLY 文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
let pointDataFormat: PointcloudDataFormat | null = null;
|
||||||
|
let pointCount: number | null = null;
|
||||||
|
let currentElement: string | null = null;
|
||||||
|
const pointAttributes: PointAttribute[] = [];
|
||||||
|
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
if (!line || line === "end_header" || line.startsWith("comment ") || line.startsWith("obj_info ")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = line.split(/\s+/);
|
||||||
|
if (tokens[0] === "format") {
|
||||||
|
const value = tokens[1];
|
||||||
|
if (value !== "ascii" && value !== "binary_little_endian" && value !== "binary_big_endian") {
|
||||||
|
throw new PointcloudMetadataParseError("不支持的 PLY 编码");
|
||||||
|
}
|
||||||
|
pointDataFormat = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens[0] === "element") {
|
||||||
|
currentElement = tokens[1] ?? null;
|
||||||
|
if (currentElement === "vertex") {
|
||||||
|
const count = Number(tokens[2]);
|
||||||
|
if (!Number.isSafeInteger(count) || count < 0) {
|
||||||
|
throw new PointcloudMetadataParseError("PLY 顶点数无效");
|
||||||
|
}
|
||||||
|
pointCount = count;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens[0] === "property" && currentElement === "vertex") {
|
||||||
|
if (tokens[1] === "list") {
|
||||||
|
throw new PointcloudMetadataParseError("暂不支持列表型顶点属性");
|
||||||
|
}
|
||||||
|
const scalar = PLY_SCALAR_TYPES[tokens[1]];
|
||||||
|
const name = tokens[2];
|
||||||
|
if (!scalar || !name) {
|
||||||
|
throw new PointcloudMetadataParseError("PLY 包含未知的顶点属性类型");
|
||||||
|
}
|
||||||
|
pointAttributes.push({ name, type: scalar.type, bytes: scalar.bytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pointDataFormat) {
|
||||||
|
throw new PointcloudMetadataParseError("PLY 文件头缺少编码信息");
|
||||||
|
}
|
||||||
|
if (pointCount === null) {
|
||||||
|
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点数量");
|
||||||
|
}
|
||||||
|
if (pointAttributes.length === 0) {
|
||||||
|
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点属性");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pointCount,
|
||||||
|
pointAttributes,
|
||||||
|
pointDataFormat,
|
||||||
|
bytesPerPoint: pointAttributes.reduce((sum, attribute) => sum + attribute.bytes, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectPointcloudMetadata(filePath: string, displayName = filePath): PointcloudMetadataValues {
|
||||||
|
const extension = path.extname(displayName || filePath).toLowerCase();
|
||||||
|
if (extension !== ".ply") {
|
||||||
|
return {
|
||||||
|
metadataStatus: "unsupported",
|
||||||
|
pointCount: null,
|
||||||
|
pointAttributes: null,
|
||||||
|
pointDataFormat: null,
|
||||||
|
bytesPerPoint: null,
|
||||||
|
metadataError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = fs.openSync(filePath, "r");
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.allocUnsafe(MAX_PLY_HEADER_BYTES);
|
||||||
|
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||||
|
const parsed = parsePlyHeader(buffer.subarray(0, bytesRead));
|
||||||
|
return { metadataStatus: "ready", ...parsed, metadataError: null };
|
||||||
|
} finally {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
metadataStatus: "error",
|
||||||
|
pointCount: null,
|
||||||
|
pointAttributes: null,
|
||||||
|
pointDataFormat: null,
|
||||||
|
bytesPerPoint: null,
|
||||||
|
metadataError: error instanceof PointcloudMetadataParseError
|
||||||
|
? error.message
|
||||||
|
: "PLY 文件头解析失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
181
server/pointcloudRename.test.ts
Normal file
181
server/pointcloudRename.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { User } from "../drizzle/schema";
|
||||||
|
import type { TrpcContext } from "./_core/context";
|
||||||
|
import * as db from "./db";
|
||||||
|
import { appRouter } from "./routers";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getTaskPointcloudFileById: vi.fn(),
|
||||||
|
findTaskPointcloudFileByTaskAndName: vi.fn(),
|
||||||
|
updateTaskPointcloudFileName: vi.fn(),
|
||||||
|
getModelPointcloudFileById: vi.fn(),
|
||||||
|
findModelPointcloudFileByModelAndName: vi.fn(),
|
||||||
|
updateModelPointcloudFileName: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const now = new Date("2026-07-21T00:00:00Z");
|
||||||
|
const taskPointcloud = {
|
||||||
|
id: 11,
|
||||||
|
taskId: 101,
|
||||||
|
name: "原始点云.ply",
|
||||||
|
fileSize: 123,
|
||||||
|
filePath: "/data/tasks/101/physical-file.PLY",
|
||||||
|
source: "uploaded",
|
||||||
|
sourceResultId: null,
|
||||||
|
sourcePointcloudFileId: null,
|
||||||
|
sourceModelPointcloudId: null,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
const modelPointcloud = {
|
||||||
|
id: 22,
|
||||||
|
modelId: 202,
|
||||||
|
name: "车型点云.txt",
|
||||||
|
fileSize: 456,
|
||||||
|
filePath: "/data/models/202/physical-file.txt",
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
function user(role: User["role"]): User {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
username: `${role}-account`,
|
||||||
|
passwordHash: "unused",
|
||||||
|
name: role,
|
||||||
|
email: null,
|
||||||
|
role,
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastLoginAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function caller(role: User["role"] = "admin") {
|
||||||
|
const context: TrpcContext = {
|
||||||
|
user: user(role),
|
||||||
|
req: {} as TrpcContext["req"],
|
||||||
|
res: {} as TrpcContext["res"],
|
||||||
|
};
|
||||||
|
return appRouter.createCaller(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("点云重命名路由", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValue(taskPointcloud);
|
||||||
|
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(db.updateTaskPointcloudFileName).mockImplementation(async (id, name) => ({
|
||||||
|
...taskPointcloud,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
vi.mocked(db.getModelPointcloudFileById).mockResolvedValue(modelPointcloud);
|
||||||
|
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(db.updateModelPointcloudFileName).mockImplementation(async (id, name) => ({
|
||||||
|
...modelPointcloud,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("任务点云去除首尾空格并按实际路径保留扩展名", async () => {
|
||||||
|
const result = await caller().pointcloudFile.rename({ id: 11, baseName: " 新名称 " });
|
||||||
|
|
||||||
|
expect(result?.name).toBe("新名称.PLY");
|
||||||
|
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "新名称.PLY", 11);
|
||||||
|
expect(db.updateTaskPointcloudFileName).toHaveBeenCalledWith(11, "新名称.PLY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("车型库点云按 modelId 检查重名并返回更新记录", async () => {
|
||||||
|
const result = await caller("super_admin").modelPointcloudFile.rename({ id: 22, baseName: "扫描结果" });
|
||||||
|
|
||||||
|
expect(result?.name).toBe("扫描结果.txt");
|
||||||
|
expect(db.findModelPointcloudFileByModelAndName).toHaveBeenCalledWith(202, "扫描结果.txt", 22);
|
||||||
|
expect(db.updateModelPointcloudFileName).toHaveBeenCalledWith(22, "扫描结果.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", " "])("拒绝空名称 %#", async (baseName) => {
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "点云名称不能为空",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["目录/名称", "目录\\名称", "名称\n换行"])("拒绝非法名称 %s", async (baseName) => {
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("拒绝拼接扩展名后超过 255 个字符的名称", async () => {
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "名".repeat(252) })).rejects.toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "点云名称不能超过 255 个字符(含扩展名)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同一任务中忽略英文大小写禁止重名", async () => {
|
||||||
|
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValueOnce({
|
||||||
|
...taskPointcloud,
|
||||||
|
id: 12,
|
||||||
|
name: "NEW-NAME.ply",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "new-name" })).rejects.toMatchObject({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "当前任务下已存在同名点云",
|
||||||
|
});
|
||||||
|
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同一车型中忽略英文大小写禁止重名", async () => {
|
||||||
|
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValueOnce({
|
||||||
|
...modelPointcloud,
|
||||||
|
id: 23,
|
||||||
|
name: "SCAN.txt",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(caller().modelPointcloudFile.rename({ id: 22, baseName: "scan" })).rejects.toMatchObject({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "当前车型下已存在同名点云",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("记录不存在时返回 NOT_FOUND", async () => {
|
||||||
|
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(undefined);
|
||||||
|
vi.mocked(db.getModelPointcloudFileById).mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
await expect(caller().modelPointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("名称未变化时直接成功且不查询重名、不更新", async () => {
|
||||||
|
const unchanged = { ...taskPointcloud, name: "原始点云.PLY" };
|
||||||
|
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(unchanged);
|
||||||
|
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toEqual(unchanged);
|
||||||
|
expect(db.findTaskPointcloudFileByTaskAndName).not.toHaveBeenCalled();
|
||||||
|
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("名称只改变大小写时排除自身后允许更新", async () => {
|
||||||
|
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toMatchObject({
|
||||||
|
name: "原始点云.PLY",
|
||||||
|
});
|
||||||
|
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "原始点云.PLY", 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("普通用户无权调用,管理员和超级管理员可以调用", async () => {
|
||||||
|
await expect(caller("user").pointcloudFile.rename({ id: 11, baseName: "无权修改" })).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
await expect(caller("admin").pointcloudFile.rename({ id: 11, baseName: "管理员修改" })).resolves.toMatchObject({
|
||||||
|
name: "管理员修改.PLY",
|
||||||
|
});
|
||||||
|
await expect(caller("super_admin").pointcloudFile.rename({ id: 11, baseName: "超管修改" })).resolves.toMatchObject({
|
||||||
|
name: "超管修改.PLY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
89
server/pointcloudSample.test.ts
Normal file
89
server/pointcloudSample.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { samplePointcloudFile, samplePointcloudFiles } from './pointcloudSample';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (temporaryDirectories.length > 0) {
|
||||||
|
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeBinaryPly(properties: string[], records: number[][]): string {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'pointcloud-sample-'));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
const filePath = join(directory, 'cloud.ply');
|
||||||
|
const header = Buffer.from([
|
||||||
|
'ply',
|
||||||
|
'format binary_little_endian 1.0',
|
||||||
|
`element vertex ${records.length}`,
|
||||||
|
...properties.map((name) => `property float ${name}`),
|
||||||
|
'end_header',
|
||||||
|
'',
|
||||||
|
].join('\n'), 'ascii');
|
||||||
|
const body = Buffer.alloc(records.length * properties.length * 4);
|
||||||
|
records.forEach((record, recordIndex) => {
|
||||||
|
record.forEach((value, propertyIndex) => {
|
||||||
|
body.writeFloatLE(value, (recordIndex * properties.length + propertyIndex) * 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
writeFileSync(filePath, Buffer.concat([header, body]));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('samplePointcloudFile', () => {
|
||||||
|
it('不会为只有 xyz 的输入点云伪造误差', async () => {
|
||||||
|
const filePath = writeBinaryPly(
|
||||||
|
['x', 'y', 'z'],
|
||||||
|
[[1, 2, 3], [4, 5, 6]],
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = await samplePointcloudFile(filePath, 1_000, 2);
|
||||||
|
|
||||||
|
expect(points).toHaveLength(2);
|
||||||
|
expect(points.every((point) => point.error === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['error', 'deviation'])('保留 PLY 中真实的 %s 属性', async (errorProperty) => {
|
||||||
|
const filePath = writeBinaryPly(
|
||||||
|
['x', 'y', 'z', errorProperty],
|
||||||
|
[[1, 2, 3, 0.25], [4, 5, 6, -0.5]],
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = await samplePointcloudFile(filePath, 1_000, 2);
|
||||||
|
|
||||||
|
expect(points.map((point) => point.error)).toEqual([0.25, -0.5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('samplePointcloudFiles', () => {
|
||||||
|
it('合并彩色与无色 PLY,并使用 28 字节彩色步长', async () => {
|
||||||
|
const plain = writeBinaryPly(['x', 'y', 'z'], [[1, 2, 3], [4, 5, 6]]);
|
||||||
|
const colored = writeBinaryPly(
|
||||||
|
['x', 'y', 'z', 'red', 'green', 'blue'],
|
||||||
|
[[7, 8, 9, 255, 128, 0], [10, 11, 12, 0, 64, 255]],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await samplePointcloudFiles([plain, colored], 4, 10);
|
||||||
|
|
||||||
|
expect(result.pointCount).toBe(4);
|
||||||
|
expect(result.hasColor).toBe(true);
|
||||||
|
expect(result.hasError).toBe(false);
|
||||||
|
expect(result.buffer.byteLength).toBe(4 * 28);
|
||||||
|
expect(result.fileStats.map((file) => file.sampled)).toEqual([2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('限制总采样数量,无色 PLY 使用 16 字节步长', async () => {
|
||||||
|
const first = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i, i, i]));
|
||||||
|
const second = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i + 10, i, i]));
|
||||||
|
|
||||||
|
const result = await samplePointcloudFiles([first, second], 100, 6);
|
||||||
|
|
||||||
|
expect(result.pointCount).toBe(6);
|
||||||
|
expect(result.hasColor).toBe(false);
|
||||||
|
expect(result.buffer.byteLength).toBe(6 * 16);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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(
|
||||||
@@ -66,9 +74,10 @@ export async function randomSeekSample(
|
|||||||
const y = parseFloat(parts[1]);
|
const y = parseFloat(parts[1]);
|
||||||
const z = parseFloat(parts[2]);
|
const z = parseFloat(parts[2]);
|
||||||
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
||||||
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
const parsedError = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
||||||
if (isNaN(error)) error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5;
|
const point: SampledPoint = { x, y, z };
|
||||||
points.push({ x, y, z, error });
|
if (Number.isFinite(parsedError)) point.error = parsedError;
|
||||||
|
points.push(point);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
fs.closeSync(fd);
|
fs.closeSync(fd);
|
||||||
@@ -173,8 +182,10 @@ function samplePlyBinary(
|
|||||||
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
|
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
|
||||||
let xOff = -1, yOff = -1, zOff = -1;
|
let xOff = -1, yOff = -1, zOff = -1;
|
||||||
let rOff = -1, gOff = -1, bOff = -1;
|
let rOff = -1, gOff = -1, bOff = -1;
|
||||||
|
let errorOff = -1;
|
||||||
let xType = 'float', yType = 'float', zType = 'float';
|
let xType = 'float', yType = 'float', zType = 'float';
|
||||||
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
|
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
|
||||||
|
let errorType = 'float';
|
||||||
let byteOff = 0;
|
let byteOff = 0;
|
||||||
for (const p of properties) {
|
for (const p of properties) {
|
||||||
const n = p.name.toLowerCase();
|
const n = p.name.toLowerCase();
|
||||||
@@ -184,6 +195,7 @@ function samplePlyBinary(
|
|||||||
else if (n === 'red') { rOff = byteOff; rType = p.type; }
|
else if (n === 'red') { rOff = byteOff; rType = p.type; }
|
||||||
else if (n === 'green') { gOff = byteOff; gType = p.type; }
|
else if (n === 'green') { gOff = byteOff; gType = p.type; }
|
||||||
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
|
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
|
||||||
|
else if (n === 'error' || n === 'deviation') { errorOff = byteOff; errorType = p.type; }
|
||||||
byteOff += p.byteSize;
|
byteOff += p.byteSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +240,11 @@ function samplePlyBinary(
|
|||||||
const y = readNum(recBuf, yOff, yType);
|
const y = readNum(recBuf, yOff, yType);
|
||||||
const z = readNum(recBuf, zOff, zType);
|
const z = readNum(recBuf, zOff, zType);
|
||||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||||||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
const pt: SampledPoint = { x, y, z };
|
||||||
const pt: SampledPoint = { x, y, z, error };
|
if (errorOff >= 0) {
|
||||||
|
const error = readNum(recBuf, errorOff, errorType);
|
||||||
|
if (Number.isFinite(error)) pt.error = error;
|
||||||
|
}
|
||||||
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
|
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
|
||||||
pt.r = readNum(recBuf, rOff, rType);
|
pt.r = readNum(recBuf, rOff, rType);
|
||||||
pt.g = readNum(recBuf, gOff, gType);
|
pt.g = readNum(recBuf, gOff, gType);
|
||||||
@@ -289,18 +304,22 @@ function samplePlyAscii(
|
|||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const parts = line.split(/\s+/);
|
const parts = line.split(/\s+/);
|
||||||
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
|
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
|
||||||
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
|
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1, errorI = -1;
|
||||||
header.properties.forEach((p, i) => {
|
header.properties.forEach((p, i) => {
|
||||||
const n = p.name.toLowerCase();
|
const n = p.name.toLowerCase();
|
||||||
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
|
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
|
||||||
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
|
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
|
||||||
|
else if (n === 'error' || n === 'deviation') errorI = i;
|
||||||
});
|
});
|
||||||
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
|
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
|
||||||
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
|
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
|
||||||
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
|
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
|
||||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||||||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
const pt: SampledPoint = { x, y, z };
|
||||||
const pt: SampledPoint = { x, y, z, error };
|
if (errorI >= 0) {
|
||||||
|
const error = parseFloat(parts[errorI]);
|
||||||
|
if (Number.isFinite(error)) pt.error = error;
|
||||||
|
}
|
||||||
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
|
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
|
||||||
pt.r = parseFloat(parts[ri]);
|
pt.r = parseFloat(parts[ri]);
|
||||||
pt.g = parseFloat(parts[gi]);
|
pt.g = parseFloat(parts[gi]);
|
||||||
@@ -345,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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
||||||
import { appRouter } from "./routers";
|
|
||||||
import type { TrpcContext } from "./_core/context";
|
|
||||||
|
|
||||||
// Mock database functions
|
|
||||||
vi.mock("./db", () => ({
|
|
||||||
createProject: vi.fn().mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
name: "Test Project",
|
|
||||||
description: "Test Description",
|
|
||||||
status: "uploading",
|
|
||||||
totalPoints: 0,
|
|
||||||
totalChunks: 10,
|
|
||||||
uploadedChunks: 0,
|
|
||||||
totalSize: 0,
|
|
||||||
lodLevels: 0,
|
|
||||||
boundingBox: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
getProjectsByUserId: vi.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
name: "Test Project",
|
|
||||||
description: "Test Description",
|
|
||||||
status: "ready",
|
|
||||||
totalPoints: 1000000,
|
|
||||||
totalChunks: 10,
|
|
||||||
uploadedChunks: 10,
|
|
||||||
totalSize: 32000000,
|
|
||||||
lodLevels: 8,
|
|
||||||
boundingBox: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
getProjectById: vi.fn().mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
userId: 1,
|
|
||||||
name: "Test Project",
|
|
||||||
description: "Test Description",
|
|
||||||
status: "ready",
|
|
||||||
totalPoints: 1000000,
|
|
||||||
totalChunks: 10,
|
|
||||||
uploadedChunks: 10,
|
|
||||||
totalSize: 32000000,
|
|
||||||
lodLevels: 8,
|
|
||||||
boundingBox: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
updateProject: vi.fn().mockResolvedValue(undefined),
|
|
||||||
createAnalysisResult: vi.fn().mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
projectId: 1,
|
|
||||||
cadModelId: null,
|
|
||||||
type: "error_analysis",
|
|
||||||
status: "pending",
|
|
||||||
resultData: null,
|
|
||||||
errorStats: null,
|
|
||||||
measurements: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
updateAnalysisResult: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
|
||||||
|
|
||||||
function createAuthContext(): TrpcContext {
|
|
||||||
const user: AuthenticatedUser = {
|
|
||||||
id: 1,
|
|
||||||
openId: "test-user",
|
|
||||||
email: "test@example.com",
|
|
||||||
name: "Test User",
|
|
||||||
loginMethod: "manus",
|
|
||||||
role: "user",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
lastSignedIn: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
req: {
|
|
||||||
protocol: "https",
|
|
||||||
headers: {},
|
|
||||||
} as TrpcContext["req"],
|
|
||||||
res: {
|
|
||||||
clearCookie: vi.fn(),
|
|
||||||
} as unknown as TrpcContext["res"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("project router", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new project", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.project.create({
|
|
||||||
name: "Test Project",
|
|
||||||
description: "Test Description",
|
|
||||||
totalChunks: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.name).toBe("Test Project");
|
|
||||||
expect(result.status).toBe("uploading");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists user projects", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.project.list();
|
|
||||||
|
|
||||||
expect(Array.isArray(result)).toBe(true);
|
|
||||||
expect(result.length).toBeGreaterThan(0);
|
|
||||||
expect(result[0].name).toBe("Test Project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gets a project by id", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.project.get({ id: 1 });
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result?.id).toBe(1);
|
|
||||||
expect(result?.name).toBe("Test Project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates a project", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.project.update({
|
|
||||||
id: 1,
|
|
||||||
name: "Updated Project",
|
|
||||||
status: "ready",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("analysis router", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates an analysis result", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.analysis.create({
|
|
||||||
projectId: 1,
|
|
||||||
type: "error_analysis",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.type).toBe("error_analysis");
|
|
||||||
expect(result.status).toBe("pending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs error analysis simulation", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.analysis.runErrorAnalysis({
|
|
||||||
analysisId: 1,
|
|
||||||
projectId: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.errorStats).toBeDefined();
|
|
||||||
expect(result.errorStats.min).toBeDefined();
|
|
||||||
expect(result.errorStats.max).toBeDefined();
|
|
||||||
expect(result.errorStats.mean).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs measurement simulation", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.analysis.runMeasurement({
|
|
||||||
analysisId: 1,
|
|
||||||
projectId: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.measurements).toBeDefined();
|
|
||||||
expect(Array.isArray(result.measurements)).toBe(true);
|
|
||||||
expect(result.measurements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs compression simulation", async () => {
|
|
||||||
const ctx = createAuthContext();
|
|
||||||
const caller = appRouter.createCaller(ctx);
|
|
||||||
|
|
||||||
const result = await caller.analysis.runCompression({
|
|
||||||
analysisId: 1,
|
|
||||||
projectId: 1,
|
|
||||||
compressionRatio: 0.3,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.resultData).toBeDefined();
|
|
||||||
expect(result.resultData.compressionRatio).toBe(0.3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { COOKIE_NAME } from "@shared/const";
|
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||||
import { getSessionCookieOptions } from "./_core/cookies";
|
import { getSessionCookieOptions } from "./_core/cookies";
|
||||||
import { systemRouter } from "./_core/systemRouter";
|
import { systemRouter } from "./_core/systemRouter";
|
||||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@@ -13,12 +13,45 @@ 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 {
|
||||||
|
inspectionObjectContextFromName,
|
||||||
|
logCompatibilityWarnings,
|
||||||
|
normalizeMeasurements,
|
||||||
|
} from "./measurementCompatibility.js";
|
||||||
|
import {
|
||||||
|
clearLoginFailures,
|
||||||
|
createSessionToken,
|
||||||
|
hashPassword,
|
||||||
|
isLoginRateLimited,
|
||||||
|
publicUser,
|
||||||
|
recordLoginFailure,
|
||||||
|
validatePassword,
|
||||||
|
verifyPassword,
|
||||||
|
} from "./_core/auth";
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || "/data";
|
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||||
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
||||||
|
|
||||||
|
function pointcloudDisplayName(baseNameInput: string, filePath: string): string {
|
||||||
|
const baseName = baseNameInput.trim();
|
||||||
|
if (!baseName) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" });
|
||||||
|
}
|
||||||
|
if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `${baseName}${path.extname(filePath)}`;
|
||||||
|
if (Array.from(name).length > 255) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" });
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
"train-measurement",
|
"train-measurement",
|
||||||
"coupler-panel",
|
"coupler-panel",
|
||||||
@@ -64,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)
|
||||||
@@ -77,6 +111,29 @@ async function validateRegistrationRunInput(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function validateTaskInputCompatibility(
|
||||||
|
taskId: number,
|
||||||
|
pointcloud: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>[number],
|
||||||
|
cad: Awaited<ReturnType<typeof db.listTaskCadFiles>>[number],
|
||||||
|
) {
|
||||||
|
const task = await db.getInspectionTaskById(taskId);
|
||||||
|
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
|
||||||
|
if (!task.inspectionObjectId) {
|
||||||
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "历史任务尚未确认检测对象,请联系管理员完成归类后再运行算法。" });
|
||||||
|
}
|
||||||
|
if (pointcloud.source === "model_library" && pointcloud.sourceModelPointcloudId) {
|
||||||
|
const source = await db.getModelPointcloudFileById(pointcloud.sourceModelPointcloudId);
|
||||||
|
if (!source || source.inspectionObjectId !== task.inspectionObjectId) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "点云与当前检测对象不兼容。" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cad.source === "model_library" && cad.sourceModelCadFileId) {
|
||||||
|
if (!await db.cadAppliesToInspectionObject(cad.sourceModelCadFileId, task.inspectionObjectId)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "CAD 与当前检测对象不兼容。" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function latestRegisteredPointcloud(
|
function latestRegisteredPointcloud(
|
||||||
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
|
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
|
||||||
sourcePointcloudFileId: number,
|
sourcePointcloudFileId: number,
|
||||||
@@ -257,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DemoFlowMeasurementJob {
|
||||||
|
pluginId: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
taskNos?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DemoFlowMetadata {
|
||||||
|
demoFlowMode: "all" | "selected";
|
||||||
|
demoFlowSelectedTaskNos?: number[];
|
||||||
|
demoFlowSelectedTaskCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
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") {
|
||||||
@@ -279,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],
|
||||||
@@ -294,24 +365,248 @@ 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,
|
||||||
|
|
||||||
auth: router({
|
auth: router({
|
||||||
me: publicProcedure.query(opts => opts.ctx.user),
|
login: publicProcedure
|
||||||
|
.input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const username = input.username.trim();
|
||||||
|
const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown";
|
||||||
|
if (isLoginRateLimited(ip, username)) {
|
||||||
|
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" });
|
||||||
|
}
|
||||||
|
const user = await db.getUserByUsername(username);
|
||||||
|
const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null));
|
||||||
|
if (!user || !valid) {
|
||||||
|
recordLoginFailure(ip, username);
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" });
|
||||||
|
}
|
||||||
|
clearLoginFailures(ip, username);
|
||||||
|
await db.updateUser(user.id, { lastLoginAt: new Date() });
|
||||||
|
const token = await createSessionToken(user);
|
||||||
|
ctx.res.cookie(COOKIE_NAME, token, {
|
||||||
|
...getSessionCookieOptions(ctx.req),
|
||||||
|
maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
return publicUser({ ...user, lastLoginAt: new Date() });
|
||||||
|
}),
|
||||||
|
me: sessionProcedure.query(opts => publicUser(opts.ctx.user)),
|
||||||
logout: publicProcedure.mutation(({ ctx }) => {
|
logout: publicProcedure.mutation(({ ctx }) => {
|
||||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||||
return { success: true } as const;
|
return { success: true } as const;
|
||||||
}),
|
}),
|
||||||
|
changePassword: sessionProcedure
|
||||||
|
.input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" });
|
||||||
|
}
|
||||||
|
if (input.currentPassword === input.newPassword) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" });
|
||||||
|
}
|
||||||
|
validatePassword(input.newPassword);
|
||||||
|
const passwordHash = await hashPassword(input.newPassword);
|
||||||
|
const nextVersion = ctx.user.sessionVersion + 1;
|
||||||
|
await db.updateUser(ctx.user.id, {
|
||||||
|
passwordHash,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: nextVersion,
|
||||||
|
});
|
||||||
|
const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion });
|
||||||
|
ctx.res.cookie(COOKIE_NAME, token, {
|
||||||
|
...getSessionCookieOptions(ctx.req),
|
||||||
|
maxAge: SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
return { success: true } as const;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
users: router({
|
||||||
|
list: superAdminProcedure
|
||||||
|
.input(z.object({ search: z.string().max(100).optional() }).optional())
|
||||||
|
.query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)),
|
||||||
|
create: superAdminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"),
|
||||||
|
temporaryPassword: z.string().min(8).max(256),
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||||
|
role: z.enum(["user", "admin", "super_admin"]),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const row = await db.createUser({
|
||||||
|
username: input.username,
|
||||||
|
passwordHash: await hashPassword(input.temporaryPassword),
|
||||||
|
name: input.name,
|
||||||
|
email: input.email || null,
|
||||||
|
role: input.role,
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 1,
|
||||||
|
sessionVersion: 0,
|
||||||
|
});
|
||||||
|
if (!row) throw new Error("创建用户失败");
|
||||||
|
return publicUser(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateKeyError(error)) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
update: superAdminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
||||||
|
name: z.string().trim().min(1).max(100).optional(),
|
||||||
|
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||||
|
role: z.enum(["user", "admin", "super_admin"]).optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const target = await db.getUserById(input.id);
|
||||||
|
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||||
|
if (target.id === ctx.user.id && input.role && input.role !== target.role) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" });
|
||||||
|
}
|
||||||
|
if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" });
|
||||||
|
}
|
||||||
|
const { id, email, ...rest } = input;
|
||||||
|
try {
|
||||||
|
const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) });
|
||||||
|
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||||
|
return publicUser(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
setActive: superAdminProcedure
|
||||||
|
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const target = await db.getUserById(input.id);
|
||||||
|
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||||
|
if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" });
|
||||||
|
if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" });
|
||||||
|
}
|
||||||
|
if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" });
|
||||||
|
}
|
||||||
|
const row = await db.updateUser(target.id, {
|
||||||
|
isActive: input.isActive ? 1 : 0,
|
||||||
|
sessionVersion: target.sessionVersion + 1,
|
||||||
|
});
|
||||||
|
return publicUser(row!);
|
||||||
|
}),
|
||||||
|
resetPassword: superAdminProcedure
|
||||||
|
.input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" });
|
||||||
|
const target = await db.getUserById(input.id);
|
||||||
|
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||||
|
await db.updateUser(target.id, {
|
||||||
|
passwordHash: await hashPassword(input.temporaryPassword),
|
||||||
|
mustChangePassword: 1,
|
||||||
|
sessionVersion: target.sessionVersion + 1,
|
||||||
|
});
|
||||||
|
return { success: true } as const;
|
||||||
|
}),
|
||||||
|
delete: superAdminProcedure
|
||||||
|
.input(z.object({ id: z.number().int().positive() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" });
|
||||||
|
const target = await db.getUserById(input.id);
|
||||||
|
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||||
|
if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" });
|
||||||
|
}
|
||||||
|
await db.deleteUser(target.id);
|
||||||
|
return { success: true } as const;
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── 车系 ──────────────────────────────────────────────────────────────────
|
// ─── 车系 ──────────────────────────────────────────────────────────────────
|
||||||
@@ -402,16 +697,88 @@ export const appRouter = router({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ─── 检测对象配置 ─────────────────────────────────────────────────────────
|
||||||
|
inspectionObject: router({
|
||||||
|
list: protectedProcedure
|
||||||
|
.input(z.object({ modelId: z.number(), activeOnly: z.boolean().optional() }))
|
||||||
|
.query(({ input }) => db.listInspectionObjects(input.modelId, input.activeOnly ?? false)),
|
||||||
|
create: adminProcedure
|
||||||
|
.input(z.object({ modelId: z.number(), name: z.string().trim().min(1).max(100), description: z.string().optional() }))
|
||||||
|
.mutation(({ input }) => db.createInspectionObject({ ...input, description: input.description ?? null, status: "draft" })),
|
||||||
|
update: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), name: z.string().trim().min(1).max(100).optional(), description: z.string().optional() }))
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const { id, ...data } = input;
|
||||||
|
return db.updateInspectionObject(id, data);
|
||||||
|
}),
|
||||||
|
configureDefaults: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), defaultModelPointcloudId: z.number(), defaultModelCadFileId: z.number(), active: z.boolean() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try { return await db.configureInspectionObject(input); }
|
||||||
|
catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "默认输入方案无效" }); }
|
||||||
|
}),
|
||||||
|
setStatus: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), status: z.enum(["draft", "active"]) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
if (input.status === "active") {
|
||||||
|
const object = await db.getInspectionObjectById(input.id);
|
||||||
|
if (!object?.defaultModelPointcloudId || !object.defaultModelCadFileId) {
|
||||||
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "缺少预置点云或默认 CAD,不能启用检测对象" });
|
||||||
|
}
|
||||||
|
return db.configureInspectionObject({
|
||||||
|
id: object.id,
|
||||||
|
defaultModelPointcloudId: object.defaultModelPointcloudId,
|
||||||
|
defaultModelCadFileId: object.defaultModelCadFileId,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return db.updateInspectionObject(input.id, { status: "draft" });
|
||||||
|
}),
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await db.deleteInspectionObject(input.id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof db.InspectionObjectReferencedError) {
|
||||||
|
throw new TRPCError({ code: "PRECONDITION_FAILED", message: error.message });
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message === "检测对象不存在") {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────
|
// ─── 车型 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);
|
||||||
@@ -444,7 +811,32 @@ export const appRouter = router({
|
|||||||
return db.listModelPointcloudFiles(input.modelId);
|
return db.listModelPointcloudFiles(input.modelId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
listForInspectionObject: protectedProcedure
|
||||||
|
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
|
||||||
|
.query(({ input }) => db.listModelPointcloudFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
|
||||||
|
|
||||||
|
assignInspectionObject: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
|
||||||
|
.mutation(({ input }) => db.assignModelPointcloudInspectionObject(input.id, input.inspectionObjectId)),
|
||||||
|
|
||||||
|
rename: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const row = await db.getModelPointcloudFileById(input.id);
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "车型库点云不存在" });
|
||||||
|
}
|
||||||
|
const name = pointcloudDisplayName(input.baseName, row.filePath);
|
||||||
|
if (name === row.name) return row;
|
||||||
|
|
||||||
|
const duplicate = await db.findModelPointcloudFileByModelAndName(row.modelId, name, row.id);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "当前车型下已存在同名点云" });
|
||||||
|
}
|
||||||
|
return db.updateModelPointcloudFileName(row.id, name);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: adminProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await db.deleteModelPointcloudFile(input.id);
|
await db.deleteModelPointcloudFile(input.id);
|
||||||
@@ -524,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(),
|
||||||
@@ -538,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,
|
||||||
@@ -596,6 +1003,23 @@ export const appRouter = router({
|
|||||||
return db.listTaskPointcloudFiles(input.taskId);
|
return db.listTaskPointcloudFiles(input.taskId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
rename: adminProcedure
|
||||||
|
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const row = await db.getTaskPointcloudFileById(input.id);
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" });
|
||||||
|
}
|
||||||
|
const name = pointcloudDisplayName(input.baseName, row.filePath);
|
||||||
|
if (name === row.name) return row;
|
||||||
|
|
||||||
|
const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id);
|
||||||
|
if (duplicate) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" });
|
||||||
|
}
|
||||||
|
return db.updateTaskPointcloudFileName(row.id, name);
|
||||||
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -631,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
|
||||||
@@ -801,11 +1261,7 @@ export const appRouter = router({
|
|||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
// 先取消正在运行或排队的 job,再删数据库记录
|
return deleteAnalysisResult(input.id);
|
||||||
cancelAlgorithmJob(input.id);
|
|
||||||
cancelLegacyAnalysisJob(input.id);
|
|
||||||
await db.deleteTaskAnalysisResult(input.id);
|
|
||||||
return { success: true };
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -956,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(
|
||||||
@@ -968,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
|
||||||
@@ -1053,6 +1488,26 @@ export const appRouter = router({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
markDemoFlowCancelled(input.taskId);
|
markDemoFlowCancelled(input.taskId);
|
||||||
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
|
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
|
||||||
|
const rows = await db.listTaskAnalysisResults(input.taskId);
|
||||||
|
const latestDemoRow = rows.find((row) => {
|
||||||
|
const data = row.resultData as Record<string, unknown> | null;
|
||||||
|
return typeof data?.demoFlowRunId === "string";
|
||||||
|
});
|
||||||
|
if (latestDemoRow) {
|
||||||
|
const latestDemoData = latestDemoRow.resultData as Record<string, unknown>;
|
||||||
|
const latestRunId = latestDemoData.demoFlowRunId;
|
||||||
|
const cancelledAt = new Date().toISOString();
|
||||||
|
// 即使停止发生在“配准完成、测量记录尚未创建”的短暂窗口,
|
||||||
|
// 也在本次链路的全部现有记录上留下结束标记,避免刷新后重新显示为排队中。
|
||||||
|
await Promise.all(
|
||||||
|
rows
|
||||||
|
.filter((row) => {
|
||||||
|
const data = row.resultData as Record<string, unknown> | null;
|
||||||
|
return data?.demoFlowRunId === latestRunId;
|
||||||
|
})
|
||||||
|
.map((row) => db.patchTaskAnalysisResultData(row.id, { demoFlowCancelledAt: cancelledAt })),
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
scheduler: algorithmJobStats(),
|
scheduler: algorithmJobStats(),
|
||||||
|
|||||||
44
server/session.security.test.ts
Normal file
44
server/session.security.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { User } from "../drizzle/schema";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({ getUserById: vi.fn() }));
|
||||||
|
|
||||||
|
import * as db from "./db";
|
||||||
|
import { authenticateRequest, createSessionToken } from "./_core/auth";
|
||||||
|
import { ENV } from "./_core/env";
|
||||||
|
import { COOKIE_NAME } from "../shared/const";
|
||||||
|
|
||||||
|
function user(overrides: Partial<User> = {}): User {
|
||||||
|
const now = new Date();
|
||||||
|
return { id: 7, username: "session-user", passwordHash: "unused", name: "Session User", email: null, role: "user", isActive: 1, mustChangePassword: 0, sessionVersion: 3, createdAt: now, updatedAt: now, lastLoginAt: null, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWith(token: string) {
|
||||||
|
return { headers: { cookie: `${COOKIE_NAME}=${token}` } } as Parameters<typeof authenticateRequest>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("会话即时失效", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
ENV.cookieSecret = "test-session-secret-with-at-least-32-chars";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有效账号与匹配版本可恢复会话", async () => {
|
||||||
|
const current = user();
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(current);
|
||||||
|
const token = await createSessionToken(current);
|
||||||
|
await expect(authenticateRequest(requestWith(token))).resolves.toMatchObject({ id: 7, sessionVersion: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("改密或重置密码递增版本后旧会话立即失效", async () => {
|
||||||
|
const token = await createSessionToken(user());
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(user({ sessionVersion: 4 }));
|
||||||
|
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("账号被禁用后旧会话立即失效", async () => {
|
||||||
|
const token = await createSessionToken(user());
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(user({ isActive: 0 }));
|
||||||
|
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
server/users.security.test.ts
Normal file
61
server/users.security.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { User } from "../drizzle/schema";
|
||||||
|
import type { TrpcContext } from "./_core/context";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getUserById: vi.fn(),
|
||||||
|
countActiveSuperAdmins: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
deleteUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as db from "./db";
|
||||||
|
import { appRouter } from "./routers";
|
||||||
|
|
||||||
|
function makeUser(id: number, role: User["role"] = "super_admin"): User {
|
||||||
|
const now = new Date();
|
||||||
|
return { id, username: `user-${id}`, passwordHash: "unused", name: `User ${id}`, email: null, role, isActive: 1, mustChangePassword: 0, sessionVersion: 0, createdAt: now, updatedAt: now, lastLoginAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function caller() {
|
||||||
|
const ctx: TrpcContext = { user: makeUser(1), req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
|
||||||
|
return appRouter.createCaller(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("用户管理安全保护", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("禁止删除自己", async () => {
|
||||||
|
await expect(caller().users.delete({ id: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(db.deleteUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("禁止修改自己的角色", async () => {
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(makeUser(1));
|
||||||
|
await expect(caller().users.update({ id: 1, role: "admin" })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(db.updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("禁止禁用最后一个有效超级管理员", async () => {
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(makeUser(2));
|
||||||
|
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(1);
|
||||||
|
await expect(caller().users.setActive({ id: 2, isActive: false })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(db.updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("有多个超级管理员时允许禁用其中一个并使旧会话失效", async () => {
|
||||||
|
const target = makeUser(2);
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(target);
|
||||||
|
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(2);
|
||||||
|
vi.mocked(db.updateUser).mockResolvedValue({ ...target, isActive: 0, sessionVersion: 1 });
|
||||||
|
await expect(caller().users.setActive({ id: 2, isActive: false })).resolves.toMatchObject({ isActive: 0, sessionVersion: 1 });
|
||||||
|
expect(db.updateUser).toHaveBeenCalledWith(2, { isActive: 0, sessionVersion: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("遗留账号未修改用户名并重置密码前不能启用", async () => {
|
||||||
|
const legacy = { ...makeUser(2, "user"), username: "legacy_user_2", passwordHash: null, isActive: 0 };
|
||||||
|
vi.mocked(db.getUserById).mockResolvedValue(legacy);
|
||||||
|
await expect(caller().users.setActive({ id: 2, isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||||
|
expect(db.updateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const COOKIE_NAME = "app_session_id";
|
export const COOKIE_NAME = "app_session_id";
|
||||||
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
|
export const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||||
export const AXIOS_TIMEOUT_MS = 30_000;
|
export const UNAUTHED_ERR_MSG = '请先登录';
|
||||||
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
|
export const NOT_ADMIN_ERR_MSG = '无权执行此操作';
|
||||||
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';
|
export const MUST_CHANGE_PASSWORD_ERR_MSG = '请先修改初始密码';
|
||||||
|
|||||||
21
shared/pointcloudMetadata.ts
Normal file
21
shared/pointcloudMetadata.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type PointcloudMetadataStatus = "pending" | "ready" | "unsupported" | "error";
|
||||||
|
|
||||||
|
export type PointcloudDataFormat =
|
||||||
|
| "ascii"
|
||||||
|
| "binary_little_endian"
|
||||||
|
| "binary_big_endian";
|
||||||
|
|
||||||
|
export type PointAttribute = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PointcloudMetadataValues = {
|
||||||
|
metadataStatus: Exclude<PointcloudMetadataStatus, "pending">;
|
||||||
|
pointCount: number | null;
|
||||||
|
pointAttributes: PointAttribute[] | null;
|
||||||
|
pointDataFormat: PointcloudDataFormat | null;
|
||||||
|
bytesPerPoint: number | null;
|
||||||
|
metadataError: string | null;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user