Compare commits

...

3 Commits

66 changed files with 4498 additions and 1116 deletions

View File

@@ -34,11 +34,7 @@ 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

View File

@@ -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)

View File

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

View File

@@ -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} />

View File

@@ -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,

View File

@@ -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, statusClass, statusLabel, type Overlay3D } from './GenericResultView';
import { aggregateDemoFlowResults, 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';
@@ -108,14 +110,15 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
); );
} }
function MeasurementTable({ items }: { items: Measurement[] }) { function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
const showDetails = displayMode === 'maximized';
return ( return (
<table className="w-full text-[10px] border-collapse"> <table className="w-full text-[10px] border-collapse">
<thead> <thead>
<tr className="text-muted-foreground"> <tr className="text-muted-foreground">
<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>
@@ -123,8 +126,8 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
{items.map((m, i) => ( {items.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">{m.name}</td>
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td> {showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
<td className="text-right text-muted-foreground">±{m.tolerance}</td> {showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
<td className="text-right"> <td className="text-right">
<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' :
@@ -233,6 +236,7 @@ type DemoFlowMeta = {
}; };
const SHOW_LEGACY_ANALYSIS_ACTIONS = false; 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 }
@@ -316,9 +320,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());
@@ -401,10 +410,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 (
@@ -445,7 +486,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="停止"
@@ -455,13 +496,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">
@@ -486,10 +527,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>
@@ -677,6 +718,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>
@@ -685,20 +727,141 @@ 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 aggregated = aggregateDemoFlowResults(
const data = asRecord(r.resultData); group.results,
const progress = numberFromUnknown(data?.progress); (result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`; { includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
}); );
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' : ''}`}>
@@ -719,13 +882,92 @@ 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">项目</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>
{aggregated.tableRows.map((row) => row.kind === 'algorithm' ? (
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
<td colSpan={showMeasurementDetails ? 4 : 2} 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 truncate max-w-[140px]" title={row.measurement.name}>{row.measurement.name}</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)}`}>
{row.measurement.status === 'info' ? '参考' : statusLabel(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>
)} )}
@@ -739,7 +981,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>
); );
@@ -764,7 +1006,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。 误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。 装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */} 偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
{SHOW_LEGACY_ANALYSIS_ACTIONS && ( {canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
<> <>
<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' : ''}`} className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
@@ -808,7 +1050,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>
)} )}

View File

@@ -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>
); );

View File

@@ -0,0 +1,208 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
import type { DemoFlowState, DemoFlowStep, 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(() => {
switch (state.overallStatus) {
case 'processing': return '一键全流程演示运行中';
case 'pending': return '一键全流程演示排队中';
case 'completed': return '一键全流程演示已完成';
case 'error': return '一键全流程演示失败';
case 'cancelled': return '一键全流程演示已停止';
default: return '一键全流程演示';
}
}, [state.overallStatus]);
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-64 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="mb-1.5 flex items-center justify-between gap-3 text-[10px]">
<span className="truncate font-medium">一键全流程 · {overallStatusLabel(state.overallStatus)}</span>
<span className="flex-shrink-0 font-mono text-muted-foreground">{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 px-6 py-5 max-w-md w-full mx-4 shadow-2xl border border-border/60 bg-[oklch(0.1_0.01_240)]/90 backdrop-blur-sm">
<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="text-sm font-medium truncate">{title}</h3>
<p className="text-[10px] text-muted-foreground">
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}%
</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={`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="text-xs font-medium truncate">{label}</span>
<span className="text-[9px] text-muted-foreground flex-shrink-0">
{step.status === 'processing' ? '运行中'
: step.status === 'pending' ? '排队中'
: step.status === 'completed' ? '已完成'
: step.status === 'error' ? '失败'
: step.status === 'cancelled' ? '已停止'
: step.status}
</span>
</div>
{showProgress && (
<div className="mt-1.5">
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
/>
</div>
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
{Math.round((step.progress ?? 0) * 100)}%
</div>
</div>
)}
</div>
</div>
);
})}
</div>
{state.overallStatus === 'processing' && state.currentStep && (
<div className="mt-4 text-[10px] text-muted-foreground text-center">
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } f
interface Measurement { interface Measurement {
name: string; name: string;
value: number; value: number | null;
unit?: string; unit?: string;
tolerance?: number; tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */ /** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
@@ -22,6 +22,7 @@ interface Measurement {
status?: 'pass' | 'warning' | 'fail' | 'info'; status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */ /** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string; passLabel?: string;
warningLabel?: string;
failLabel?: string; failLabel?: string;
} }
@@ -66,6 +67,8 @@ 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: { onViewOverlay?: (overlay: {
taskId: number; taskId: number;
@@ -76,7 +79,7 @@ interface Props {
}) => void; }) => 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';
@@ -89,12 +92,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:
@@ -102,20 +105,29 @@ 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 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);
} }
function numberArrayFromUnknown(value: unknown): number[] | undefined { export function numberArrayFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined; if (!Array.isArray(value)) return undefined;
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id)); const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
return ids.length > 0 ? ids : undefined; return ids.length > 0 ? ids : undefined;
} }
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D { export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
if (!overlay || overlay.kind === 'none') return { kind: 'none' }; if (!overlay || overlay.kind === 'none') return { kind: 'none' };
if (overlay.kind === 'ply') { if (overlay.kind === 'ply') {
const files = Array.isArray(overlay.files) const files = Array.isArray(overlay.files)
@@ -157,11 +169,13 @@ export default function GenericResultView({
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 = 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)
@@ -206,8 +220,8 @@ export default function GenericResultView({
<thead> <thead>
<tr className="text-muted-foreground"> <tr className="text-muted-foreground">
<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>
@@ -217,12 +231,12 @@ export default function GenericResultView({
<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}
</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
@@ -230,7 +244,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)}

View File

@@ -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;
@@ -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';
@@ -1202,6 +1285,8 @@ 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({
@@ -1228,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);
@@ -1378,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>
@@ -1418,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;
}); });
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */ /** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
@@ -1535,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);
@@ -1551,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];
@@ -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({
@@ -1651,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);
@@ -1964,6 +2059,7 @@ 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 = const hasVisibleCadHeatmap =
pointCloudRenderMode === 'smoothHeatmap' && pointCloudRenderMode === 'smoothHeatmap' &&
serverCadHeatmap.status === 'ready' && serverCadHeatmap.status === 'ready' &&
@@ -2120,6 +2216,7 @@ export default function PointCloudViewer({
plyOverlayData={plyOverlayData} plyOverlayData={plyOverlayData}
roiPickMode={roiPickMode} roiPickMode={roiPickMode}
onRoiPointPicked={onRoiPointPicked} onRoiPointPicked={onRoiPointPicked}
demoFlowActive={demoFlowActive}
/> />
</Canvas> </Canvas>
</div> </div>

View File

@@ -14,9 +14,19 @@ import {
FileBox, FileBox,
Eye, Eye,
Library, Library,
Pencil,
Info,
} from 'lucide-react'; } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAuth } from '@/_core/hooks/useAuth';
interface Props { interface Props {
modelId: number; modelId: number;
@@ -40,6 +50,50 @@ function formatSize(bytes: number) {
return (bytes / 1e9).toFixed(2) + ' GB'; return (bytes / 1e9).toFixed(2) + ' GB';
} }
function compactNumber(value: number, unit: number) {
const scaled = value / unit;
return Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(1).replace(/\.0$/, '');
}
function formatCompactPointCount(file: PointcloudMetadataFile) {
if (file.metadataStatus === 'error') return '解析失败';
if (file.metadataStatus !== 'ready' || file.pointCount == null) return '点数未知';
if (file.pointCount >= 1e8) return `${compactNumber(file.pointCount, 1e8)}亿点`;
if (file.pointCount >= 1e4) return `${compactNumber(file.pointCount, 1e4)}万点`;
return `${file.pointCount.toLocaleString('zh-CN')}点`;
}
const pointDataFormatLabels: Record<string, string> = {
ascii: 'ASCII',
binary_little_endian: '二进制(小端)',
binary_big_endian: '二进制(大端)',
};
type PointcloudMetadataFile = {
name: string;
fileSize: number;
metadataStatus: 'pending' | 'ready' | 'unsupported' | 'error';
pointCount: number | null;
pointAttributes: Array<{ name: string; type: string; bytes: number }> | null;
pointDataFormat: string | null;
bytesPerPoint: number | null;
};
type PointcloudMetadataDetail = PointcloudMetadataFile & { sourceLabel: string };
function pointcloudFormat(name: string) {
const extension = name.split('.').pop();
return extension ? extension.toUpperCase() : '未知';
}
function unsupportedMetadataMessage(name: string) {
const extension = name.split('.').pop()?.toLowerCase();
if (extension === 'txt' || extension === 'xyz' || extension === 'pts') {
return '文本点云没有标准文件头,当前版本不执行完整扫描。';
}
return '当前版本暂不支持解析此格式的点云属性。';
}
function formatDateTime(value?: string | Date) { function formatDateTime(value?: string | Date) {
if (!value) return '未知时间'; if (!value) return '未知时间';
const date = value instanceof Date ? value : new Date(value); const date = value instanceof Date ? value : new Date(value);
@@ -47,6 +101,23 @@ function formatDateTime(value?: string | Date) {
return date.toLocaleString('zh-CN', { hour12: false }); return date.toLocaleString('zh-CN', { hour12: false });
} }
function pointcloudNameParts(name: string, filePath: string) {
const physicalName = filePath.split(/[\\/]/).pop() ?? filePath;
const dotIndex = physicalName.lastIndexOf('.');
const extension = dotIndex > 0 ? physicalName.slice(dotIndex) : '';
const baseName = extension && name.toLowerCase().endsWith(extension.toLowerCase())
? name.slice(0, -extension.length)
: name;
return { baseName, extension };
}
type PointcloudRenameTarget = {
scope: 'task' | 'model';
id: number;
baseName: string;
extension: string;
};
type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error'; type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error';
const taskStatusLabels: Record<InspectionTaskStatus, string> = { const taskStatusLabels: Record<InspectionTaskStatus, string> = {
@@ -83,6 +154,7 @@ export default function TaskManager({
onStartCreate, onStartCreate,
onEndCreate, onEndCreate,
}: Props) { }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [taskName, setTaskName] = useState(''); const [taskName, setTaskName] = useState('');
@@ -94,6 +166,9 @@ export default function TaskManager({
const [pcLibraryOpen, setPcLibraryOpen] = useState(false); const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set()); const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false); const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
const [renameTarget, setRenameTarget] = useState<PointcloudRenameTarget | null>(null);
const [renameError, setRenameError] = useState<string | null>(null);
const [metadataDetail, setMetadataDetail] = useState<PointcloudMetadataDetail | null>(null);
// ── 点云文件多选状态 ────────────────────────────────────────────────────── // ── 点云文件多选状态 ──────────────────────────────────────────────────────
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set()); const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
@@ -136,10 +211,36 @@ export default function TaskManager({
const deletePcFile = trpc.pointcloudFile.delete.useMutation({ const deletePcFile = trpc.pointcloudFile.delete.useMutation({
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }), onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
}); });
const renamePcFile = trpc.pointcloudFile.rename.useMutation({
onSuccess: (updated) => {
if (activeTaskId != null) {
utils.pointcloudFile.list.setData({ taskId: activeTaskId }, (current) =>
current?.map((file) => (file.id === updated.id ? updated : file)),
);
void utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
}
setRenameTarget(null);
setRenameError(null);
toast.success('名称已修改');
},
onError: (error) => setRenameError(error.message || '修改名称失败'),
});
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery( const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
{ modelId }, { modelId },
{ enabled: activeTaskId != null }, { enabled: activeTaskId != null },
); );
const renameModelPcFile = trpc.modelPointcloudFile.rename.useMutation({
onSuccess: (updated) => {
utils.modelPointcloudFile.list.setData({ modelId }, (current) =>
current?.map((file) => (file.id === updated.id ? updated : file)),
);
void utils.modelPointcloudFile.list.invalidate({ modelId });
setRenameTarget(null);
setRenameError(null);
toast.success('名称已修改');
},
onError: (error) => setRenameError(error.message || '修改名称失败'),
});
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({ const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
onSuccess: () => { onSuccess: () => {
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId }); if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
@@ -181,6 +282,9 @@ export default function TaskManager({
setSelectedModelCadFileIds(new Set()); setSelectedModelCadFileIds(new Set());
setPcLibraryOpen(false); setPcLibraryOpen(false);
setCadLibraryOpen(false); setCadLibraryOpen(false);
setRenameTarget(null);
setRenameError(null);
setMetadataDetail(null);
}, [activeTaskId]); }, [activeTaskId]);
// 通知父组件点云选中变化 // 通知父组件点云选中变化
@@ -220,6 +324,34 @@ export default function TaskManager({
}); });
} }
const renamePending = renamePcFile.isPending || renameModelPcFile.isPending;
function startRename(
scope: PointcloudRenameTarget['scope'],
file: { id: number; name: string; filePath: string },
) {
if (renamePending) return;
setRenameTarget({ scope, id: file.id, ...pointcloudNameParts(file.name, file.filePath) });
setRenameError(null);
}
function cancelRename() {
if (renamePending) return;
setRenameTarget(null);
setRenameError(null);
}
function submitRename() {
if (!renameTarget || renamePending) return;
setRenameError(null);
const input = { id: renameTarget.id, baseName: renameTarget.baseName };
if (renameTarget.scope === 'task') {
renamePcFile.mutate(input);
} else {
renameModelPcFile.mutate(input);
}
}
function openUpload(taskId: number) { function openUpload(taskId: number) {
pendingTaskId.current = taskId; pendingTaskId.current = taskId;
fileRef.current?.click(); fileRef.current?.click();
@@ -396,7 +528,7 @@ export default function TaskManager({
<span className="industrial-label flex items-center gap-1.5"> <span className="industrial-label flex items-center gap-1.5">
<ClipboardList className="w-3.5 h-3.5" /> 检测任务 <ClipboardList className="w-3.5 h-3.5" /> 检测任务
</span> </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={() => { onClick={() => {
setAdding(true); setAdding(true);
@@ -404,10 +536,10 @@ export default function TaskManager({
}} }}
> >
<Plus className="w-3 h-3" /> 新建任务 <Plus className="w-3 h-3" /> 新建任务
</button> </button>}
</div> </div>
{adding && ( {canWrite && adding && (
<div className="flex items-center gap-1 mb-1.5"> <div className="flex items-center gap-1 mb-1.5">
<input <input
autoFocus autoFocus
@@ -463,7 +595,7 @@ export default function TaskManager({
> >
{taskStatusLabels[status] ?? status} {taskStatusLabels[status] ?? status}
</span> </span>
<button {canWrite && <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary" className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
title="上传点云文件" title="上传点云文件"
onClick={(e) => { onClick={(e) => {
@@ -472,8 +604,8 @@ export default function TaskManager({
}} }}
> >
<Upload className="w-3 h-3" /> <Upload className="w-3 h-3" />
</button> </button>}
<button {canWrite && <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive" className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
title="删除任务" title="删除任务"
onClick={(e) => { onClick={(e) => {
@@ -482,7 +614,7 @@ export default function TaskManager({
}} }}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>}
</div> </div>
); );
})} })}
@@ -490,7 +622,7 @@ export default function TaskManager({
{tasks.length === 0 && ( {tasks.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>
)} )}
</div> </div>
@@ -526,20 +658,20 @@ export default function TaskManager({
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button {canWrite && <button
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => openUpload(activeTaskId)} onClick={() => openUpload(activeTaskId)}
> >
<Upload className="w-3 h-3" /> 上传到任务 <Upload className="w-3 h-3" /> 上传到任务
</button> </button>}
<button {canWrite && <button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${ className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
pcLibraryOpen ? 'text-primary border-primary/50' : '' pcLibraryOpen ? 'text-primary border-primary/50' : ''
}`} }`}
onClick={() => setPcLibraryOpen((open) => !open)} onClick={() => setPcLibraryOpen((open) => !open)}
> >
<Library className="w-3 h-3" /> 从车型库选择 <Library className="w-3 h-3" /> 从车型库选择
</button> </button>}
{pcFiles.length > 0 && ( {pcFiles.length > 0 && (
<button <button
className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
@@ -568,7 +700,7 @@ export default function TaskManager({
</div> </div>
)} )}
{pcLibraryOpen && ( {canWrite && pcLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2"> <div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
@@ -584,29 +716,144 @@ export default function TaskManager({
</button> </button>
</div> </div>
<div className="space-y-1 max-h-40 overflow-auto"> <div className="space-y-1.5 max-h-56 overflow-auto pr-0.5">
{modelPcFiles.map((f) => { {modelPcFiles.map((f) => {
const isChecked = selectedModelPcFileIds.has(f.id); const isChecked = selectedModelPcFileIds.has(f.id);
const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id;
return ( return (
<button <div
key={f.id} key={f.id}
type="button" role="checkbox"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${ aria-checked={isChecked}
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70' tabIndex={0}
className={`group relative w-full overflow-hidden rounded-lg border text-xs text-left min-w-0 cursor-pointer transition-all ${
isChecked
? 'border-cyan-400/55 bg-gradient-to-br from-cyan-500/15 via-cyan-500/5 to-blue-500/10 shadow-[0_0_16px_rgba(34,211,238,0.09)]'
: 'border-border/60 bg-gradient-to-br from-cyan-500/[0.07] via-background/40 to-blue-500/[0.06] hover:border-cyan-400/35 hover:from-cyan-500/10'
}`} }`}
onClick={() => toggleModelPcFile(f.id)} onClick={() => toggleModelPcFile(f.id)}
onKeyDown={(event) => {
if (isEditing) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleModelPcFile(f.id);
}
}}
> >
{isChecked ? ( <div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" /> <div className="relative flex items-start gap-2 p-2 pl-2.5">
) : ( {isChecked ? (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" /> <CheckSquare className="mt-0.5 w-3.5 h-3.5 text-cyan-300 flex-shrink-0" />
)} ) : (
<Cloud className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" /> <Square className="mt-0.5 w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span> )}
<span className="text-muted-foreground text-[10px] flex-shrink-0"> <Cloud className="mt-0.5 w-3.5 h-3.5 text-cyan-400 flex-shrink-0 drop-shadow-[0_0_5px_rgba(34,211,238,0.45)]" />
{formatSize(f.fileSize)} {isEditing && renameTarget ? (
</span> <div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
</button> <div className="flex items-center gap-1 min-w-0">
<input
autoFocus
className="min-w-0 flex-1 rounded border border-primary/50 bg-background px-1.5 py-0.5 text-xs outline-none focus:border-primary"
value={renameTarget.baseName}
aria-label="车型库点云主文件名"
disabled={renamePending}
onChange={(event) => {
setRenameTarget({ ...renameTarget, baseName: event.target.value });
setRenameError(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitRename();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelRename();
}
}}
/>
<span className="text-muted-foreground flex-shrink-0">{renameTarget.extension}</span>
<button
type="button"
className="p-0.5 rounded text-green-400 hover:bg-secondary disabled:opacity-50"
title="确认修改"
aria-label="确认修改车型库点云名称"
disabled={renamePending}
onClick={(event) => {
event.stopPropagation();
submitRename();
}}
>
<Check className="w-3 h-3" />
</button>
<button
type="button"
className="p-0.5 rounded text-muted-foreground hover:bg-secondary disabled:opacity-50"
title="取消修改"
aria-label="取消修改车型库点云名称"
disabled={renamePending}
onClick={(event) => {
event.stopPropagation();
cancelRename();
}}
>
<X className="w-3 h-3" />
</button>
</div>
{renameError && <div className="mt-0.5 text-[10px] text-destructive">{renameError}</div>}
</div>
) : (
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-h-4 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
{f.name}
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm break-all">{f.name}</TooltipContent>
</Tooltip>
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
className="flex-shrink-0 rounded-full border border-cyan-400/25 bg-cyan-400/10 px-1.5 py-0.5 text-[9px] font-medium text-cyan-200 whitespace-nowrap"
title={f.pointCount == null ? '点数未知' : `${f.pointCount.toLocaleString('zh-CN')} 点`}
>
{formatCompactPointCount(f)}
</span>
<span className="truncate text-[9px] text-muted-foreground whitespace-nowrap">
{formatSize(f.fileSize)}
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded-md border border-cyan-400/20 bg-cyan-400/10 text-cyan-300 hover:bg-cyan-400/20"
title="查看点云属性信息"
aria-label={`查看车型库点云属性:${f.name}`}
onClick={(event) => {
event.stopPropagation();
setMetadataDetail({ ...f, sourceLabel: '车型点云库' });
}}
>
<Info className="w-3 h-3" />
</button>
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-muted-foreground opacity-60 hover:border-border hover:bg-secondary hover:text-foreground group-hover:opacity-100"
title="修改名称"
aria-label={`修改车型库点云名称:${f.name}`}
onClick={(event) => {
event.stopPropagation();
startRename('model', f);
}}
>
<Pencil className="w-3 h-3" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
); );
})} })}
{modelPcFiles.length === 0 && ( {modelPcFiles.length === 0 && (
@@ -647,6 +894,7 @@ export default function TaskManager({
<div className="space-y-1"> <div className="space-y-1">
{pcFiles.map((f) => { {pcFiles.map((f) => {
const isChecked = selectedFileIds.has(f.id); const isChecked = selectedFileIds.has(f.id);
const isEditing = renameTarget?.scope === 'task' && renameTarget.id === f.id;
const meta = f as { const meta = f as {
source?: string; source?: string;
sourceResultId?: number | null; sourceResultId?: number | null;
@@ -660,85 +908,194 @@ export default function TaskManager({
return ( return (
<div <div
key={f.id} key={f.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer className={`group relative overflow-hidden rounded-lg border text-xs cursor-pointer transition-all
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`} ${isChecked
? 'border-cyan-400/55 bg-gradient-to-br from-cyan-500/15 via-cyan-500/5 to-blue-500/10 shadow-[0_0_18px_rgba(34,211,238,0.1)]'
: 'border-border/60 bg-gradient-to-br from-cyan-500/[0.06] via-background/50 to-blue-500/[0.05] hover:border-cyan-400/35 hover:from-cyan-500/10'}`}
onClick={() => toggleFile(f.id)} onClick={() => toggleFile(f.id)}
> >
{/* 复选框 */} <div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
<button <div className="relative flex items-start gap-2 p-2 pl-2.5">
type="button" <button
className="flex-shrink-0" type="button"
onClick={(e) => { className="mt-0.5 flex-shrink-0"
e.stopPropagation(); onClick={(e) => {
toggleFile(f.id); e.stopPropagation();
}} toggleFile(f.id);
> }}
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<Cloud
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 min-w-0 truncate">{f.name}</span>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[min(32rem,calc(100vw-2rem))]"
> >
<div className="space-y-1 text-[11px]"> {isChecked ? (
<div className="break-all">{f.name}</div> <CheckSquare className="w-3.5 h-3.5 text-cyan-300" />
{source === 'registered' && ( ) : (
<> <Square className="w-3.5 h-3.5 text-muted-foreground" />
<div>类型:已配准点云</div> )}
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>} </button>
<div>生成时间:{formatDateTime(meta.createdAt)}</div> <Cloud
<div> className={`mt-0.5 w-3.5 h-3.5 flex-shrink-0 drop-shadow-[0_0_5px_rgba(34,211,238,0.45)] ${isChecked ? 'text-cyan-300' : 'text-cyan-400'}`}
来源: />
{sourcePointcloud {isEditing && renameTarget ? (
? sourcePointcloud.name <div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
: meta.sourcePointcloudFileId != null <div className="flex items-center gap-1 min-w-0">
? `点云 #${meta.sourcePointcloudFileId}` <input
: '旧记录未记录来源'} autoFocus
</div> className="min-w-0 flex-1 rounded border border-primary/50 bg-background px-1.5 py-0.5 text-xs outline-none focus:border-primary"
</> value={renameTarget.baseName}
)} aria-label="任务点云主文件名"
disabled={renamePending}
onChange={(event) => {
setRenameTarget({ ...renameTarget, baseName: event.target.value });
setRenameError(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submitRename();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelRename();
}
}}
/>
<span className="text-muted-foreground flex-shrink-0">{renameTarget.extension}</span>
<button
type="button"
className="p-0.5 rounded text-green-400 hover:bg-secondary disabled:opacity-50"
title="确认修改"
aria-label="确认修改任务点云名称"
disabled={renamePending}
onClick={(event) => {
event.stopPropagation();
submitRename();
}}
>
<Check className="w-3 h-3" />
</button>
<button
type="button"
className="p-0.5 rounded text-muted-foreground hover:bg-secondary disabled:opacity-50"
title="取消修改"
aria-label="取消修改任务点云名称"
disabled={renamePending}
onClick={(event) => {
event.stopPropagation();
cancelRename();
}}
>
<X className="w-3 h-3" />
</button>
</div> </div>
</TooltipContent> {renameError && <div className="mt-0.5 text-[10px] text-destructive">{renameError}</div>}
</Tooltip> </div>
{source === 'registered' && ( ) : (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap"> <div className="min-w-0 flex-1">
已配准 <div className="flex items-start gap-1.5">
</span> <Tooltip>
)} <TooltipTrigger asChild>
{source === 'registered' && meta.sourceResultId != null && ( <span className="min-w-0 flex-1 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
<span className="text-[9px] px-1 py-0.5 rounded bg-sky-500/15 text-sky-300 border border-sky-500/30 flex-shrink-0 whitespace-nowrap"> {f.name}
R{meta.sourceResultId} </span>
</span> </TooltipTrigger>
)} <TooltipContent
{source === 'model_library' && ( side="right"
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap"> align="start"
车型库 className="max-w-[min(32rem,calc(100vw-2rem))]"
</span> >
)} <div className="space-y-1 text-[11px]">
<span className="text-muted-foreground text-[10px] flex-shrink-0"> <div className="break-all">{f.name}</div>
{formatSize(f.fileSize)} {source === 'registered' && (
</span> <>
<button <div>类型:已配准点云</div>
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0" {meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
onClick={(e) => { <div>生成时间:{formatDateTime(meta.createdAt)}</div>
e.stopPropagation(); <div>
if (confirm(`删除点云文件「${f.name}」?`)) 来源:
deletePcFile.mutate({ id: f.id }); {sourcePointcloud
}} ? sourcePointcloud.name
> : meta.sourcePointcloudFileId != null
<Trash2 className="w-3 h-3" /> ? `点云 #${meta.sourcePointcloudFileId}`
</button> : '旧记录未记录来源'}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
{source === 'registered' && (
<span
className="mt-px flex-shrink-0 rounded border border-blue-400/20 bg-blue-500/10 px-1 py-0.5 text-[8px] leading-3 text-blue-200"
title="已配准点云"
>
{meta.sourceResultId != null ? `R${meta.sourceResultId}` : '配准'}
</span>
)}
{source === 'model_library' && (
<span className="mt-px flex-shrink-0 rounded border border-amber-400/20 bg-amber-500/10 px-1 py-0.5 text-[8px] leading-3 text-amber-200">
车型库
</span>
)}
</div>
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
<span
className="flex-shrink-0 rounded-full border border-cyan-400/25 bg-cyan-400/10 px-1.5 py-0.5 text-[9px] font-medium text-cyan-200 whitespace-nowrap"
title={f.pointCount == null ? '点数未知' : `${f.pointCount.toLocaleString('zh-CN')} 点`}
>
{formatCompactPointCount(f)}
</span>
<span className="truncate text-[9px] text-muted-foreground whitespace-nowrap">
{formatSize(f.fileSize)}
</span>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded-md border border-cyan-400/20 bg-cyan-400/10 text-cyan-300 hover:bg-cyan-400/20"
title="查看点云属性信息"
aria-label={`查看任务点云属性:${f.name}`}
onClick={(event) => {
event.stopPropagation();
const sourceLabel = source === 'registered'
? '算法生成(配准点云)'
: source === 'model_library'
? '车型点云库'
: '用户上传';
setMetadataDetail({ ...f, sourceLabel });
}}
>
<Info className="w-3 h-3" />
</button>
{canWrite && (
<button
type="button"
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-muted-foreground opacity-60 hover:border-border hover:bg-secondary hover:text-foreground group-hover:opacity-100"
title="修改名称"
aria-label={`修改任务点云名称:${f.name}`}
onClick={(event) => {
event.stopPropagation();
startRename('task', f);
}}
>
<Pencil className="w-3 h-3" />
</button>
)}
{canWrite && <button
type="button"
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-destructive/70 opacity-60 hover:border-destructive/20 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
title="删除点云文件"
aria-label={`删除任务点云:${f.name}`}
onClick={(e) => {
e.stopPropagation();
if (confirm(`删除点云文件「${f.name}」?`))
deletePcFile.mutate({ id: f.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>}
</div>
</div>
</div>
)}
</div>
</div> </div>
); );
})} })}
@@ -783,20 +1140,20 @@ export default function TaskManager({
全选 全选
</button> </button>
)} )}
<button {canWrite && <button
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5" className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => cadFileRef.current?.click()} onClick={() => cadFileRef.current?.click()}
> >
<Upload className="w-3 h-3" /> 上传到任务 <Upload className="w-3 h-3" /> 上传到任务
</button> </button>}
<button {canWrite && <button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${ className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
cadLibraryOpen ? 'text-primary border-primary/50' : '' cadLibraryOpen ? 'text-primary border-primary/50' : ''
}`} }`}
onClick={() => setCadLibraryOpen((open) => !open)} onClick={() => setCadLibraryOpen((open) => !open)}
> >
<Library className="w-3 h-3" /> 从车型库选择 <Library className="w-3 h-3" /> 从车型库选择
</button> </button>}
</div> </div>
</div> </div>
@@ -811,7 +1168,7 @@ export default function TaskManager({
</div> </div>
)} )}
{cadLibraryOpen && ( {canWrite && cadLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2"> <div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
@@ -955,7 +1312,7 @@ export default function TaskManager({
<Eye className="w-3 h-3 text-primary" /> <Eye className="w-3 h-3 text-primary" />
</button> </button>
)} )}
<button {canWrite && <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0" className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -964,7 +1321,7 @@ export default function TaskManager({
}} }}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>}
</div> </div>
); );
})} })}
@@ -1003,6 +1360,105 @@ export default function TaskManager({
</div> </div>
)} )}
<Dialog
open={metadataDetail !== null}
onOpenChange={(open) => { if (!open) setMetadataDetail(null); }}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>点云属性信息</DialogTitle>
<DialogDescription>文件头中记录的点数、编码与每点字段。</DialogDescription>
</DialogHeader>
{metadataDetail && (
<div className="space-y-4 text-sm">
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
<div className="sm:col-span-2 min-w-0">
<dt className="text-xs text-muted-foreground">文件名</dt>
<dd className="mt-0.5 break-all">{metadataDetail.name}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">来源</dt>
<dd className="mt-0.5">{metadataDetail.sourceLabel}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">文件大小</dt>
<dd className="mt-0.5">{formatSize(metadataDetail.fileSize)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">格式</dt>
<dd className="mt-0.5">{pointcloudFormat(metadataDetail.name)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">编码</dt>
<dd className="mt-0.5">
{metadataDetail.pointDataFormat
? pointDataFormatLabels[metadataDetail.pointDataFormat] ?? metadataDetail.pointDataFormat
: '未知'}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">精确点数</dt>
<dd className="mt-0.5 font-mono">
{metadataDetail.metadataStatus === 'ready' && metadataDetail.pointCount != null
? metadataDetail.pointCount.toLocaleString('en-US')
: '点数未知'}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">每点字节数</dt>
<dd className="mt-0.5">
{metadataDetail.bytesPerPoint == null ? '未知' : `${metadataDetail.bytesPerPoint} 字节`}
</dd>
</div>
</dl>
{metadataDetail.metadataStatus === 'ready' && metadataDetail.pointAttributes && (
<div className="overflow-hidden rounded border">
<table className="w-full text-left text-xs">
<thead className="bg-secondary/70 text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">属性名称</th>
<th className="px-3 py-2 font-medium">数据类型</th>
<th className="px-3 py-2 text-right font-medium">字节数</th>
</tr>
</thead>
<tbody>
{metadataDetail.pointAttributes.map((attribute, index) => (
<tr key={`${attribute.name}-${index}`} className="border-t">
<td className="px-3 py-2 font-mono">{attribute.name}</td>
<td className="px-3 py-2 font-mono">{attribute.type}</td>
<td className="px-3 py-2 text-right">{attribute.bytes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{metadataDetail.metadataStatus === 'unsupported' && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 text-amber-200">
<div className="font-medium">点数未知</div>
<div className="mt-1 text-xs text-amber-100/80">
{unsupportedMetadataMessage(metadataDetail.name)}
</div>
</div>
)}
{metadataDetail.metadataStatus === 'error' && (
<div className="rounded border border-red-500/30 bg-red-500/10 p-3 text-red-200">
<div className="font-medium">属性解析失败</div>
<div className="mt-1 text-xs text-red-100/80">无法从该文件的 PLY 文件头读取属性信息。</div>
</div>
)}
{metadataDetail.metadataStatus === 'pending' && (
<div className="rounded border border-blue-500/30 bg-blue-500/10 p-3 text-blue-200">
属性信息正在解析,请稍后刷新。
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
{/* 隐藏的文件 input,支持多选 */} {/* 隐藏的文件 input,支持多选 */}
<input <input
ref={fileRef} ref={fileRef}

View File

@@ -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
@@ -176,9 +180,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<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 +201,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 +263,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 +277,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 +289,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>

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { aggregateDemoFlowResults, 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: '配准误差 p95', value: 0.1, status: 'info' },
{ name: '配准误差均值', value: 0.05, status: 'info' },
],
}),
], nameOf, { includeAlgorithmGroups: true });
expect(model.measurementRows).toEqual([]);
expect(model.tableRows).toEqual([]);
});
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', {
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
}),
result(20, 2, 'completed', {
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
}),
], nameOf);
expect(model.artifactGroups).toMatchObject([
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
]);
});
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
const model = aggregateDemoFlowResults([
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
result(30, 3, 'pending', { stage: '等待资源' }),
result(40, 4, 'error', { error: '输入无效' }),
result(50, 5, 'cancelled', {}),
], nameOf);
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
expect(model.statusNotices).toMatchObject([
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
{ resultId: 30, status: 'pending', detail: '等待资源' },
{ resultId: 40, status: 'error', detail: '输入无效' },
{ resultId: 50, status: 'cancelled' },
]);
});
});

View File

@@ -0,0 +1,167 @@
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
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 aggregateDemoFlowResults(
results: DemoFlowSourceResult[],
getAlgorithmName: (result: DemoFlowSourceResult) => string,
options: { includeAlgorithmGroups?: boolean } = {},
): 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[] = [];
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 = filterVisibleMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
if (measurements.length > 0 && options.includeAlgorithmGroups) {
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
}
measurements.forEach((measurement, measurementIndex) => {
const row: DemoFlowMeasurementRow = {
kind: 'measurement',
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
measurementIndex,
measurement,
};
measurementRows.push(row);
tableRows.push(row);
});
const files = normalizeFiles(data.files);
const images = normalizeImages(data.images);
const extra = asRecord(data.extra) ?? undefined;
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
artifactGroups.push({
sourceAlgorithmId,
algorithmName,
resultId: result.id,
flowIndex: index,
files,
images,
overlay: data.overlay3d,
inputPointCloudFileIds: data.inputPointCloudFileIds,
inputCadFileIds: data.inputCadFileIds,
extra,
});
}
if (result.status !== 'completed') {
const progress = finiteNumber(data.progress);
const detail = result.status === 'error'
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
: typeof data.stage === 'string'
? data.stage
: typeof data.error === 'string'
? data.error
: undefined;
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
}
}
return { tableRows, measurementRows, artifactGroups, statusNotices };
}

View File

@@ -1,23 +1,2 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; 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();
};

View File

@@ -0,0 +1,161 @@
import { useMemo } from 'react';
import { trpc } from '@/lib/trpc';
type AnalysisResultRow = {
id: number;
taskId?: number | null;
type: string;
pluginId?: string | null;
status: string;
createdAt: string | Date;
updatedAt: string | Date;
resultData: unknown;
};
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;
}
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;
}
const EMPTY_STATE: DemoFlowState = {
active: false,
runId: null,
total: 0,
completed: 0,
steps: [],
overallStatus: 'idle',
currentStep: null,
overallProgress: 0,
};
function isActiveStatus(step: DemoFlowStep): boolean {
return step.status === 'processing' || step.status === 'pending';
}
export function useDemoFlowState(taskId: number | null): DemoFlowState {
const { data: results = [] } = trpc.analysisResult.list.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
return useMemo(() => {
if (taskId == null || results.length === 0) return EMPTY_STATE;
const rows = results as AnalysisResultRow[];
const steps: DemoFlowStep[] = [];
let activeRunId: string | null = null;
let declaredTotal = 0;
for (const r of rows) {
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) {
// 只关注最新的一次 demo 流(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;
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
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) return EMPTY_STATE;
steps.sort((a, b) => a.index - b.index);
const total = Math.max(steps.length, declaredTotal);
// 用 pending 占位步骤补齐尚未创建结果记录的后续步骤
for (let i = steps.length + 1; i <= total; i++) {
steps.push({
id: -i,
index: i,
role: i === 1 ? 'registration' : 'measurement',
pluginId: null,
status: 'pending',
createdAt: steps[0]?.createdAt ?? new Date(),
updatedAt: steps[0]?.updatedAt ?? new Date(),
});
}
const completed = steps.filter((s) => s.status === 'completed').length;
const active = steps.some(isActiveStatus);
const currentStep =
steps.find((s) => s.status === 'processing') ??
steps.find((s) => s.status === 'pending') ??
steps[steps.length - 1] ??
null;
let overallStatus: DemoFlowState['overallStatus'] = 'idle';
if (steps.some((s) => s.status === 'processing')) overallStatus = 'processing';
else if (steps.some((s) => s.status === 'pending')) overallStatus = 'pending';
else if (steps.every((s) => s.status === 'completed')) overallStatus = 'completed';
else if (steps.some((s) => s.status === 'error')) overallStatus = 'error';
else if (steps.every((s) => s.status === 'cancelled')) overallStatus = 'cancelled';
else if (steps.some((s) => s.status === 'cancelled')) overallStatus = 'cancelled';
const doneProgress = completed / total;
const currentProgress = currentStep && isActiveStatus(currentStep)
? (currentStep.progress ?? 0) / total
: 0;
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
return {
active,
runId: activeRunId,
total,
completed,
steps,
overallStatus,
currentStep,
overallProgress,
};
}, [results, taskId]);
}

View File

@@ -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);
} }
}); });

View File

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

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect, useMemo } from 'react'; import { 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 { 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,7 +10,8 @@ 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,
Users,
Box, Box,
Cloud, Cloud,
Eye, Eye,
@@ -22,7 +24,7 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Home() { export default function Home() {
const { loading, isAuthenticated } = useAuth(); const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// ── 树导航选中状态 ───────────────────────────────────────────────────────── // ── 树导航选中状态 ─────────────────────────────────────────────────────────
@@ -66,8 +68,17 @@ 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 [showPointCloud, setShowPointCloud] = useState(true); const [showPointCloud, setShowPointCloud] = useState(true);
const [showCADModel] = useState(true); const [showCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false); const [showWireframe, setShowWireframe] = useState(false);
@@ -133,6 +144,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 +229,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);
@@ -281,7 +297,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 +313,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,6 +322,7 @@ export default function Home() {
setPluginOverlay(null); setPluginOverlay(null);
setHeatmapViewResultId(resultId); setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap'); setPointCloudRenderMode('smoothHeatmap');
setShowHeatmap(true);
}} }}
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => { onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
const expectedIds = inputPointCloudFileIds ?? []; const expectedIds = inputPointCloudFileIds ?? [];
@@ -348,6 +365,7 @@ export default function Home() {
setHeatmapViewResultId(null); setHeatmapViewResultId(null);
setPluginOverlay({ taskId, resultId, overlay }); setPluginOverlay({ taskId, resultId, overlay });
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points'); setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
setShowHeatmap(overlay.kind === 'vertexColors');
}} }}
externalPending={runAnalysisJob.isPending} externalPending={runAnalysisJob.isPending}
onRunAssembly={() => { onRunAssembly={() => {
@@ -420,10 +438,13 @@ export default function Home() {
{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 ? (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5"> <div className="flex items-center gap-2 text-xs">
<LogIn className="w-3.5 h-3.5" /> <div className="text-right leading-tight"><div>{user.username}</div><div className="text-[10px] text-muted-foreground">{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}</div></div>
</a> {isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
</div>
) : null} ) : null}
</div> </div>
</header> </header>
@@ -481,6 +502,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"
/> />
@@ -495,6 +517,8 @@ export default function Home() {
</div> </div>
</div> </div>
)} )}
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
</div> </div>
{/* 操作提示 */} {/* 操作提示 */}
@@ -549,6 +573,7 @@ export default function Home() {
setPointCloudRenderMode('points'); setPointCloudRenderMode('points');
setHeatmapViewResultId(null); setHeatmapViewResultId(null);
setPluginOverlay(null); setPluginOverlay(null);
setShowHeatmap(false);
setShowPointCloud(true); setShowPointCloud(true);
return; return;
} }
@@ -564,14 +589,16 @@ export default function Home() {
正在显示分析结果 #{heatmapViewResultId} 正在显示分析结果 #{heatmapViewResultId}
</span> </span>
)} )}
<button {pointCloudRenderMode === 'smoothHeatmap' && (
type="button" <button
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`} type="button"
onClick={() => setShowHeatmap(v => !v)} className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
> onClick={() => setShowHeatmap(v => !v)}
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />} >
<span>热力图</span> {showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</button> <span>热力图</span>
</button>
)}
</div> </div>
<div className="h-5 w-px bg-border" /> <div className="h-5 w-px bg-border" />

View File

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

View File

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

View File

@@ -44,16 +44,15 @@ services:
- NODE_ENV=development - 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} 占位符引用)

View File

@@ -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}

View File

@@ -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;

View File

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

View File

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

View File

@@ -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;
@@ -71,6 +75,12 @@ 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(),
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(),
}); });
@@ -136,6 +146,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(),
}); });

View File

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

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

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

View File

@@ -1,6 +1,6 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; import type { 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;

View File

@@ -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),
}; };
} }

View File

@@ -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 ?? "",

View File

@@ -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');

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const'; import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server"; import { 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 } });
}),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
const user: AuthenticatedUser = { 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: "/",
}); });

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
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,
@@ -12,7 +13,6 @@ import {
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 +80,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> {
@@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile)
return row; 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 getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> { export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
@@ -255,6 +290,36 @@ 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;
@@ -448,6 +513,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 +522,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> {
@@ -507,6 +639,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 +718,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));
});
}

View File

@@ -1,10 +1,12 @@
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 { samplePointcloudFile, type SampledPoint } 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';
const router = Router(); const router = Router();
@@ -48,6 +50,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 {
@@ -66,7 +95,7 @@ function safeResolve(base: string, filename: string): string | null {
// 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)) { if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' }); return res.status(400).json({ success: false, error: 'Missing file or modelId' });
@@ -98,18 +127,20 @@ 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)) { if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' }); return res.status(400).json({ success: false, error: 'Missing file or modelId' });
} }
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,
...metadata,
}); });
res.json({ res.json({
@@ -126,7 +157,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 +188,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 +199,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 +229,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 +240,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 +251,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 +262,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 +278,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 +287,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 +299,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 +307,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) {
@@ -326,6 +355,7 @@ router.post('/sample/batch', async (req, res) => {
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节 // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
const hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error)));
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined)); const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16; const bytesPerPoint = hasColor ? 28 : 16;
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint); const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
@@ -335,7 +365,7 @@ router.post('/sample/batch', async (req, res) => {
binBuf.writeFloatLE(p.x, offset); binBuf.writeFloatLE(p.x, offset);
binBuf.writeFloatLE(p.y, offset + 4); binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8); binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12); binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12);
if (hasColor) { if (hasColor) {
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16); binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20); binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
@@ -355,6 +385,7 @@ router.post('/sample/batch', async (req, res) => {
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled); res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0'); res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-Has-Error', hasError ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats)); res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf); res.send(binBuf);
} catch (err) { } catch (err) {
@@ -366,7 +397,7 @@ router.post('/sample/batch', async (req, res) => {
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)────────────────────── // ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
// 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 +433,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 +463,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 +486,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)) {
@@ -494,7 +525,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({

View File

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

View File

@@ -23,14 +23,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 {

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ from _plugin_io import (
write_error_result, write_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,12 +211,21 @@ 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}")
res = pipeline.run_contour_batch( res = pipeline.run_contour_batch(
all_tasks=True, all_tasks=True,
data_root=str(data_root), data_root=str(data_root),
@@ -205,20 +234,13 @@ def main() -> int:
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 +251,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
@@ -430,6 +459,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__":

View File

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

View File

@@ -3,7 +3,7 @@
"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,
@@ -15,6 +15,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 +54,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": {

View File

@@ -106,11 +106,17 @@ export interface PluginProgressEvent {
/** 测量项(GenericResultView 渲染) */ /** 测量项(GenericResultView 渲染) */
export interface PluginMeasurement { export interface PluginMeasurement {
name: string; name: string;
value: number; /** 失败任务没有有效测量值时为 null,前端显示破折号。 */
value: number | null;
unit?: string; unit?: string;
tolerance?: number; tolerance?: number;
/** 无法安全数值化的原始公差表达式。 */
toleranceLabel?: string;
/** pass / warning / fail / info */ /** pass / warning / fail / info */
status?: 'pass' | 'warning' | 'fail' | 'info'; status?: 'pass' | 'warning' | 'fail' | 'info';
passLabel?: string;
warningLabel?: string;
failLabel?: string;
} }
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */ /** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
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 } 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]);
});
});

View File

@@ -1,7 +1,7 @@
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 };
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */ /** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
export async function randomSeekSample( export async function randomSeekSample(
@@ -66,9 +66,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 +174,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 +187,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 +232,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 +296,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]);

View File

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

View File

@@ -1,7 +1,7 @@
import { COOKIE_NAME } from "@shared/const"; import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies"; import { 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";
@@ -15,10 +15,37 @@ import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js"; import { listPlugins, getPlugin } from "./plugins/registry.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 {
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",
@@ -306,12 +333,164 @@ 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;
}),
}), }),
// ─── 车系 ────────────────────────────────────────────────────────────────── // ─── 车系 ──────────────────────────────────────────────────────────────────
@@ -444,6 +623,23 @@ export const appRouter = router({
return db.listModelPointcloudFiles(input.modelId); return db.listModelPointcloudFiles(input.modelId);
}), }),
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: protectedProcedure delete: protectedProcedure
.input(z.object({ id: z.number() })) .input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -596,6 +792,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 }) => {
@@ -801,11 +1014,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 };
}), }),
}), }),

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
export const COOKIE_NAME = "app_session_id"; export const 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 = '请先修改初始密码';

View File

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

View File

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