Compare commits
3 Commits
683e4d6c07
...
33c5fb9b9c
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c5fb9b9c | |||
| 752ea3f944 | |||
| 8555aa2f2e |
@@ -34,11 +34,7 @@ COPY . .
|
||||
|
||||
# 设置构建时环境变量
|
||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
||||
ARG VITE_OAUTH_PORTAL_URL="disabled"
|
||||
ARG VITE_APP_ID=""
|
||||
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
|
||||
|
||||
@@ -322,6 +322,7 @@ class MeasurementPipeline:
|
||||
excel_output_path: str | None = None,
|
||||
result_template_path: str | Path | None = DEFAULT_RESULT_TEMPLATE,
|
||||
combined_context_pointcloud_path: str | Path | None = DEFAULT_COMBINED_CONTEXT_POINTCLOUD,
|
||||
save_combined_visualization: bool = True,
|
||||
no_visualize: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
batch_start_time = time.perf_counter()
|
||||
@@ -443,7 +444,7 @@ class MeasurementPipeline:
|
||||
|
||||
combined_visualization_output = None
|
||||
combined_pointclouds = []
|
||||
if combined_visualizations:
|
||||
if save_combined_visualization and combined_visualizations:
|
||||
combined_visualization_output = self._contour_combined_save_path(save_path)
|
||||
self._save_contour_combined_pointcloud_stream(
|
||||
combined_visualizations,
|
||||
@@ -627,6 +628,7 @@ class MeasurementPipeline:
|
||||
template_sheet_name, headers, template_rows = self._read_measurement_template(template_path)
|
||||
task_col = self._find_header_index(headers, ["任务号", "任务编号", "task_id"])
|
||||
result_col = self._find_header_index(headers, ["测量结果"])
|
||||
error_col = self._find_header_index(headers, ["误差结果"])
|
||||
conclusion_col = self._find_header_index(headers, ["测量结论", "结论", "状态"])
|
||||
|
||||
result_by_task = {
|
||||
@@ -648,6 +650,7 @@ class MeasurementPipeline:
|
||||
continue
|
||||
|
||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||
output_row[error_col] = result_row.get("max")
|
||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||
table.append(output_row)
|
||||
used_task_ids.add(task_id)
|
||||
@@ -659,6 +662,7 @@ class MeasurementPipeline:
|
||||
output_row = [""] * len(headers)
|
||||
output_row[task_col] = task_id
|
||||
output_row[result_col] = self._row_measurement_mean(result_row)
|
||||
output_row[error_col] = result_row.get("max")
|
||||
output_row[conclusion_col] = self._row_measurement_conclusion(result_row)
|
||||
table.append(output_row)
|
||||
|
||||
@@ -729,7 +733,9 @@ class MeasurementPipeline:
|
||||
|
||||
def _row_measurement_conclusion(self, row: dict[str, Any]) -> str:
|
||||
status = str(row.get("status", "")).strip()
|
||||
if status and status != "成功":
|
||||
if status in {"成功", "部分成功", "失败"}:
|
||||
return status
|
||||
if status:
|
||||
return "失败"
|
||||
|
||||
value = self._row_measurement_mean(row)
|
||||
|
||||
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
62
alg/平整度、平面度、轮廓测量v1.5/test_pipeline_retention.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
|
||||
from pipeline import MeasurementPipeline
|
||||
|
||||
|
||||
class CombinedContourRetentionTest(TestCase):
|
||||
def make_pipeline(self, root: Path) -> MeasurementPipeline:
|
||||
mesh = root / "mesh.ply"
|
||||
pointcloud = root / "points.ply"
|
||||
mesh.touch()
|
||||
pointcloud.touch()
|
||||
|
||||
pipeline = MeasurementPipeline.__new__(MeasurementPipeline)
|
||||
pipeline._resolve_contour_task_inputs = Mock(return_value=[{
|
||||
"task_id": "1",
|
||||
"sample_id": "sample",
|
||||
"cad_path": None,
|
||||
"mesh_path": mesh,
|
||||
"pointcloud_path": pointcloud,
|
||||
}])
|
||||
pipeline._contour_task_save_path = Mock(return_value=root / "sample_contour.ply")
|
||||
pipeline.run_contour = Mock(return_value={
|
||||
"distance_stats": SimpleNamespace(max=0.5),
|
||||
"distances": np.array([0.1, 0.2]),
|
||||
"pointcloud": object(),
|
||||
})
|
||||
pipeline._contour_combined_save_path = Mock(return_value=root / "combined_contour.ply")
|
||||
pipeline._save_contour_combined_pointcloud_stream = Mock()
|
||||
pipeline._should_visualize = Mock(return_value=False)
|
||||
return pipeline
|
||||
|
||||
def test_skips_combined_file_when_disabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
pipeline = self.make_pipeline(Path(temp))
|
||||
result = pipeline.run_contour_batch(
|
||||
all_tasks=True,
|
||||
save_path=temp,
|
||||
save_combined_visualization=False,
|
||||
no_visualize=True,
|
||||
)
|
||||
|
||||
self.assertIsNone(result["combined_visualization_output"])
|
||||
pipeline._save_contour_combined_pointcloud_stream.assert_not_called()
|
||||
|
||||
def test_keeps_backward_compatible_combined_file_when_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
pipeline = self.make_pipeline(Path(temp))
|
||||
result = pipeline.run_contour_batch(
|
||||
all_tasks=True,
|
||||
save_path=temp,
|
||||
save_combined_visualization=True,
|
||||
no_visualize=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result["combined_visualization_output"], Path(temp) / "combined_contour.ply")
|
||||
pipeline._save_contour_combined_pointcloud_stream.assert_called_once()
|
||||
@@ -1,16 +1,33 @@
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { Route, Switch } from "wouter";
|
||||
import { Redirect, Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import ChangePassword from "./pages/ChangePassword";
|
||||
import Users from "./pages/Users";
|
||||
import { useAuth } from "./_core/hooks/useAuth";
|
||||
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
|
||||
|
||||
function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <DashboardLayoutSkeleton />;
|
||||
if (!user) return <Redirect to="/login" />;
|
||||
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
|
||||
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Router() {
|
||||
// make sure to consider if you need authentication for certain routes
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={"/"} component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/change-password" component={ChangePassword} />
|
||||
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
|
||||
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
|
||||
|
||||
const logoutMutation = trpc.auth.logout.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.auth.me.setData(undefined, null);
|
||||
utils.auth.me.setData(undefined, undefined);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
utils.auth.me.setData(undefined, null);
|
||||
utils.auth.me.setData(undefined, undefined);
|
||||
await utils.auth.me.invalidate();
|
||||
}
|
||||
}, [logoutMutation, utils]);
|
||||
|
||||
const state = useMemo(() => {
|
||||
localStorage.setItem(
|
||||
"manus-runtime-user-info",
|
||||
JSON.stringify(meQuery.data)
|
||||
);
|
||||
return {
|
||||
user: meQuery.data ?? null,
|
||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||
isAuthenticated: Boolean(meQuery.data),
|
||||
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
|
||||
isSuperAdmin: meQuery.data?.role === "super_admin",
|
||||
};
|
||||
}, [
|
||||
meQuery.data,
|
||||
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.pathname === redirectPath) return;
|
||||
|
||||
window.location.href = redirectPath
|
||||
window.location.href = redirectPath;
|
||||
}, [
|
||||
redirectOnUnauthenticated,
|
||||
redirectPath,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
|
||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
||||
import { useAuth } from '@/_core/hooks/useAuth';
|
||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, statusLabel, type Overlay3D } from './GenericResultView';
|
||||
import { aggregateDemoFlowResults, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
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 (
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left py-1 font-normal">项目</th>
|
||||
<th className="text-right py-1 font-normal">值</th>
|
||||
<th className="text-right py-1 font-normal">公差</th>
|
||||
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||
{showDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||
<th className="text-right py-1 font-normal">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -123,8 +126,8 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
|
||||
{items.map((m, i) => (
|
||||
<tr key={i} className="border-t border-border/40">
|
||||
<td className="py-1">{m.name}</td>
|
||||
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
|
||||
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
|
||||
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
||||
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
||||
<td className="text-right">
|
||||
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
||||
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
||||
@@ -233,6 +236,7 @@ type DemoFlowMeta = {
|
||||
};
|
||||
|
||||
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||
const SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
|
||||
|
||||
type AnalysisListItem =
|
||||
| { kind: 'result'; result: AnalysisResultRow }
|
||||
@@ -316,9 +320,14 @@ function getDemoFlowTotal(results: AnalysisResultRow[]) {
|
||||
}
|
||||
|
||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||
const { canWrite } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
||||
const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState<Set<string>>(new Set());
|
||||
const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState<Set<string>>(new Set());
|
||||
const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState<Set<string>>(new Set());
|
||||
const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState<Set<string>>(new Set());
|
||||
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
||||
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
||||
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
||||
@@ -401,10 +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 errorStats = r.errorStats as ErrorStats | null;
|
||||
const measurements = r.measurements as Measurement[] | null;
|
||||
const measurements = filterVisibleMeasurements((r.measurements as Measurement[] | null) ?? []);
|
||||
const resultTitle = getResultTitle(r, pluginNameById);
|
||||
|
||||
return (
|
||||
@@ -445,7 +486,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
<Play className="w-3 h-3 text-primary" />
|
||||
</button>
|
||||
)}
|
||||
{isCancellableStatus(r.status) && (
|
||||
{canWrite && isCancellableStatus(r.status) && (
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
||||
title="停止"
|
||||
@@ -455,13 +496,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
<Square className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
{canWrite && <button
|
||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||
title="删除"
|
||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</button>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
||||
@@ -486,10 +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' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||
{measurements && measurements.length > 0 && (
|
||||
{measurements.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
||||
<MeasurementTable items={measurements} />
|
||||
<MeasurementTable items={measurements} displayMode={variant} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -677,6 +718,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
pluginId={r.pluginId ?? undefined}
|
||||
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||
displayMode={variant}
|
||||
onViewOverlay={onViewPluginOverlay}
|
||||
/>
|
||||
</div>
|
||||
@@ -685,20 +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) {
|
||||
const expanded = expandedFlowIds.has(group.runId);
|
||||
const status = getDemoFlowStatus(group.results);
|
||||
const total = getDemoFlowTotal(group.results);
|
||||
const completed = group.results.filter((r) => r.status === 'completed').length;
|
||||
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
||||
const runningItems = group.results
|
||||
.filter((r) => r.status === 'processing' || r.status === 'pending')
|
||||
.slice(0, 2)
|
||||
.map((r) => {
|
||||
const data = asRecord(r.resultData);
|
||||
const progress = numberFromUnknown(data?.progress);
|
||||
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
|
||||
});
|
||||
const cardKey = `${variant}:${group.runId}`;
|
||||
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
||||
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
|
||||
const aggregated = aggregateDemoFlowResults(
|
||||
group.results,
|
||||
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
||||
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
|
||||
);
|
||||
const showMeasurementDetails = variant === 'maximized';
|
||||
|
||||
return (
|
||||
<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>
|
||||
{expanded && (
|
||||
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
||||
{runningItems.length > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
运行中:{runningItems.join('、')}
|
||||
<div className="industrial-card rounded overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||
onClick={() => toggleFlowResultCard(cardKey)}
|
||||
>
|
||||
{resultsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||
<Ruler className="w-3.5 h-3.5 text-green-400" />
|
||||
<span className="text-xs flex-1">算法结果</span>
|
||||
<span className="text-[10px] text-muted-foreground">{aggregated.measurementRows.length} 项</span>
|
||||
</button>
|
||||
{resultsExpanded && (
|
||||
<div className="border-t border-border/40">
|
||||
{aggregated.statusNotices.length > 0 && (
|
||||
<div className="space-y-1 p-2 pb-1">
|
||||
{aggregated.statusNotices.map(renderFlowStatusNotice)}
|
||||
</div>
|
||||
)}
|
||||
{aggregated.tableRows.length === 0 ? (
|
||||
<div className="text-[10px] text-muted-foreground text-center py-4">暂无测量结果</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[10px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b border-border/60">
|
||||
<th className="text-left px-2 py-1.5 font-normal">项目</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 className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
|
||||
{group.results.map((result) => renderResultCard(result))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -739,7 +981,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
{listItems.map((item) => (
|
||||
item.kind === 'demoFlow'
|
||||
? renderDemoFlowCard(item, variant)
|
||||
: renderResultCard(item.result)
|
||||
: renderResultCard(item.result, variant)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -764,7 +1006,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
||||
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
||||
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
||||
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||
<>
|
||||
<button
|
||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
@@ -808,7 +1050,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
|
||||
{!isLoading && results.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无分析结果,点击运行分析
|
||||
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
export function DashboardLayoutSkeleton() {
|
||||
return (
|
||||
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
|
||||
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
||||
{/* Logo area */}
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
|
||||
<Skeleton className="bg-muted h-4 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="space-y-2 px-2">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* User profile area at bottom */}
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-2 w-32" />
|
||||
<Skeleton className="bg-muted h-3 w-20" />
|
||||
<Skeleton className="bg-muted h-2 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
|
||||
{/* Main content skeleton */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Content blocks */}
|
||||
<Skeleton className="h-12 w-48 rounded-lg" />
|
||||
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
<Skeleton className="bg-muted h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
208
client/src/components/DemoFlowOverlay.tsx
Normal file
208
client/src/components/DemoFlowOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } f
|
||||
|
||||
interface Measurement {
|
||||
name: string;
|
||||
value: number;
|
||||
value: number | null;
|
||||
unit?: string;
|
||||
tolerance?: number;
|
||||
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
||||
@@ -22,6 +22,7 @@ interface Measurement {
|
||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
||||
passLabel?: string;
|
||||
warningLabel?: string;
|
||||
failLabel?: string;
|
||||
}
|
||||
|
||||
@@ -66,6 +67,8 @@ interface Props {
|
||||
pluginId?: string;
|
||||
pluginName?: string;
|
||||
data: PluginResultLike;
|
||||
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
|
||||
displayMode?: 'panel' | 'maximized';
|
||||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||||
onViewOverlay?: (overlay: {
|
||||
taskId: number;
|
||||
@@ -76,7 +79,7 @@ interface Props {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function statusClass(s?: Measurement['status']) {
|
||||
export function statusClass(s?: Measurement['status']) {
|
||||
switch (s) {
|
||||
case 'pass':
|
||||
return 'bg-green-900/40 text-green-400';
|
||||
@@ -89,12 +92,12 @@ function statusClass(s?: Measurement['status']) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(m: Measurement) {
|
||||
export function statusLabel(m: Measurement) {
|
||||
switch (m.status) {
|
||||
case 'pass':
|
||||
return m.passLabel ?? '合格';
|
||||
case 'warning':
|
||||
return '警告';
|
||||
return m.warningLabel ?? '警告';
|
||||
case 'fail':
|
||||
return m.failLabel ?? '超差';
|
||||
default:
|
||||
@@ -102,20 +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 '—';
|
||||
const abs = Math.abs(v);
|
||||
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
||||
return v.toFixed(3);
|
||||
}
|
||||
|
||||
function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
||||
return ids.length > 0 ? ids : undefined;
|
||||
}
|
||||
|
||||
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
||||
if (overlay.kind === 'ply') {
|
||||
const files = Array.isArray(overlay.files)
|
||||
@@ -157,11 +169,13 @@ export default function GenericResultView({
|
||||
pluginId,
|
||||
pluginName,
|
||||
data,
|
||||
displayMode = 'panel',
|
||||
onViewOverlay,
|
||||
}: Props) {
|
||||
const [showExtra, setShowExtra] = useState(false);
|
||||
const errorStats = data.errorStats ?? null;
|
||||
const measurements = data.measurements ?? [];
|
||||
const measurements = filterVisibleMeasurements(data.measurements ?? []);
|
||||
const showMeasurementDetails = displayMode === 'maximized';
|
||||
const images = data.images ?? [];
|
||||
const files = (data.files ?? []).map((f) =>
|
||||
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
||||
@@ -206,8 +220,8 @@ export default function GenericResultView({
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
<th className="text-left py-1 font-normal">项目</th>
|
||||
<th className="text-right py-1 font-normal">值</th>
|
||||
<th className="text-right py-1 font-normal">公差</th>
|
||||
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||
{showMeasurementDetails && <th className="text-right py-1 font-normal">公差</th>}
|
||||
<th className="text-right py-1 font-normal">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -217,12 +231,12 @@ export default function GenericResultView({
|
||||
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||||
{m.name}
|
||||
</td>
|
||||
<td className="text-right font-mono">
|
||||
{showMeasurementDetails && <td className="text-right font-mono">
|
||||
{m.baseline != null
|
||||
? `${formatValue(m.value)}/${m.baseline}mm`
|
||||
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
||||
</td>
|
||||
<td className="text-right text-muted-foreground">
|
||||
</td>}
|
||||
{showMeasurementDetails && <td className="text-right text-muted-foreground">
|
||||
{m.toleranceLabel != null
|
||||
? m.toleranceLabel
|
||||
: m.tolerance != null
|
||||
@@ -230,7 +244,7 @@ export default function GenericResultView({
|
||||
? `${m.tolerance}/${m.baseline}mm`
|
||||
: `±${m.tolerance}`
|
||||
: '—'}
|
||||
</td>
|
||||
</td>}
|
||||
<td className="text-right">
|
||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
||||
{m.status === 'info' ? '参考' : statusLabel(m)}
|
||||
|
||||
@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
|
||||
function getHeatmapLegendBounds(
|
||||
points: Point[],
|
||||
mode: 'points' | 'smoothHeatmap',
|
||||
): { min: number; max: number; midTick: string } {
|
||||
): { min: number; max: number; midTick: string } | null {
|
||||
if (mode === 'smoothHeatmap') {
|
||||
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
|
||||
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
|
||||
if (pointsWithError.length === 0) return null;
|
||||
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||
const midTick =
|
||||
minError < 0 && maxError > 0
|
||||
? '0'
|
||||
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
|
||||
let minE = Infinity;
|
||||
let maxE = -Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const e = points[i].error ?? 0;
|
||||
const e = points[i].error;
|
||||
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
|
||||
if (e < minE) minE = e;
|
||||
if (e > maxE) maxE = e;
|
||||
}
|
||||
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
|
||||
if (!(maxE > minE)) {
|
||||
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
||||
}
|
||||
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
||||
|
||||
const positions = new Float32Array(data.points.length * 3);
|
||||
const colors = new Float32Array(data.points.length * 3);
|
||||
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
|
||||
const hasErrorValues = pointsWithError.length > 0;
|
||||
|
||||
let minError: number;
|
||||
let maxError: number;
|
||||
if (errorRangeMode === 'percentile') {
|
||||
const r = displayErrorRangeSmoothHeatmap(data.points);
|
||||
if (errorRangeMode === 'percentile' && hasErrorValues) {
|
||||
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||
minError = r.minError;
|
||||
maxError = r.maxError;
|
||||
} else {
|
||||
minError = Infinity;
|
||||
maxError = -Infinity;
|
||||
for (let i = 0; i < data.points.length; i++) {
|
||||
const err = data.points[i].error ?? 0;
|
||||
const err = data.points[i].error;
|
||||
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
|
||||
if (err < minError) minError = err;
|
||||
if (err > maxError) maxError = err;
|
||||
}
|
||||
@@ -420,8 +427,8 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
||||
colors[i3] = point.r;
|
||||
colors[i3 + 1] = point.g;
|
||||
colors[i3 + 2] = point.b;
|
||||
} else if (showHeatmap) {
|
||||
const color = errorToColor(point.error ?? 0, minError, maxError);
|
||||
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
|
||||
const color = errorToColor(point.error!, minError, maxError);
|
||||
colors[i3] = color.r;
|
||||
colors[i3 + 1] = color.g;
|
||||
colors[i3 + 2] = color.b;
|
||||
@@ -1165,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
|
||||
);
|
||||
});
|
||||
|
||||
// 一键全流程演示扫描动画
|
||||
interface DemoFlowScannerProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
const sweepRef = useRef<THREE.Mesh>(null);
|
||||
const particlesRef = useRef<THREE.Points>(null);
|
||||
|
||||
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
|
||||
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
|
||||
|
||||
const { positions, colors } = useMemo(() => {
|
||||
const count = 120;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
|
||||
positions[i * 3] = Math.cos(angle) * radius;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
|
||||
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||
|
||||
colors[i * 3] = 0.2;
|
||||
colors[i * 3 + 1] = 0.75;
|
||||
colors[i * 3 + 2] = 1.0;
|
||||
}
|
||||
return { positions, colors };
|
||||
}, []);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!active) return;
|
||||
const t = state.clock.elapsedTime;
|
||||
if (ringRef.current) {
|
||||
ringRef.current.rotation.z = -t * 0.3;
|
||||
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
|
||||
}
|
||||
if (sweepRef.current) {
|
||||
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
|
||||
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
|
||||
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
|
||||
}
|
||||
if (particlesRef.current) {
|
||||
particlesRef.current.rotation.y = t * 0.15;
|
||||
}
|
||||
});
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* 旋转圆环 */}
|
||||
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<ringGeometry args={[3.5, 3.7, 96]} />
|
||||
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
{/* 扫描平面 */}
|
||||
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[7.6, 7.6, 48, 48]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
|
||||
</mesh>
|
||||
|
||||
{/* 粒子环 */}
|
||||
<points ref={particlesRef}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||
</bufferGeometry>
|
||||
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
|
||||
</points>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
||||
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
||||
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
||||
|
||||
@@ -1202,6 +1285,8 @@ interface SceneContentProps {
|
||||
/** ROI 坐标拾取模式 */
|
||||
roiPickMode?: boolean;
|
||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||
/** 一键全流程演示运行中:展示扫描动画 */
|
||||
demoFlowActive?: boolean;
|
||||
}
|
||||
|
||||
const SceneContent = memo(function SceneContent({
|
||||
@@ -1228,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
|
||||
plyOverlayData = null,
|
||||
roiPickMode = false,
|
||||
onRoiPointPicked,
|
||||
demoFlowActive = false,
|
||||
}: SceneContentProps) {
|
||||
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
||||
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||
@@ -1378,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
|
||||
{/* Grid */}
|
||||
<IndustrialGrid />
|
||||
|
||||
{/* 一键全流程演示扫描动画 */}
|
||||
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
|
||||
|
||||
{/* PLY 叠加层(kind=ply 插件结果) */}
|
||||
{plyOverlayData && (
|
||||
<points>
|
||||
@@ -1418,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
|
||||
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
||||
prevProps.dispRange === nextProps.dispRange &&
|
||||
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
||||
prevProps.roiPickMode === nextProps.roiPickMode;
|
||||
prevProps.roiPickMode === nextProps.roiPickMode &&
|
||||
prevProps.demoFlowActive === nextProps.demoFlowActive;
|
||||
});
|
||||
|
||||
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
||||
@@ -1535,6 +1625,7 @@ async function fetchBatchSampled(
|
||||
|
||||
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
||||
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||
const hasError = resp.headers.get('X-Has-Error') === '1';
|
||||
const stride = hasColor ? 7 : 4;
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const floats = new Float32Array(arrayBuffer);
|
||||
@@ -1551,7 +1642,8 @@ async function fetchBatchSampled(
|
||||
for (let i = 0; i < count; i++) {
|
||||
const base = i * stride;
|
||||
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
||||
const pt: Point = { x, y, z, error };
|
||||
const pt: Point = { x, y, z };
|
||||
if (hasError && Number.isFinite(error)) pt.error = error;
|
||||
if (hasColor) {
|
||||
pt.r = floats[base + 4];
|
||||
pt.g = floats[base + 5];
|
||||
@@ -1622,6 +1714,8 @@ interface PointCloudViewerProps {
|
||||
*/
|
||||
roiPickMode?: boolean;
|
||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
|
||||
demoFlowActive?: boolean;
|
||||
}
|
||||
|
||||
export default function PointCloudViewer({
|
||||
@@ -1651,6 +1745,7 @@ export default function PointCloudViewer({
|
||||
pluginOverlay = null,
|
||||
roiPickMode = false,
|
||||
onRoiPointPicked,
|
||||
demoFlowActive = false,
|
||||
}: PointCloudViewerProps) {
|
||||
// 点云数据状态
|
||||
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
||||
@@ -1964,6 +2059,7 @@ export default function PointCloudViewer({
|
||||
|
||||
const heatmapLegendBounds = useMemo(() => {
|
||||
if (!showHeatmap) return null;
|
||||
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
|
||||
const hasVisibleCadHeatmap =
|
||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||
serverCadHeatmap.status === 'ready' &&
|
||||
@@ -2120,6 +2216,7 @@ export default function PointCloudViewer({
|
||||
plyOverlayData={plyOverlayData}
|
||||
roiPickMode={roiPickMode}
|
||||
onRoiPointPicked={onRoiPointPicked}
|
||||
demoFlowActive={demoFlowActive}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -14,9 +14,19 @@ import {
|
||||
FileBox,
|
||||
Eye,
|
||||
Library,
|
||||
Pencil,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/_core/hooks/useAuth';
|
||||
|
||||
interface Props {
|
||||
modelId: number;
|
||||
@@ -40,6 +50,50 @@ function formatSize(bytes: number) {
|
||||
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) {
|
||||
if (!value) return '未知时间';
|
||||
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 });
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
|
||||
@@ -83,6 +154,7 @@ export default function TaskManager({
|
||||
onStartCreate,
|
||||
onEndCreate,
|
||||
}: Props) {
|
||||
const { canWrite } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
@@ -94,6 +166,9 @@ export default function TaskManager({
|
||||
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
|
||||
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
|
||||
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());
|
||||
@@ -136,10 +211,36 @@ export default function TaskManager({
|
||||
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
||||
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(
|
||||
{ modelId },
|
||||
{ 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({
|
||||
onSuccess: () => {
|
||||
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
|
||||
@@ -181,6 +282,9 @@ export default function TaskManager({
|
||||
setSelectedModelCadFileIds(new Set());
|
||||
setPcLibraryOpen(false);
|
||||
setCadLibraryOpen(false);
|
||||
setRenameTarget(null);
|
||||
setRenameError(null);
|
||||
setMetadataDetail(null);
|
||||
}, [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) {
|
||||
pendingTaskId.current = taskId;
|
||||
fileRef.current?.click();
|
||||
@@ -396,7 +528,7 @@ export default function TaskManager({
|
||||
<span className="industrial-label flex items-center gap-1.5">
|
||||
<ClipboardList className="w-3.5 h-3.5" /> 检测任务
|
||||
</span>
|
||||
<button
|
||||
{canWrite && <button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={() => {
|
||||
setAdding(true);
|
||||
@@ -404,10 +536,10 @@ export default function TaskManager({
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" /> 新建任务
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
{canWrite && adding && (
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
@@ -463,7 +595,7 @@ export default function TaskManager({
|
||||
>
|
||||
{taskStatusLabels[status] ?? status}
|
||||
</span>
|
||||
<button
|
||||
{canWrite && <button
|
||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
|
||||
title="上传点云文件"
|
||||
onClick={(e) => {
|
||||
@@ -472,8 +604,8 @@ export default function TaskManager({
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
title="删除任务"
|
||||
onClick={(e) => {
|
||||
@@ -482,7 +614,7 @@ export default function TaskManager({
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -490,7 +622,7 @@ export default function TaskManager({
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无任务,点击新建任务
|
||||
{canWrite ? '暂无任务,点击新建任务' : '暂无任务'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -526,20 +658,20 @@ export default function TaskManager({
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
onClick={() => openUpload(activeTaskId)}
|
||||
>
|
||||
<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 ${
|
||||
pcLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||
}`}
|
||||
onClick={() => setPcLibraryOpen((open) => !open)}
|
||||
>
|
||||
<Library className="w-3 h-3" /> 从车型库选择
|
||||
</button>
|
||||
</button>}
|
||||
{pcFiles.length > 0 && (
|
||||
<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"
|
||||
@@ -568,7 +700,7 @@ export default function TaskManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pcLibraryOpen && (
|
||||
{canWrite && pcLibraryOpen && (
|
||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
@@ -584,29 +716,144 @@ export default function TaskManager({
|
||||
</button>
|
||||
</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) => {
|
||||
const isChecked = selectedModelPcFileIds.has(f.id);
|
||||
const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
|
||||
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
|
||||
role="checkbox"
|
||||
aria-checked={isChecked}
|
||||
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)}
|
||||
onKeyDown={(event) => {
|
||||
if (isEditing) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggleModelPcFile(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" />
|
||||
<div className="relative flex items-start gap-2 p-2 pl-2.5">
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
<CheckSquare className="mt-0.5 w-3.5 h-3.5 text-cyan-300 flex-shrink-0" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<Square className="mt-0.5 w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<Cloud className="w-3.5 h-3.5 text-cyan-400 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)]" />
|
||||
{isEditing && renameTarget ? (
|
||||
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
|
||||
<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 && (
|
||||
@@ -647,6 +894,7 @@ export default function TaskManager({
|
||||
<div className="space-y-1">
|
||||
{pcFiles.map((f) => {
|
||||
const isChecked = selectedFileIds.has(f.id);
|
||||
const isEditing = renameTarget?.scope === 'task' && renameTarget.id === f.id;
|
||||
const meta = f as {
|
||||
source?: string;
|
||||
sourceResultId?: number | null;
|
||||
@@ -660,31 +908,92 @@ export default function TaskManager({
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
|
||||
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
|
||||
className={`group relative overflow-hidden rounded-lg border text-xs 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_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)}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
|
||||
<div className="relative flex items-start gap-2 p-2 pl-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0"
|
||||
className="mt-0.5 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFile(f.id);
|
||||
}}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
||||
<CheckSquare className="w-3.5 h-3.5 text-cyan-300" />
|
||||
) : (
|
||||
<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'}`}
|
||||
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'}`}
|
||||
/>
|
||||
{isEditing && renameTarget ? (
|
||||
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
|
||||
<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">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-1 min-w-0 truncate">{f.name}</span>
|
||||
<span className="min-w-0 flex-1 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
|
||||
{f.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
@@ -712,25 +1021,68 @@ export default function TaskManager({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{source === 'registered' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
|
||||
已配准
|
||||
</span>
|
||||
)}
|
||||
{source === 'registered' && meta.sourceResultId != null && (
|
||||
<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">
|
||||
R{meta.sourceResultId}
|
||||
<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="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">
|
||||
<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>
|
||||
)}
|
||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||
</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
|
||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
|
||||
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}」?`))
|
||||
@@ -738,7 +1090,12 @@ export default function TaskManager({
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -783,20 +1140,20 @@ export default function TaskManager({
|
||||
全选
|
||||
</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"
|
||||
onClick={() => cadFileRef.current?.click()}
|
||||
>
|
||||
<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 ${
|
||||
cadLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||
}`}
|
||||
onClick={() => setCadLibraryOpen((open) => !open)}
|
||||
>
|
||||
<Library className="w-3 h-3" /> 从车型库选择
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -811,7 +1168,7 @@ export default function TaskManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cadLibraryOpen && (
|
||||
{canWrite && cadLibraryOpen && (
|
||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
@@ -955,7 +1312,7 @@ export default function TaskManager({
|
||||
<Eye className="w-3 h-3 text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
{canWrite && <button
|
||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -964,7 +1321,7 @@ export default function TaskManager({
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1003,6 +1360,105 @@ export default function TaskManager({
|
||||
</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
|
||||
ref={fileRef}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@/_core/hooks/useAuth';
|
||||
|
||||
export type TreeSelection = {
|
||||
seriesId: number | null;
|
||||
@@ -25,9 +26,11 @@ interface Props {
|
||||
function EditableText({
|
||||
value,
|
||||
onSave,
|
||||
canEdit,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (v: string) => void;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
@@ -35,7 +38,7 @@ function EditableText({
|
||||
return (
|
||||
<span
|
||||
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
||||
onDoubleClick={() => { setDraft(value); setEditing(true); }}
|
||||
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
@@ -60,6 +63,7 @@ function EditableText({
|
||||
}
|
||||
|
||||
export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
const { canWrite } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
||||
const [newSeriesName, setNewSeriesName] = useState('');
|
||||
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
<div className="industrial-panel p-2 space-y-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="industrial-label">车系 / 车型 / 任务</span>
|
||||
<button
|
||||
{canWrite && <button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={() => setAddingSeries(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3" /> 车系
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
{addingSeries && (
|
||||
{canWrite && addingSeries && (
|
||||
<div className="flex items-center gap-1 pl-1">
|
||||
<input
|
||||
autoFocus
|
||||
@@ -176,9 +180,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
<EditableText
|
||||
value={series.name}
|
||||
canEdit={canWrite}
|
||||
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
||||
/>
|
||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary"
|
||||
title="新增车型"
|
||||
@@ -196,11 +201,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</span>}
|
||||
</div>
|
||||
|
||||
{/* 新增车型输入 */}
|
||||
{addingModelFor === series.id && (
|
||||
{canWrite && addingModelFor === series.id && (
|
||||
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
||||
<input
|
||||
autoFocus
|
||||
@@ -258,9 +263,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
||||
<EditableText
|
||||
value={model.name}
|
||||
canEdit={canWrite}
|
||||
onSave={name => updateModel.mutate({ id: model.id, name })}
|
||||
/>
|
||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||
title="删除车型"
|
||||
@@ -271,7 +277,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</span>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -283,7 +289,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
|
||||
{seriesList.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||
暂无车系,点击右上角新增
|
||||
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
115
client/src/components/demoFlowAggregation.test.ts
Normal file
115
client/src/components/demoFlowAggregation.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
167
client/src/components/demoFlowAggregation.ts
Normal file
167
client/src/components/demoFlowAggregation.ts
Normal 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 };
|
||||
}
|
||||
@@ -1,23 +1,2 @@
|
||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
|
||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
||||
export const getLoginUrl = () => {
|
||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
||||
const appId = import.meta.env.VITE_APP_ID;
|
||||
|
||||
// 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录)
|
||||
if (!oauthPortalUrl || oauthPortalUrl === 'disabled') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
||||
const state = btoa(redirectUri);
|
||||
|
||||
const url = new URL(`${oauthPortalUrl}/app-auth`);
|
||||
url.searchParams.set("appId", appId || '');
|
||||
url.searchParams.set("redirectUri", redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("type", "signIn");
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||
export const getLoginUrl = () => "/login";
|
||||
|
||||
161
client/src/hooks/useDemoFlowState.ts
Normal file
161
client/src/hooks/useDemoFlowState.ts
Normal 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]);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import App from "./App";
|
||||
import { getLoginUrl } from "./const";
|
||||
import "./index.css";
|
||||
|
||||
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
||||
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
|
||||
|
||||
if (!isUnauthorized) return;
|
||||
|
||||
window.location.href = getLoginUrl();
|
||||
if (window.location.pathname !== "/login") window.location.href = "/login";
|
||||
};
|
||||
|
||||
queryClient.getQueryCache().subscribe(event => {
|
||||
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
|
||||
if (event.type === "updated" && event.action.type === "error") {
|
||||
const error = event.mutation.state.error;
|
||||
redirectToLoginIfUnauthorized(error);
|
||||
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
|
||||
toast.error(error.message || "无权执行此操作");
|
||||
}
|
||||
console.error("[API Mutation Error]", error);
|
||||
}
|
||||
});
|
||||
|
||||
48
client/src/pages/ChangePassword.tsx
Normal file
48
client/src/pages/ChangePassword.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { Redirect, useLocation } from "wouter";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function ChangePassword() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const change = trpc.auth.changePassword.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.auth.me.invalidate();
|
||||
setLocation("/");
|
||||
},
|
||||
});
|
||||
|
||||
if (!loading && !user) return <Redirect to="/login" />;
|
||||
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (!mismatch) change.mutate({ currentPassword, newPassword });
|
||||
}}>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">修改密码</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
|
||||
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
|
||||
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
|
||||
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
|
||||
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
|
||||
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
|
||||
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useAuth } from '@/_core/hooks/useAuth';
|
||||
import { getLoginUrl } from '@/const';
|
||||
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
||||
import { useDemoFlowState } from '@/hooks/useDemoFlowState';
|
||||
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
||||
import TaskManager from '@/components/TaskManager';
|
||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||
@@ -9,7 +10,8 @@ import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
||||
import type { Overlay3D } from '@/components/GenericResultView';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import {
|
||||
LogIn,
|
||||
LogOut,
|
||||
Users,
|
||||
Box,
|
||||
Cloud,
|
||||
Eye,
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Home() {
|
||||
const { loading, isAuthenticated } = useAuth();
|
||||
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
||||
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 [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
||||
|
||||
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
|
||||
const demoFlowState = useDemoFlowState(selection.taskId);
|
||||
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const pluginNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
|
||||
return map;
|
||||
}, [pluginList]);
|
||||
|
||||
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
const [showPointCloud, setShowPointCloud] = useState(true);
|
||||
const [showCADModel] = useState(true);
|
||||
const [showWireframe, setShowWireframe] = useState(false);
|
||||
@@ -133,6 +144,7 @@ export default function Home() {
|
||||
setHeatmapViewResultId(null);
|
||||
setPluginOverlay(null);
|
||||
setPointCloudRenderMode('points');
|
||||
setShowHeatmap(false);
|
||||
setSelectedPcFileIds([]);
|
||||
setSelectedTaskCadFileIds([]);
|
||||
setRoiPickMode(false);
|
||||
@@ -217,6 +229,10 @@ export default function Home() {
|
||||
);
|
||||
|
||||
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
||||
setHeatmapViewResultId(null);
|
||||
setPluginOverlay(null);
|
||||
setPointCloudRenderMode('points');
|
||||
setShowHeatmap(false);
|
||||
setPointCloudTaskId(taskId);
|
||||
setPointCloudUrls([`__task:${taskId}`]);
|
||||
setPcLoading(true);
|
||||
@@ -281,7 +297,7 @@ export default function Home() {
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<AlgorithmRunPanel
|
||||
{canWrite && <AlgorithmRunPanel
|
||||
taskId={selection.taskId}
|
||||
modelId={selection.modelId}
|
||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||
@@ -297,8 +313,8 @@ export default function Home() {
|
||||
setRoiPickedPoint(null);
|
||||
}}
|
||||
activeRoiPickTarget={activeRoiPickTarget}
|
||||
/>
|
||||
<div className="h-px bg-border" />
|
||||
/>}
|
||||
{canWrite && <div className="h-px bg-border" />}
|
||||
<AnalysisPanel
|
||||
taskId={selection.taskId}
|
||||
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
||||
@@ -306,6 +322,7 @@ export default function Home() {
|
||||
setPluginOverlay(null);
|
||||
setHeatmapViewResultId(resultId);
|
||||
setPointCloudRenderMode('smoothHeatmap');
|
||||
setShowHeatmap(true);
|
||||
}}
|
||||
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
|
||||
const expectedIds = inputPointCloudFileIds ?? [];
|
||||
@@ -348,6 +365,7 @@ export default function Home() {
|
||||
setHeatmapViewResultId(null);
|
||||
setPluginOverlay({ taskId, resultId, overlay });
|
||||
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
||||
setShowHeatmap(overlay.kind === 'vertexColors');
|
||||
}}
|
||||
externalPending={runAnalysisJob.isPending}
|
||||
onRunAssembly={() => {
|
||||
@@ -420,10 +438,13 @@ export default function Home() {
|
||||
|
||||
{loading ? (
|
||||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||||
) : !isAuthenticated ? (
|
||||
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
) : user ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="text-right leading-tight"><div>{user.username}</div><div className="text-[10px] text-muted-foreground">{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}</div></div>
|
||||
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
||||
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
|
||||
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
@@ -481,6 +502,7 @@ export default function Home() {
|
||||
setRoiPickedPoint(pt);
|
||||
setRoiPickMode(false);
|
||||
}}
|
||||
demoFlowActive={demoFlowState.active}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -495,6 +517,8 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
|
||||
</div>
|
||||
|
||||
{/* 操作提示 */}
|
||||
@@ -549,6 +573,7 @@ export default function Home() {
|
||||
setPointCloudRenderMode('points');
|
||||
setHeatmapViewResultId(null);
|
||||
setPluginOverlay(null);
|
||||
setShowHeatmap(false);
|
||||
setShowPointCloud(true);
|
||||
return;
|
||||
}
|
||||
@@ -564,6 +589,7 @@ export default function Home() {
|
||||
正在显示分析结果 #{heatmapViewResultId}
|
||||
</span>
|
||||
)}
|
||||
{pointCloudRenderMode === 'smoothHeatmap' && (
|
||||
<button
|
||||
type="button"
|
||||
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
||||
@@ -572,6 +598,7 @@ export default function Home() {
|
||||
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
<span>热力图</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border" />
|
||||
|
||||
53
client/src/pages/Login.tsx
Normal file
53
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { Redirect, useLocation } from "wouter";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function Login() {
|
||||
const { user, loading } = useAuth();
|
||||
const [, setLocation] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const login = trpc.auth.login.useMutation({
|
||||
onSuccess: async (signedInUser) => {
|
||||
utils.auth.me.setData(undefined, signedInUser);
|
||||
await utils.auth.me.invalidate();
|
||||
setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/");
|
||||
},
|
||||
});
|
||||
|
||||
if (!loading && user) return <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<form
|
||||
className="w-full max-w-sm industrial-panel p-8 space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
login.mutate({ username, password });
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
|
||||
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
|
||||
{login.isPending ? "正在登录…" : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
client/src/pages/Users.tsx
Normal file
68
client/src/pages/Users.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ArrowLeft, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const;
|
||||
type Role = keyof typeof roleNames;
|
||||
|
||||
export default function Users() {
|
||||
const [, setLocation] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
const [search, setSearch] = useState("");
|
||||
const [draftSearch, setDraftSearch] = useState("");
|
||||
const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role });
|
||||
const list = trpc.users.list.useQuery(search ? { search } : {});
|
||||
const refresh = () => utils.users.list.invalidate();
|
||||
const onError = (error: { message: string }) => toast.error(error.message);
|
||||
const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError });
|
||||
const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError });
|
||||
const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError });
|
||||
const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError });
|
||||
const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6 space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
|
||||
</header>
|
||||
|
||||
<section className="industrial-panel p-5">
|
||||
<h2 className="font-medium mb-4">新建用户</h2>
|
||||
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
|
||||
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
|
||||
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
|
||||
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
|
||||
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="industrial-panel p-5 space-y-4">
|
||||
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
|
||||
<tbody>{list.data?.map(user => (
|
||||
<tr key={user.id} className="border-b border-border/60">
|
||||
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
|
||||
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
|
||||
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
|
||||
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
|
||||
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
|
||||
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
|
||||
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,16 +44,15 @@ services:
|
||||
- NODE_ENV=development
|
||||
- PORT=3002
|
||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
- AUTH_MODE=local
|
||||
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
|
||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
|
||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
|
||||
- S3_ENDPOINT=http://localhost:9010
|
||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
||||
- S3_BUCKET=pointcloud-data
|
||||
- S3_REGION=us-east-1
|
||||
- VITE_APP_TITLE=宏量点云处理系统
|
||||
- VITE_OAUTH_PORTAL_URL=disabled
|
||||
- VITE_APP_ID=
|
||||
# 数据目录路径
|
||||
- DATA_DIR=/data
|
||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||
|
||||
@@ -28,9 +28,9 @@ services:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
# 内网部署禁用OAuth,使用本地认证
|
||||
- AUTH_MODE=local
|
||||
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
|
||||
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
|
||||
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
|
||||
# MinIO S3兼容存储配置
|
||||
- S3_ENDPOINT=http://localhost:9010
|
||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||
|
||||
@@ -16,15 +16,17 @@ USE pointcloud_db;
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
openId VARCHAR(64) NOT NULL UNIQUE,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
passwordHash VARCHAR(255),
|
||||
name TEXT,
|
||||
email VARCHAR(320),
|
||||
loginMethod VARCHAR(64),
|
||||
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
|
||||
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||
isActive INT NOT NULL DEFAULT 1,
|
||||
mustChangePassword INT NOT NULL DEFAULT 1,
|
||||
sessionVersion INT NOT NULL DEFAULT 0,
|
||||
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_openId (openId),
|
||||
lastLoginAt TIMESTAMP NULL,
|
||||
INDEX idx_email (email(255))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -124,6 +126,22 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` (
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`modelId` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`fileSize` BIGINT NOT NULL,
|
||||
`filePath` VARCHAR(512) NOT NULL,
|
||||
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||
`pointCount` BIGINT NULL,
|
||||
`pointAttributes` JSON NULL,
|
||||
`pointDataFormat` VARCHAR(32) NULL,
|
||||
`bytesPerPoint` INT NULL,
|
||||
`metadataError` TEXT NULL,
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_model_pointcloud_modelId` (`modelId`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`modelId` INT NOT NULL,
|
||||
@@ -143,6 +161,16 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`fileSize` BIGINT NOT NULL,
|
||||
`filePath` VARCHAR(512) NOT NULL,
|
||||
`source` VARCHAR(32) NOT NULL DEFAULT 'uploaded',
|
||||
`sourceResultId` INT NULL,
|
||||
`sourcePointcloudFileId` INT NULL,
|
||||
`sourceModelPointcloudId` INT NULL,
|
||||
`metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||
`pointCount` BIGINT NULL,
|
||||
`pointAttributes` JSON NULL,
|
||||
`pointDataFormat` VARCHAR(32) NULL,
|
||||
`bytesPerPoint` INT NULL,
|
||||
`metadataError` TEXT NULL,
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -158,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 创建默认管理员用户 (内网部署使用)
|
||||
INSERT INTO users (openId, name, email, role) VALUES
|
||||
('admin-local', '系统管理员', 'admin@localhost', 'admin')
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name);
|
||||
|
||||
-- 输出初始化完成信息
|
||||
SELECT 'Database initialization completed successfully!' AS message;
|
||||
|
||||
22
drizzle/0015_local_users_and_roles.sql
Normal file
22
drizzle/0015_local_users_and_roles.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
ALTER TABLE `users`
|
||||
MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||
ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`,
|
||||
ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`,
|
||||
ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`,
|
||||
ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`,
|
||||
ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`,
|
||||
ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`;
|
||||
|
||||
UPDATE `users`
|
||||
SET `username` = CONCAT('legacy_user_', `id`),
|
||||
`isActive` = 0,
|
||||
`mustChangePassword` = 1,
|
||||
`sessionVersion` = `sessionVersion` + 1;
|
||||
|
||||
ALTER TABLE `users`
|
||||
MODIFY COLUMN `username` VARCHAR(64) NOT NULL,
|
||||
ADD UNIQUE INDEX `users_username_unique` (`username`),
|
||||
DROP INDEX `users_openId_unique`,
|
||||
DROP COLUMN `openId`,
|
||||
DROP COLUMN `loginMethod`,
|
||||
DROP COLUMN `lastSignedIn`;
|
||||
15
drizzle/0016_pointcloud_metadata.sql
Normal file
15
drizzle/0016_pointcloud_metadata.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE `model_pointcloud_files`
|
||||
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN `pointCount` bigint NULL,
|
||||
ADD COLUMN `pointAttributes` json NULL,
|
||||
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||
ADD COLUMN `bytesPerPoint` int NULL,
|
||||
ADD COLUMN `metadataError` text NULL;
|
||||
|
||||
ALTER TABLE `task_pointcloud_files`
|
||||
ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN `pointCount` bigint NULL,
|
||||
ADD COLUMN `pointAttributes` json NULL,
|
||||
ADD COLUMN `pointDataFormat` varchar(32) NULL,
|
||||
ADD COLUMN `bytesPerPoint` int NULL,
|
||||
ADD COLUMN `metadataError` text NULL;
|
||||
@@ -1,17 +1,21 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||
import type { PointAttribute } from "../shared/pointcloudMetadata";
|
||||
|
||||
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
||||
// ─── 本地用户 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||||
username: varchar("username", { length: 64 }).notNull().unique(),
|
||||
passwordHash: varchar("passwordHash", { length: 255 }),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 320 }),
|
||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||||
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
|
||||
isActive: int("isActive").default(1).notNull(),
|
||||
mustChangePassword: int("mustChangePassword").default(1).notNull(),
|
||||
sessionVersion: int("sessionVersion").default(0).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||
lastLoginAt: timestamp("lastLoginAt"),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
@@ -71,6 +75,12 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -136,6 +146,12 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
|
||||
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
||||
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
||||
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
||||
metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(),
|
||||
pointCount: bigint("pointCount", { mode: "number" }),
|
||||
pointAttributes: json("pointAttributes").$type<PointAttribute[]>(),
|
||||
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
|
||||
bytesPerPoint: int("bytesPerPoint"),
|
||||
metadataError: text("metadataError"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
||||
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
275
scripts/backfill-flatness-suite-measurements.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export type Measurement = Record<string, unknown>;
|
||||
|
||||
export interface CandidateRow extends RowDataPacket {
|
||||
id: number;
|
||||
taskId: number;
|
||||
measurements: unknown;
|
||||
resultData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BackfillPlan {
|
||||
row: CandidateRow;
|
||||
resultJsonPath: string;
|
||||
originalResultJson: Record<string, unknown>;
|
||||
measurements: Measurement[];
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
planned: number;
|
||||
applied: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export function mergeMeasurements<T extends Record<string, unknown>>(
|
||||
value: T | null,
|
||||
measurements: Measurement[],
|
||||
): T & { measurements: Measurement[] } {
|
||||
return { ...(value ?? ({} as T)), measurements };
|
||||
}
|
||||
|
||||
export async function executePlans(
|
||||
plans: BackfillPlan[],
|
||||
apply: boolean,
|
||||
persist: (plan: BackfillPlan) => Promise<boolean>,
|
||||
): Promise<ExecutionSummary> {
|
||||
const summary: ExecutionSummary = {
|
||||
planned: plans.length,
|
||||
applied: 0,
|
||||
skipped: apply ? 0 : plans.length,
|
||||
failed: 0,
|
||||
};
|
||||
if (!apply) return summary;
|
||||
|
||||
for (const plan of plans) {
|
||||
try {
|
||||
if (await persist(plan)) summary.applied += 1;
|
||||
else summary.skipped += 1;
|
||||
} catch (error) {
|
||||
summary.failed += 1;
|
||||
console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function argumentValue(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
async function parseMeasurements(
|
||||
excelPath: string,
|
||||
templatePath: string,
|
||||
helperPath: string,
|
||||
): Promise<Measurement[]> {
|
||||
const python = process.env.PYTHON3_PATH || "/usr/bin/python3";
|
||||
const { stdout } = await execFile(
|
||||
python,
|
||||
[helperPath, "--excel", excelPath, "--template", templatePath],
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
throw new Error("解析结果为空")
|
||||
}
|
||||
return parsed.filter((item): item is Measurement => !!item && typeof item === "object");
|
||||
}
|
||||
|
||||
async function listCandidates(pool: Pool, resultId?: number): Promise<CandidateRow[]> {
|
||||
const filters = [
|
||||
"pluginId = 'flatness-suite'",
|
||||
"status = 'completed'",
|
||||
"(measurements IS NULL OR JSON_LENGTH(measurements) = 0)",
|
||||
];
|
||||
const params: number[] = [];
|
||||
if (resultId !== undefined) {
|
||||
filters.push("id = ?");
|
||||
params.push(resultId);
|
||||
}
|
||||
const [rows] = await pool.query<CandidateRow[]>(
|
||||
`SELECT id, taskId, measurements, resultData
|
||||
FROM task_analysis_results
|
||||
WHERE ${filters.join(" AND ")}
|
||||
ORDER BY id`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function buildPlans(
|
||||
rows: CandidateRow[],
|
||||
dataDir: string,
|
||||
templatePath: string,
|
||||
helperPath: string,
|
||||
): Promise<{ plans: BackfillPlan[]; skipped: number }> {
|
||||
const plans: BackfillPlan[] = [];
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id));
|
||||
const resultJsonPath = path.join(resultDir, "result.json");
|
||||
const excelPath = path.join(resultDir, "contour.xlsx");
|
||||
try {
|
||||
const [resultText] = await Promise.all([
|
||||
fs.readFile(resultJsonPath, "utf8"),
|
||||
fs.access(excelPath),
|
||||
]);
|
||||
const resultJson = JSON.parse(resultText) as Record<string, unknown>;
|
||||
const extra = resultJson.extra as Record<string, unknown> | undefined;
|
||||
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | undefined;
|
||||
if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") {
|
||||
throw new Error("不是 contour_batch 批量结果")
|
||||
}
|
||||
const measurements = await parseMeasurements(excelPath, templatePath, helperPath);
|
||||
plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements });
|
||||
console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`);
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
return { plans, skipped };
|
||||
}
|
||||
|
||||
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
|
||||
const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`;
|
||||
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await fs.rename(temporaryPath, filePath);
|
||||
}
|
||||
|
||||
async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise<string> {
|
||||
const backupDir = path.join(dataDir, "maintenance-backups");
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`);
|
||||
await writeJsonAtomically(backupPath, {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
entries: plans.map((plan) => ({
|
||||
id: plan.row.id,
|
||||
taskId: plan.row.taskId,
|
||||
resultJsonPath: plan.resultJsonPath,
|
||||
measurements: plan.row.measurements,
|
||||
resultData: plan.row.resultData,
|
||||
resultJson: plan.originalResultJson,
|
||||
})),
|
||||
});
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
async function persistPlan(pool: Pool, plan: BackfillPlan): Promise<boolean> {
|
||||
const connection = await pool.getConnection();
|
||||
const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements);
|
||||
const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements);
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await writeJsonAtomically(plan.resultJsonPath, updatedResultJson);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE task_analysis_results
|
||||
SET measurements = ?, resultData = ?
|
||||
WHERE id = ?
|
||||
AND pluginId = 'flatness-suite'
|
||||
AND status = 'completed'
|
||||
AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`,
|
||||
[JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id],
|
||||
);
|
||||
if (result.affectedRows !== 1) {
|
||||
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson);
|
||||
await connection.rollback();
|
||||
return false;
|
||||
}
|
||||
await connection.commit();
|
||||
return true;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
interface BackupManifest {
|
||||
entries: Array<{
|
||||
id: number;
|
||||
resultJsonPath: string;
|
||||
measurements: unknown;
|
||||
resultData: unknown;
|
||||
resultJson: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function restoreBackup(pool: Pool, backupPath: string): Promise<void> {
|
||||
const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest;
|
||||
if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效")
|
||||
for (const entry of manifest.entries) {
|
||||
await writeJsonAtomically(entry.resultJsonPath, entry.resultJson);
|
||||
await pool.execute(
|
||||
"UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'",
|
||||
[
|
||||
entry.measurements == null ? null : JSON.stringify(entry.measurements),
|
||||
entry.resultData == null ? null : JSON.stringify(entry.resultData),
|
||||
entry.id,
|
||||
],
|
||||
);
|
||||
console.log(`[backfill] 已恢复结果 ${entry.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("缺少 DATABASE_URL")
|
||||
const dataDir = process.env.DATA_DIR || "/data";
|
||||
const algRoot = process.env.ALG_ROOT || "/alg";
|
||||
const resultIdRaw = argumentValue("--result-id");
|
||||
const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw);
|
||||
if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数")
|
||||
const apply = process.argv.includes("--apply");
|
||||
const restorePath = argumentValue("--restore");
|
||||
const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx");
|
||||
const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py");
|
||||
const pool = mysql.createPool(databaseUrl);
|
||||
|
||||
try {
|
||||
if (restorePath) {
|
||||
await restoreBackup(pool, restorePath);
|
||||
return;
|
||||
}
|
||||
const rows = await listCandidates(pool, resultId);
|
||||
const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath);
|
||||
let backupPath: string | undefined;
|
||||
if (apply && plans.length > 0) {
|
||||
backupPath = await writeBackup(plans, dataDir);
|
||||
console.log(`[backfill] 写入前备份: ${backupPath}`);
|
||||
}
|
||||
const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan));
|
||||
summary.skipped += skipped;
|
||||
console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`);
|
||||
if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入");
|
||||
if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
||||
if (isMain) {
|
||||
main().catch((error) => {
|
||||
console.error("[backfill] 失败:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
153
server/_core/auth.ts
Normal file
153
server/_core/auth.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { promisify } from "util";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
function sessionSecret() {
|
||||
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
|
||||
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
|
||||
}
|
||||
return new TextEncoder().encode(ENV.cookieSecret);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string) {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
validatePassword(password);
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scrypt(password, salt, 64) as Buffer;
|
||||
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string | null) {
|
||||
if (!encoded) return false;
|
||||
const [scheme, saltValue, hashValue] = encoded.split("$");
|
||||
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
|
||||
try {
|
||||
const expected = Buffer.from(hashValue, "base64url");
|
||||
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
|
||||
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
|
||||
.sign(sessionSecret());
|
||||
}
|
||||
|
||||
function readSessionCookie(req: Request) {
|
||||
if (!req.headers.cookie) return undefined;
|
||||
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
||||
}
|
||||
|
||||
export async function authenticateRequest(req: Request): Promise<User | null> {
|
||||
const token = readSessionCookie(req);
|
||||
if (!token) return null;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
|
||||
const userId = Number(payload.userId);
|
||||
const sessionVersion = Number(payload.sessionVersion);
|
||||
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
|
||||
const user = await db.getUserById(userId);
|
||||
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type Attempt = { failures: number[] };
|
||||
const attempts = new Map<string, Attempt>();
|
||||
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
||||
const RATE_MAX_FAILURES = 5;
|
||||
|
||||
function normalizedUsername(username: string) {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function attemptKeys(ip: string, username: string) {
|
||||
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
|
||||
}
|
||||
|
||||
function recentFailures(key: string, now = Date.now()) {
|
||||
const record = attempts.get(key);
|
||||
if (!record) return [];
|
||||
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
|
||||
if (record.failures.length === 0) attempts.delete(key);
|
||||
return record.failures;
|
||||
}
|
||||
|
||||
export function isLoginRateLimited(ip: string, username: string) {
|
||||
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
|
||||
}
|
||||
|
||||
export function recordLoginFailure(ip: string, username: string) {
|
||||
const now = Date.now();
|
||||
for (const key of attemptKeys(ip, username)) {
|
||||
const failures = recentFailures(key, now);
|
||||
failures.push(now);
|
||||
attempts.set(key, { failures });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginFailures(ip: string, username: string) {
|
||||
for (const key of attemptKeys(ip, username)) attempts.delete(key);
|
||||
}
|
||||
|
||||
export function resetLoginRateLimitForTests() {
|
||||
attempts.clear();
|
||||
}
|
||||
|
||||
export async function ensureBootstrapAdmin() {
|
||||
if (await db.countActiveSuperAdmins() > 0) return;
|
||||
const username = ENV.bootstrapAdminUsername.trim();
|
||||
const password = ENV.bootstrapAdminPassword;
|
||||
if (!username || !password) {
|
||||
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
|
||||
}
|
||||
validatePassword(password);
|
||||
const existing = await db.getUserByUsername(username);
|
||||
const passwordHash = await hashPassword(password);
|
||||
if (existing) {
|
||||
await db.updateUser(existing.id, {
|
||||
passwordHash,
|
||||
role: "super_admin",
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: existing.sessionVersion + 1,
|
||||
});
|
||||
} else {
|
||||
await db.createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
name: "系统管理员",
|
||||
role: "super_admin",
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: 0,
|
||||
});
|
||||
}
|
||||
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
|
||||
}
|
||||
|
||||
export function publicUser(user: User) {
|
||||
const { passwordHash: _passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import { sdk } from "./sdk";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
@@ -14,7 +14,7 @@ export async function createContext(
|
||||
let user: User | null = null;
|
||||
|
||||
try {
|
||||
user = await sdk.authenticateRequest(opts.req);
|
||||
user = await authenticateRequest(opts.req);
|
||||
} catch (error) {
|
||||
// Authentication is optional for public procedures.
|
||||
user = null;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { CookieOptions, Request } from "express";
|
||||
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function isIpAddress(host: string) {
|
||||
// Basic IPv4 check and IPv6 presence detection.
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
|
||||
return host.includes(":");
|
||||
}
|
||||
|
||||
function isSecureRequest(req: Request) {
|
||||
if (req.protocol === "https") return true;
|
||||
|
||||
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "none",
|
||||
sameSite: "lax",
|
||||
secure: isSecureRequest(req),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
export const ENV = {
|
||||
appId: process.env.VITE_APP_ID ?? "",
|
||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||
authMode: process.env.AUTH_MODE ?? "oauth",
|
||||
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
|
||||
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
|
||||
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
|
||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
|
||||
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
|
||||
@@ -7,11 +7,11 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import mysql from "mysql2/promise";
|
||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { registerOAuthRoutes } from "./oauth";
|
||||
import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { fileRouter } from "../fileRoutes";
|
||||
import { ensureBootstrapAdmin } from "./auth";
|
||||
|
||||
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
await runMigrations();
|
||||
} catch (e) {
|
||||
console.error("[Migration] Unexpected error, server will still start:", e);
|
||||
}
|
||||
await ensureBootstrapAdmin();
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
// Configure body parser with larger size limit for file uploads
|
||||
@@ -128,8 +125,6 @@ async function startServer() {
|
||||
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
||||
server.keepAliveTimeout = 0; // 长连接不超时
|
||||
server.headersTimeout = 0; // 读请求头不超时
|
||||
// OAuth callback under /api/oauth/callback
|
||||
registerOAuthRoutes(app);
|
||||
// 禁止代理(NPS 等)缓存 API 响应
|
||||
app.use('/api', (_req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import * as db from "../db";
|
||||
import { getSessionCookieOptions } from "./cookies";
|
||||
import { sdk } from "./sdk";
|
||||
|
||||
function getQueryParam(req: Request, key: string): string | undefined {
|
||||
const value = req.query[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function registerOAuthRoutes(app: Express) {
|
||||
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
|
||||
const code = getQueryParam(req, "code");
|
||||
const state = getQueryParam(req, "state");
|
||||
|
||||
if (!code || !state) {
|
||||
res.status(400).json({ error: "code and state are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
|
||||
if (!userInfo.openId) {
|
||||
res.status(400).json({ error: "openId missing from user info" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: new Date(),
|
||||
});
|
||||
|
||||
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
|
||||
name: userInfo.name || "",
|
||||
expiresInMs: ONE_YEAR_MS,
|
||||
});
|
||||
|
||||
const cookieOptions = getSessionCookieOptions(req);
|
||||
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
|
||||
|
||||
res.redirect(302, "/");
|
||||
} catch (error) {
|
||||
console.error("[OAuth] Callback failed", error);
|
||||
res.status(500).json({ error: "OAuth callback failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
import { ForbiddenError } from "@shared/_core/errors";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
import type {
|
||||
ExchangeTokenRequest,
|
||||
ExchangeTokenResponse,
|
||||
GetUserInfoResponse,
|
||||
GetUserInfoWithJwtRequest,
|
||||
GetUserInfoWithJwtResponse,
|
||||
} from "./types/manusTypes";
|
||||
// Utility function
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
export type SessionPayload = {
|
||||
openId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
|
||||
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
|
||||
|
||||
class OAuthService {
|
||||
constructor(private client: ReturnType<typeof axios.create>) {
|
||||
const isLocalAuth = ENV.authMode === "local";
|
||||
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
|
||||
if (!ENV.oAuthServerUrl && !isLocalAuth) {
|
||||
console.error(
|
||||
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
|
||||
);
|
||||
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
|
||||
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
|
||||
}
|
||||
}
|
||||
|
||||
private decodeState(state: string): string {
|
||||
const redirectUri = atob(state);
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
async getTokenByCode(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
const payload: ExchangeTokenRequest = {
|
||||
clientId: ENV.appId,
|
||||
grantType: "authorization_code",
|
||||
code,
|
||||
redirectUri: this.decodeState(state),
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<ExchangeTokenResponse>(
|
||||
EXCHANGE_TOKEN_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUserInfoByToken(
|
||||
token: ExchangeTokenResponse
|
||||
): Promise<GetUserInfoResponse> {
|
||||
const { data } = await this.client.post<GetUserInfoResponse>(
|
||||
GET_USER_INFO_PATH,
|
||||
{
|
||||
accessToken: token.accessToken,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
const createOAuthHttpClient = (): AxiosInstance =>
|
||||
axios.create({
|
||||
baseURL: ENV.oAuthServerUrl,
|
||||
timeout: AXIOS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
class SDKServer {
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly oauthService: OAuthService;
|
||||
|
||||
constructor(client: AxiosInstance = createOAuthHttpClient()) {
|
||||
this.client = client;
|
||||
this.oauthService = new OAuthService(this.client);
|
||||
}
|
||||
|
||||
private isLocalAuthMode() {
|
||||
return ENV.authMode === "local";
|
||||
}
|
||||
|
||||
private deriveLoginMethod(
|
||||
platforms: unknown,
|
||||
fallback: string | null | undefined
|
||||
): string | null {
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
if (!Array.isArray(platforms) || platforms.length === 0) return null;
|
||||
const set = new Set<string>(
|
||||
platforms.filter((p): p is string => typeof p === "string")
|
||||
);
|
||||
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
|
||||
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
|
||||
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
|
||||
if (
|
||||
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
|
||||
set.has("REGISTERED_PLATFORM_AZURE")
|
||||
)
|
||||
return "microsoft";
|
||||
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
|
||||
const first = Array.from(set)[0];
|
||||
return first ? first.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth authorization code for access token
|
||||
* @example
|
||||
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
|
||||
*/
|
||||
async exchangeCodeForToken(
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<ExchangeTokenResponse> {
|
||||
return this.oauthService.getTokenByCode(code, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information using access token
|
||||
* @example
|
||||
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
|
||||
const data = await this.oauthService.getUserInfoByToken({
|
||||
accessToken,
|
||||
} as ExchangeTokenResponse);
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoResponse;
|
||||
}
|
||||
|
||||
private parseCookies(cookieHeader: string | undefined) {
|
||||
if (!cookieHeader) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
const parsed = parseCookieHeader(cookieHeader);
|
||||
return new Map(Object.entries(parsed));
|
||||
}
|
||||
|
||||
private getSessionSecret() {
|
||||
const secret = ENV.cookieSecret;
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session token for a Manus user openId
|
||||
* @example
|
||||
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
|
||||
*/
|
||||
async createSessionToken(
|
||||
openId: string,
|
||||
options: { expiresInMs?: number; name?: string } = {}
|
||||
): Promise<string> {
|
||||
return this.signSession(
|
||||
{
|
||||
openId,
|
||||
appId: ENV.appId,
|
||||
name: options.name || "",
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
async signSession(
|
||||
payload: SessionPayload,
|
||||
options: { expiresInMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const issuedAt = Date.now();
|
||||
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
|
||||
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
|
||||
const secretKey = this.getSessionSecret();
|
||||
|
||||
return new SignJWT({
|
||||
openId: payload.openId,
|
||||
appId: payload.appId,
|
||||
name: payload.name,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
.sign(secretKey);
|
||||
}
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
||||
if (!cookieValue) {
|
||||
console.warn("[Auth] Missing session cookie");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const secretKey = this.getSessionSecret();
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(openId) ||
|
||||
!isNonEmptyString(appId) ||
|
||||
!isNonEmptyString(name)
|
||||
) {
|
||||
console.warn("[Auth] Session payload missing required fields");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Session verification failed", String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInfoWithJwt(
|
||||
jwtToken: string
|
||||
): Promise<GetUserInfoWithJwtResponse> {
|
||||
const payload: GetUserInfoWithJwtRequest = {
|
||||
jwtToken,
|
||||
projectId: ENV.appId,
|
||||
};
|
||||
|
||||
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
|
||||
GET_USER_INFO_WITH_JWT_PATH,
|
||||
payload
|
||||
);
|
||||
|
||||
const loginMethod = this.deriveLoginMethod(
|
||||
(data as any)?.platforms,
|
||||
(data as any)?.platform ?? data.platform ?? null
|
||||
);
|
||||
return {
|
||||
...(data as any),
|
||||
platform: loginMethod,
|
||||
loginMethod,
|
||||
} as GetUserInfoWithJwtResponse;
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<User> {
|
||||
if (this.isLocalAuthMode()) {
|
||||
const signedInAt = new Date();
|
||||
const localOpenId = ENV.localDevOpenId;
|
||||
|
||||
try {
|
||||
await db.upsertUser({
|
||||
openId: localOpenId,
|
||||
name: ENV.localDevName,
|
||||
email: ENV.localDevEmail,
|
||||
loginMethod: "local",
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
const localUser = await db.getUserByOpenId(localOpenId);
|
||||
if (localUser) return localUser;
|
||||
} catch {
|
||||
// DB not ready yet (e.g. MySQL still starting up); fall through to
|
||||
// an in-memory guest user so the UI keeps working.
|
||||
console.warn("[Auth] DB unavailable in local mode, using fallback user");
|
||||
}
|
||||
|
||||
// Fallback: return a transient in-memory user so requests don't fail
|
||||
// while MySQL is warming up.
|
||||
return {
|
||||
id: 0,
|
||||
openId: localOpenId,
|
||||
name: ENV.localDevName ?? "Dev User",
|
||||
email: ENV.localDevEmail ?? null,
|
||||
loginMethod: "local",
|
||||
role: "admin" as const,
|
||||
createdAt: signedInAt,
|
||||
updatedAt: signedInAt,
|
||||
lastSignedIn: signedInAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular authentication flow
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
const session = await this.verifySession(sessionCookie);
|
||||
|
||||
if (!session) {
|
||||
throw ForbiddenError("Invalid session cookie");
|
||||
}
|
||||
|
||||
const sessionUserId = session.openId;
|
||||
const signedInAt = new Date();
|
||||
let user = await db.getUserByOpenId(sessionUserId);
|
||||
|
||||
// If user not in DB, sync from OAuth server automatically
|
||||
if (!user) {
|
||||
try {
|
||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||
await db.upsertUser({
|
||||
openId: userInfo.openId,
|
||||
name: userInfo.name || null,
|
||||
email: userInfo.email ?? null,
|
||||
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
user = await db.getUserByOpenId(userInfo.openId);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to sync user from OAuth:", error);
|
||||
throw ForbiddenError("Failed to sync user info");
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw ForbiddenError("User not found");
|
||||
}
|
||||
|
||||
await db.upsertUser({
|
||||
openId: user.openId,
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export const sdk = new SDKServer();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { TrpcContext } from "./context";
|
||||
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
const requireUser = t.middleware(async opts => {
|
||||
const requireSession = t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user) {
|
||||
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(requireUser);
|
||||
export const sessionProcedure = t.procedure.use(requireSession);
|
||||
|
||||
const requireReadableUser = t.middleware(async opts => {
|
||||
if (!opts.ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
}
|
||||
if (opts.ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(requireReadableUser);
|
||||
|
||||
export const adminProcedure = t.procedure.use(
|
||||
t.middleware(async opts => {
|
||||
const { ctx, next } = opts;
|
||||
|
||||
if (!ctx.user || ctx.user.role !== 'admin') {
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
}
|
||||
if (ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
|
||||
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
export const superAdminProcedure = t.procedure.use(
|
||||
t.middleware(async opts => {
|
||||
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
|
||||
if (opts.ctx.user.mustChangePassword === 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
|
||||
}
|
||||
if (opts.ctx.user.role !== "super_admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
|
||||
}
|
||||
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
57
server/analysisArtifacts.test.ts
Normal file
57
server/analysisArtifacts.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanupAnalysisArtifacts, getAnalysisArtifactPaths } from "./analysisArtifacts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeDataDir() {
|
||||
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "analysis-artifacts-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("cleanupAnalysisArtifacts", () => {
|
||||
it("deletes the plugin directory and all legacy files for only the requested result", async () => {
|
||||
const dataDir = await makeDataDir();
|
||||
const analysisDir = path.join(dataDir, "tasks", "7", "analysis");
|
||||
await fs.promises.mkdir(path.join(analysisDir, "57"), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(analysisDir, "58"), { recursive: true });
|
||||
const files = [
|
||||
"heatmap_57.bin",
|
||||
"deviation_57.bin",
|
||||
"assembly_vertex_57_car-body.bin",
|
||||
"assembly_vertex_57_bogie.bin",
|
||||
"heatmap_58.bin",
|
||||
"assembly_vertex_570_other.bin",
|
||||
];
|
||||
await Promise.all(files.map((name) => fs.promises.writeFile(path.join(analysisDir, name), name)));
|
||||
|
||||
await cleanupAnalysisArtifacts(7, 57, dataDir);
|
||||
|
||||
await expect(fs.promises.stat(path.join(analysisDir, "57"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
for (const name of files.slice(0, 4)) {
|
||||
await expect(fs.promises.stat(path.join(analysisDir, name))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
await expect(fs.promises.stat(path.join(analysisDir, "58"))).resolves.toBeDefined();
|
||||
await expect(fs.promises.stat(path.join(analysisDir, "heatmap_58.bin"))).resolves.toBeDefined();
|
||||
await expect(fs.promises.stat(path.join(analysisDir, "assembly_vertex_570_other.bin"))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects invalid IDs before constructing a filesystem target", () => {
|
||||
expect(() => getAnalysisArtifactPaths(7, Number.NaN, "/data")).toThrow("resultId");
|
||||
expect(() => getAnalysisArtifactPaths(7, -1, "/data")).toThrow("resultId");
|
||||
expect(() => getAnalysisArtifactPaths(1.5, 2, "/data")).toThrow("taskId");
|
||||
});
|
||||
|
||||
it("is idempotent when artifacts are already absent", async () => {
|
||||
const dataDir = await makeDataDir();
|
||||
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
|
||||
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
68
server/analysisArtifacts.ts
Normal file
68
server/analysisArtifacts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export type AnalysisArtifactPaths = {
|
||||
analysisDir: string;
|
||||
resultDir: string;
|
||||
legacyFiles: string[];
|
||||
};
|
||||
|
||||
function assertNumericId(name: string, value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalysisArtifactPaths(
|
||||
taskId: number,
|
||||
resultId: number,
|
||||
dataDir = process.env.DATA_DIR || "/data",
|
||||
): AnalysisArtifactPaths {
|
||||
assertNumericId("taskId", taskId);
|
||||
assertNumericId("resultId", resultId);
|
||||
|
||||
const tasksDir = path.resolve(dataDir, "tasks");
|
||||
const taskDir = path.resolve(tasksDir, String(taskId));
|
||||
const analysisDir = path.resolve(taskDir, "analysis");
|
||||
const resultDir = path.resolve(analysisDir, String(resultId));
|
||||
|
||||
if (path.dirname(taskDir) !== tasksDir || path.dirname(resultDir) !== analysisDir) {
|
||||
throw new Error("analysis artifact path escaped its fixed root");
|
||||
}
|
||||
|
||||
return {
|
||||
analysisDir,
|
||||
resultDir,
|
||||
legacyFiles: [
|
||||
path.join(analysisDir, `heatmap_${resultId}.bin`),
|
||||
path.join(analysisDir, `deviation_${resultId}.bin`),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupAnalysisArtifacts(
|
||||
taskId: number,
|
||||
resultId: number,
|
||||
dataDir = process.env.DATA_DIR || "/data",
|
||||
): Promise<void> {
|
||||
const paths = getAnalysisArtifactPaths(taskId, resultId, dataDir);
|
||||
await fs.promises.rm(paths.resultDir, { recursive: true, force: true });
|
||||
|
||||
let entries: string[] = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(paths.analysisDir);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const assemblyPrefix = `assembly_vertex_${resultId}_`;
|
||||
const assemblyFiles = entries
|
||||
.filter((name) => name.startsWith(assemblyPrefix) && name.endsWith(".bin"))
|
||||
.map((name) => path.join(paths.analysisDir, name));
|
||||
|
||||
await Promise.all(
|
||||
[...paths.legacyFiles, ...assemblyFiles].map((file) =>
|
||||
fs.promises.rm(file, { force: true }),
|
||||
),
|
||||
);
|
||||
}
|
||||
49
server/analysisResultDeletion.test.ts
Normal file
49
server/analysisResultDeletion.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as db from "./db";
|
||||
import { cancelAlgorithmJob } from "./algorithmJob";
|
||||
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
|
||||
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
|
||||
import { deleteAnalysisResult } from "./analysisResultDeletion";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getTaskAnalysisResultById: vi.fn(),
|
||||
deleteTaskAnalysisResultWithDerivedPointclouds: vi.fn(),
|
||||
}));
|
||||
vi.mock("./algorithmJob", () => ({ cancelAlgorithmJob: vi.fn() }));
|
||||
vi.mock("./analysisJobControl", () => ({ cancelLegacyAnalysisJob: vi.fn() }));
|
||||
vi.mock("./analysisArtifacts", () => ({ cleanupAnalysisArtifacts: vi.fn() }));
|
||||
|
||||
const row = { id: 57, taskId: 7 } as Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>;
|
||||
|
||||
describe("deleteAnalysisResult", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValue(row);
|
||||
vi.mocked(cleanupAnalysisArtifacts).mockResolvedValue(undefined);
|
||||
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("cancels jobs, removes files, then transactionally cascades derived pointcloud rows", async () => {
|
||||
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
|
||||
expect(cancelAlgorithmJob).toHaveBeenCalledWith(57);
|
||||
expect(cancelLegacyAnalysisJob).toHaveBeenCalledWith(57);
|
||||
expect(cleanupAnalysisArtifacts).toHaveBeenCalledWith(7, 57);
|
||||
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).toHaveBeenCalledWith(57);
|
||||
expect(vi.mocked(cleanupAnalysisArtifacts).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps database rows when filesystem cleanup fails", async () => {
|
||||
vi.mocked(cleanupAnalysisArtifacts).mockRejectedValueOnce(new Error("permission denied"));
|
||||
await expect(deleteAnalysisResult(57)).rejects.toThrow("permission denied");
|
||||
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats deletion of an already missing result as success", async () => {
|
||||
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValueOnce(undefined);
|
||||
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
|
||||
expect(cleanupAnalysisArtifacts).not.toHaveBeenCalled();
|
||||
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
18
server/analysisResultDeletion.ts
Normal file
18
server/analysisResultDeletion.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as db from "./db";
|
||||
import { cancelAlgorithmJob } from "./algorithmJob";
|
||||
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
|
||||
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
|
||||
|
||||
export async function deleteAnalysisResult(id: number): Promise<{ success: true }> {
|
||||
const row = await db.getTaskAnalysisResultById(id);
|
||||
if (!row) return { success: true };
|
||||
|
||||
cancelAlgorithmJob(id);
|
||||
cancelLegacyAnalysisJob(id);
|
||||
|
||||
// 文件删除失败时不触碰数据库,保留记录以便用户重试。
|
||||
await cleanupAnalysisArtifacts(row.taskId, row.id);
|
||||
await db.deleteTaskAnalysisResultWithDerivedPointclouds(id);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
|
||||
|
||||
const user: AuthenticatedUser = {
|
||||
id: 1,
|
||||
openId: "sample-user",
|
||||
username: "sample-user",
|
||||
passwordHash: null,
|
||||
email: "sample@example.com",
|
||||
name: "Sample User",
|
||||
loginMethod: "manus",
|
||||
role: "user",
|
||||
isActive: 1,
|
||||
mustChangePassword: 0,
|
||||
sessionVersion: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
|
||||
const ctx: TrpcContext = {
|
||||
@@ -54,7 +57,7 @@ describe("auth.logout", () => {
|
||||
expect(clearedCookies[0]?.options).toMatchObject({
|
||||
maxAge: -1,
|
||||
secure: true,
|
||||
sameSite: "none",
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
32
server/auth.security.test.ts
Normal file
32
server/auth.security.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
hashPassword,
|
||||
isLoginRateLimited,
|
||||
recordLoginFailure,
|
||||
resetLoginRateLimitForTests,
|
||||
verifyPassword,
|
||||
} from "./_core/auth";
|
||||
|
||||
describe("本地密码认证", () => {
|
||||
beforeEach(() => resetLoginRateLimitForTests());
|
||||
|
||||
it("使用带随机盐的 scrypt 哈希校验密码", async () => {
|
||||
const first = await hashPassword("correct-password");
|
||||
const second = await hashPassword("correct-password");
|
||||
expect(first).toMatch(/^scrypt\$/);
|
||||
expect(first).not.toBe(second);
|
||||
await expect(verifyPassword("correct-password", first)).resolves.toBe(true);
|
||||
await expect(verifyPassword("wrong-password", first)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝少于 8 位的密码", async () => {
|
||||
await expect(hashPassword("short")).rejects.toThrow("至少需要 8 位");
|
||||
});
|
||||
|
||||
it("分别按 IP 和用户名限制连续失败", () => {
|
||||
for (let i = 0; i < 5; i += 1) recordLoginFailure("10.0.0.1", "alice");
|
||||
expect(isLoginRateLimited("10.0.0.1", "another-user")).toBe(true);
|
||||
expect(isLoginRateLimited("another-ip", "ALICE")).toBe(true);
|
||||
expect(isLoginRateLimited("another-ip", "bob")).toBe(false);
|
||||
});
|
||||
});
|
||||
37
server/bootstrap-admin.test.ts
Normal file
37
server/bootstrap-admin.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
countActiveSuperAdmins: vi.fn(),
|
||||
getUserByUsername: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as db from "./db";
|
||||
import { ensureBootstrapAdmin } from "./_core/auth";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
describe("引导超级管理员", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ENV.bootstrapAdminUsername = "bootstrap-test";
|
||||
ENV.bootstrapAdminPassword = "bootstrap-password";
|
||||
});
|
||||
|
||||
it("不存在有效超级管理员时只创建一次", async () => {
|
||||
vi.mocked(db.countActiveSuperAdmins).mockResolvedValueOnce(0).mockResolvedValueOnce(1);
|
||||
vi.mocked(db.getUserByUsername).mockResolvedValue(undefined);
|
||||
vi.mocked(db.createUser).mockResolvedValue({} as never);
|
||||
await ensureBootstrapAdmin();
|
||||
await ensureBootstrapAdmin();
|
||||
expect(db.createUser).toHaveBeenCalledTimes(1);
|
||||
expect(db.createUser).toHaveBeenCalledWith(expect.objectContaining({ role: "super_admin", isActive: 1, mustChangePassword: 1 }));
|
||||
});
|
||||
|
||||
it("无有效超级管理员且缺少变量时启动失败", async () => {
|
||||
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(0);
|
||||
ENV.bootstrapAdminUsername = "";
|
||||
ENV.bootstrapAdminPassword = "";
|
||||
await expect(ensureBootstrapAdmin()).rejects.toThrow("BOOTSTRAP_ADMIN_USERNAME");
|
||||
});
|
||||
});
|
||||
257
server/db.ts
257
server/db.ts
@@ -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 type { ResultSetHeader } from "mysql2";
|
||||
import { inspectPointcloudMetadata } from "./pointcloudMetadata.js";
|
||||
import {
|
||||
users, InsertUser,
|
||||
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
||||
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
@@ -80,60 +80,67 @@ export async function getDb() {
|
||||
|
||||
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
if (!user.openId) throw new Error("User openId is required for upsert");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot upsert user: database not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values: InsertUser = { openId: user.openId };
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
|
||||
const textFields = ["name", "email", "loginMethod"] as const;
|
||||
type TextField = (typeof textFields)[number];
|
||||
|
||||
const assignNullable = (field: TextField) => {
|
||||
const value = user[field];
|
||||
if (value === undefined) return;
|
||||
const normalized = value ?? null;
|
||||
values[field] = normalized;
|
||||
updateSet[field] = normalized;
|
||||
};
|
||||
textFields.forEach(assignNullable);
|
||||
|
||||
if (user.lastSignedIn !== undefined) {
|
||||
values.lastSignedIn = user.lastSignedIn;
|
||||
updateSet.lastSignedIn = user.lastSignedIn;
|
||||
}
|
||||
if (user.role !== undefined) {
|
||||
values.role = user.role;
|
||||
updateSet.role = user.role;
|
||||
} else if (user.openId === ENV.ownerOpenId) {
|
||||
values.role = 'admin';
|
||||
updateSet.role = 'admin';
|
||||
}
|
||||
|
||||
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
||||
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
||||
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
||||
} catch (error) {
|
||||
console.error("[Database] Failed to upsert user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByOpenId(openId: string) {
|
||||
export async function getUserById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function listUsers(search?: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const term = search?.trim();
|
||||
if (!term) return db.select().from(users).orderBy(desc(users.createdAt));
|
||||
const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
||||
return db.select().from(users)
|
||||
.where(or(like(users.username, pattern), like(users.name, pattern), like(users.email, pattern)))
|
||||
.orderBy(desc(users.createdAt));
|
||||
}
|
||||
|
||||
export async function createUser(data: InsertUser) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(users).values(data);
|
||||
return getUserById(getMysqlInsertId(result));
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, data: Partial<InsertUser>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(users).set(data).where(eq(users.id, id));
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
export async function incrementUserSessionVersion(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(users)
|
||||
.set({ sessionVersion: sql`${users.sessionVersion} + 1` })
|
||||
.where(eq(users.id, id));
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
}
|
||||
|
||||
export async function countActiveSuperAdmins() {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users)
|
||||
.where(and(eq(users.role, "super_admin"), eq(users.isActive, 1)));
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
// ─── 车系 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
|
||||
@@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile)
|
||||
return row;
|
||||
}
|
||||
|
||||
const modelMetadataBackfills = new Map<number, Promise<ModelPointcloudFile>>();
|
||||
|
||||
async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||||
if (row.metadataStatus !== "pending") return row;
|
||||
const existing = modelMetadataBackfills.get(row.id);
|
||||
if (existing) return existing;
|
||||
|
||||
const backfill = (async () => {
|
||||
const db = await getDb();
|
||||
if (!db) return row;
|
||||
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
|
||||
await db.update(modelPointcloudFiles)
|
||||
.set(metadata)
|
||||
.where(and(
|
||||
eq(modelPointcloudFiles.id, row.id),
|
||||
eq(modelPointcloudFiles.metadataStatus, "pending"),
|
||||
));
|
||||
return { ...row, ...metadata };
|
||||
})();
|
||||
modelMetadataBackfills.set(row.id, backfill);
|
||||
try {
|
||||
return await backfill;
|
||||
} finally {
|
||||
modelMetadataBackfills.delete(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(modelPointcloudFiles)
|
||||
const rows = await db.select().from(modelPointcloudFiles)
|
||||
.where(eq(modelPointcloudFiles.modelId, modelId))
|
||||
.orderBy(desc(modelPointcloudFiles.createdAt));
|
||||
return Promise.all(rows.map(backfillModelPointcloudMetadata));
|
||||
}
|
||||
|
||||
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
||||
@@ -255,6 +290,36 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function findModelPointcloudFileByModelAndName(
|
||||
modelId: number,
|
||||
name: string,
|
||||
excludeId?: number,
|
||||
): Promise<ModelPointcloudFile | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const conditions = [
|
||||
eq(modelPointcloudFiles.modelId, modelId),
|
||||
sql`lower(${modelPointcloudFiles.name}) = lower(${name})`,
|
||||
];
|
||||
if (excludeId !== undefined) {
|
||||
conditions.push(sql`${modelPointcloudFiles.id} <> ${excludeId}`);
|
||||
}
|
||||
const [row] = await db.select().from(modelPointcloudFiles).where(and(...conditions)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateModelPointcloudFileName(
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<ModelPointcloudFile> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(modelPointcloudFiles).set({ name }).where(eq(modelPointcloudFiles.id, id));
|
||||
const row = await getModelPointcloudFileById(id);
|
||||
if (!row) throw new Error("Model pointcloud file disappeared after update");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
@@ -448,6 +513,7 @@ export async function createRegisteredPointcloudFile(
|
||||
): Promise<TaskPointcloudFile> {
|
||||
const fs = await import('node:fs');
|
||||
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
||||
const metadata = inspectPointcloudMetadata(filePath, name);
|
||||
return createTaskPointcloudFile({
|
||||
taskId,
|
||||
name,
|
||||
@@ -456,15 +522,81 @@ export async function createRegisteredPointcloudFile(
|
||||
source: 'registered',
|
||||
sourceResultId,
|
||||
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
||||
...metadata,
|
||||
} as InsertTaskPointcloudFile);
|
||||
}
|
||||
|
||||
const taskMetadataBackfills = new Map<number, Promise<TaskPointcloudFile>>();
|
||||
|
||||
async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise<TaskPointcloudFile> {
|
||||
if (row.metadataStatus !== "pending") return row;
|
||||
const existing = taskMetadataBackfills.get(row.id);
|
||||
if (existing) return existing;
|
||||
|
||||
const backfill = (async () => {
|
||||
const db = await getDb();
|
||||
if (!db) return row;
|
||||
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
|
||||
await db.update(taskPointcloudFiles)
|
||||
.set(metadata)
|
||||
.where(and(
|
||||
eq(taskPointcloudFiles.id, row.id),
|
||||
eq(taskPointcloudFiles.metadataStatus, "pending"),
|
||||
));
|
||||
return { ...row, ...metadata };
|
||||
})();
|
||||
taskMetadataBackfills.set(row.id, backfill);
|
||||
try {
|
||||
return await backfill;
|
||||
} finally {
|
||||
taskMetadataBackfills.delete(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(taskPointcloudFiles)
|
||||
const rows = await db.select().from(taskPointcloudFiles)
|
||||
.where(eq(taskPointcloudFiles.taskId, taskId))
|
||||
.orderBy(taskPointcloudFiles.createdAt);
|
||||
return Promise.all(rows.map(backfillTaskPointcloudMetadata));
|
||||
}
|
||||
|
||||
export async function getTaskPointcloudFileById(id: number): Promise<TaskPointcloudFile | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function findTaskPointcloudFileByTaskAndName(
|
||||
taskId: number,
|
||||
name: string,
|
||||
excludeId?: number,
|
||||
): Promise<TaskPointcloudFile | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const conditions = [
|
||||
eq(taskPointcloudFiles.taskId, taskId),
|
||||
sql`lower(${taskPointcloudFiles.name}) = lower(${name})`,
|
||||
];
|
||||
if (excludeId !== undefined) {
|
||||
conditions.push(sql`${taskPointcloudFiles.id} <> ${excludeId}`);
|
||||
}
|
||||
const [row] = await db.select().from(taskPointcloudFiles).where(and(...conditions)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function updateTaskPointcloudFileName(
|
||||
id: number,
|
||||
name: string,
|
||||
): Promise<TaskPointcloudFile> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.update(taskPointcloudFiles).set({ name }).where(eq(taskPointcloudFiles.id, id));
|
||||
const row = await getTaskPointcloudFileById(id);
|
||||
if (!row) throw new Error("Task pointcloud file disappeared after update");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
|
||||
@@ -507,6 +639,12 @@ export async function linkTaskPointcloudFilesFromModel(
|
||||
filePath: source.filePath,
|
||||
source: "model_library",
|
||||
sourceModelPointcloudId: source.id,
|
||||
metadataStatus: source.metadataStatus,
|
||||
pointCount: source.pointCount,
|
||||
pointAttributes: source.pointAttributes,
|
||||
pointDataFormat: source.pointDataFormat,
|
||||
bytesPerPoint: source.bytesPerPoint,
|
||||
metadataError: source.metadataError,
|
||||
} as InsertTaskPointcloudFile);
|
||||
inserted.push(record);
|
||||
existingSourceIds.add(id);
|
||||
@@ -580,3 +718,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
|
||||
if (!db) return;
|
||||
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分析结果及其派生的已配准点云记录。
|
||||
* 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。
|
||||
*/
|
||||
export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.sourceResultId, id));
|
||||
await tx.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type RequestHandler } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import multer from 'multer';
|
||||
import * as db from './db';
|
||||
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
|
||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||
import { authenticateRequest } from './_core/auth.js';
|
||||
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -48,6 +50,33 @@ const diskUpload = multer({
|
||||
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
||||
});
|
||||
|
||||
const requireFileRead: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||
res.locals.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const requireFileWrite: RequestHandler = async (req, res, next) => {
|
||||
try {
|
||||
const user = await authenticateRequest(req);
|
||||
if (!user) return res.status(401).json({ error: '请先登录' });
|
||||
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
|
||||
if (user.role !== 'admin' && user.role !== 'super_admin') {
|
||||
return res.status(403).json({ error: '无权执行此操作' });
|
||||
}
|
||||
res.locals.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -66,7 +95,7 @@ function safeResolve(base: string, filename: string): string | null {
|
||||
// POST /api/files/upload/cad/:modelId
|
||||
// multipart/form-data field: file
|
||||
|
||||
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const modelId = parseInt(req.params.modelId, 10);
|
||||
if (!req.file || isNaN(modelId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||
@@ -98,18 +127,20 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
|
||||
// POST /api/files/upload/model-pointcloud/:modelId
|
||||
// multipart/form-data field: file
|
||||
|
||||
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const modelId = parseInt(req.params.modelId, 10);
|
||||
if (!req.file || isNaN(modelId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||
}
|
||||
|
||||
const destPath = req.file.path;
|
||||
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||
const record = await db.createModelPointcloudFile({
|
||||
modelId,
|
||||
name: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
filePath: destPath,
|
||||
...metadata,
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -126,7 +157,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
|
||||
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/task-cad/:taskId
|
||||
|
||||
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/task-cad/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
if (!req.file || isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||
@@ -157,7 +188,7 @@ router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, r
|
||||
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/task-cad/:taskId/:filename
|
||||
|
||||
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
||||
router.get('/read/task-cad/:taskId/:filename', requireFileRead, (req, res) => {
|
||||
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
|
||||
const filePath = safeResolve(cadDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -168,18 +199,20 @@ router.get('/read/task-cad/:taskId/:filename', (req, res) => {
|
||||
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/pointcloud/:taskId
|
||||
|
||||
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
|
||||
router.post('/upload/pointcloud/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
if (!req.file || isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||
}
|
||||
|
||||
const destPath = req.file.path;
|
||||
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||
const record = await db.createTaskPointcloudFile({
|
||||
taskId,
|
||||
name: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
filePath: destPath,
|
||||
...metadata,
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -196,7 +229,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
|
||||
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/cad/:modelId/:filename
|
||||
|
||||
router.get('/read/cad/:modelId/:filename', (req, res) => {
|
||||
router.get('/read/cad/:modelId/:filename', requireFileRead, (req, res) => {
|
||||
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
|
||||
const filePath = safeResolve(cadDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -207,7 +240,7 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
|
||||
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/model-pointcloud/:modelId/:filename
|
||||
|
||||
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
||||
router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => {
|
||||
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
||||
const filePath = safeResolve(pcDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -218,7 +251,7 @@ router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
||||
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/pointcloud/:taskId/:filename
|
||||
|
||||
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
||||
router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (req, res) => {
|
||||
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
|
||||
const filePath = safeResolve(pcDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
@@ -229,7 +262,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
||||
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// DELETE /api/files/delete/cad/:id
|
||||
|
||||
router.delete('/delete/cad/:id', async (req, res) => {
|
||||
router.delete('/delete/cad/:id', requireFileWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||
|
||||
@@ -245,7 +278,7 @@ router.delete('/delete/cad/:id', async (req, res) => {
|
||||
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
|
||||
// DELETE /api/files/delete/pointcloud/:id
|
||||
|
||||
router.delete('/delete/pointcloud/:id', async (req, res) => {
|
||||
router.delete('/delete/pointcloud/:id', requireFileWrite, async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||
await db.deleteTaskPointcloudFile(id);
|
||||
@@ -254,13 +287,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
|
||||
|
||||
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
||||
// POST /api/files/sample/batch
|
||||
// Body: { taskId: number, totalPoints?: number }
|
||||
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
|
||||
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
|
||||
|
||||
router.post('/sample/batch', async (req, res) => {
|
||||
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
|
||||
router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
|
||||
taskId?: number;
|
||||
filePaths?: string[];
|
||||
totalPoints?: number;
|
||||
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
||||
fileIds?: number[];
|
||||
@@ -268,7 +299,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
|
||||
let paths: string[] = [];
|
||||
|
||||
if (taskId != null) {
|
||||
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
|
||||
let records = await db.listTaskPointcloudFiles(taskId);
|
||||
// 当前端传入 fileIds 时只采样选中的文件
|
||||
if (Array.isArray(fileIds) && fileIds.length > 0) {
|
||||
@@ -276,10 +307,8 @@ router.post('/sample/batch', async (req, res) => {
|
||||
records = records.filter((r) => idSet.has(r.id));
|
||||
}
|
||||
paths = records.map((r) => r.filePath);
|
||||
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
|
||||
paths = rawPaths;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Provide taskId or filePaths' });
|
||||
return res.status(400).json({ error: '请提供有效的 taskId' });
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
@@ -326,6 +355,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
|
||||
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
|
||||
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
|
||||
const hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error)));
|
||||
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
|
||||
const bytesPerPoint = hasColor ? 28 : 16;
|
||||
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
|
||||
@@ -335,7 +365,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
binBuf.writeFloatLE(p.x, offset);
|
||||
binBuf.writeFloatLE(p.y, offset + 4);
|
||||
binBuf.writeFloatLE(p.z, offset + 8);
|
||||
binBuf.writeFloatLE(p.error, offset + 12);
|
||||
binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12);
|
||||
if (hasColor) {
|
||||
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
|
||||
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
|
||||
@@ -355,6 +385,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-Point-Count', totalSampled);
|
||||
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
|
||||
res.setHeader('X-Has-Error', hasError ? '1' : '0');
|
||||
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
|
||||
res.send(binBuf);
|
||||
} catch (err) {
|
||||
@@ -366,7 +397,7 @@ router.post('/sample/batch', async (req, res) => {
|
||||
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
|
||||
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
|
||||
|
||||
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
||||
router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||
@@ -402,7 +433,7 @@ router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
|
||||
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
|
||||
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
|
||||
|
||||
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
|
||||
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
const filename = req.params.filename;
|
||||
@@ -432,7 +463,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
|
||||
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
||||
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
|
||||
|
||||
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
||||
router.get('/read/analysis/:taskId/:resultId/*', requireFileRead, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
|
||||
@@ -455,7 +486,7 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
|
||||
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/resync/analysis/:taskId/:resultId
|
||||
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
|
||||
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
||||
router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req, res) => {
|
||||
const taskId = parseInt(req.params.taskId, 10);
|
||||
const resultId = parseInt(req.params.resultId, 10);
|
||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
||||
@@ -494,7 +525,7 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
|
||||
// POST /api/files/plugins/reload
|
||||
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
||||
// 用于更新算法版本后立即生效。
|
||||
router.post('/plugins/reload', (_req, res) => {
|
||||
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
|
||||
try {
|
||||
const plugins = reloadPluginRegistry();
|
||||
res.json({
|
||||
|
||||
62
server/flatnessSuiteBackfill.test.ts
Normal file
62
server/flatnessSuiteBackfill.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
executePlans,
|
||||
mergeMeasurements,
|
||||
type BackfillPlan,
|
||||
} from "../scripts/backfill-flatness-suite-measurements";
|
||||
|
||||
function plan(id: number): BackfillPlan {
|
||||
return {
|
||||
row: { id, taskId: 24, measurements: [], resultData: { kind: "plugin", marker: "kept" } } as BackfillPlan["row"],
|
||||
resultJsonPath: `/tmp/${id}/result.json`,
|
||||
originalResultJson: { status: "completed", extra: { module: "contour_batch" } },
|
||||
measurements: [{
|
||||
name: "任务11 · 车钩面板的平整度",
|
||||
value: null,
|
||||
unit: "mm",
|
||||
toleranceLabel: "0.5mm",
|
||||
status: "fail",
|
||||
passLabel: "成功",
|
||||
warningLabel: "部分成功",
|
||||
failLabel: "失败",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("flatness-suite 历史回填", () => {
|
||||
it("dry-run 不调用持久化", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
const summary = await executePlans([plan(234)], false, persist);
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
expect(summary).toEqual({ planned: 1, applied: 0, skipped: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("apply 由持久化层守卫实现幂等", async () => {
|
||||
const applied = new Set<number>();
|
||||
const persist = vi.fn(async (item: BackfillPlan) => {
|
||||
if (applied.has(item.row.id)) return false;
|
||||
applied.add(item.row.id);
|
||||
return true;
|
||||
});
|
||||
expect((await executePlans([plan(234)], true, persist)).applied).toBe(1);
|
||||
const second = await executePlans([plan(234)], true, persist);
|
||||
expect(second.applied).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it("异常记录不阻断其他记录", async () => {
|
||||
const persist = vi.fn(async (item: BackfillPlan) => {
|
||||
if (item.row.id === 1) throw new Error("bad excel");
|
||||
return true;
|
||||
});
|
||||
const summary = await executePlans([plan(1), plan(2)], true, persist);
|
||||
expect(summary).toEqual({ planned: 2, applied: 1, skipped: 0, failed: 1 });
|
||||
});
|
||||
|
||||
it("扩展字段完整进入 resultData 且保留已有字段", () => {
|
||||
const item = plan(234);
|
||||
const merged = mergeMeasurements(item.row.resultData, item.measurements);
|
||||
expect(merged.marker).toBe("kept");
|
||||
expect(merged.measurements).toEqual(item.measurements);
|
||||
});
|
||||
});
|
||||
@@ -23,14 +23,17 @@ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
function createAuthContext(): TrpcContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: 1,
|
||||
openId: "test-user",
|
||||
username: "test-user",
|
||||
passwordHash: null,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
loginMethod: "manus",
|
||||
role: "user",
|
||||
role: "admin",
|
||||
isActive: 1,
|
||||
mustChangePassword: 0,
|
||||
sessionVersion: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
63
server/permissions.test.ts
Normal file
63
server/permissions.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { User } from "../drizzle/schema";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import { protectedProcedure, router, superAdminProcedure } from "./_core/trpc";
|
||||
|
||||
const testRouter = router({
|
||||
read: protectedProcedure.query(() => "read-ok"),
|
||||
write: protectedProcedure.mutation(() => "write-ok"),
|
||||
manageUsers: superAdminProcedure.query(() => "users-ok"),
|
||||
});
|
||||
|
||||
function user(role: User["role"], mustChangePassword = 0): User {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: 1,
|
||||
username: `${role}-account`,
|
||||
passwordHash: "unused",
|
||||
name: role,
|
||||
email: null,
|
||||
role,
|
||||
isActive: 1,
|
||||
mustChangePassword,
|
||||
sessionVersion: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function context(currentUser: User | null): TrpcContext {
|
||||
return { user: currentUser, req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
|
||||
}
|
||||
|
||||
describe("三角色权限矩阵", () => {
|
||||
it("未登录不能读取", async () => {
|
||||
await expect(testRouter.createCaller(context(null)).read()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("首次改密前不能读取业务数据", async () => {
|
||||
await expect(testRouter.createCaller(context(user("super_admin", 1))).read()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("普通用户可读但不可写,也不可管理用户", async () => {
|
||||
const caller = testRouter.createCaller(context(user("user")));
|
||||
await expect(caller.read()).resolves.toBe("read-ok");
|
||||
await expect(caller.write()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("管理员可读写但不可管理用户", async () => {
|
||||
const caller = testRouter.createCaller(context(user("admin")));
|
||||
await expect(caller.read()).resolves.toBe("read-ok");
|
||||
await expect(caller.write()).resolves.toBe("write-ok");
|
||||
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("超级管理员拥有全部权限", async () => {
|
||||
const caller = testRouter.createCaller(context(user("super_admin")));
|
||||
await expect(caller.read()).resolves.toBe("read-ok");
|
||||
await expect(caller.write()).resolves.toBe("write-ok");
|
||||
await expect(caller.manageUsers()).resolves.toBe("users-ok");
|
||||
});
|
||||
});
|
||||
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
246
server/plugins/adapters/flatness_suite_measurements.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""flatness-suite 批量测量结果的模板关联与旧报表解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import posixpath
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
_SHEET_NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||||
_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
|
||||
def _column_index(reference: str) -> int:
|
||||
letters = "".join(character for character in reference if character.isalpha())
|
||||
if not letters:
|
||||
raise ValueError(f"无效的 Excel 单元格引用: {reference}")
|
||||
result = 0
|
||||
for character in letters.upper():
|
||||
result = result * 26 + ord(character) - ord("A") + 1
|
||||
return result
|
||||
|
||||
|
||||
def _cell_value(cell: ET.Element, shared_strings: list[str]) -> Any:
|
||||
cell_type = cell.attrib.get("t")
|
||||
if cell_type == "inlineStr":
|
||||
return "".join(text.text or "" for text in cell.findall(".//a:t", _SHEET_NS))
|
||||
|
||||
value_node = cell.find("a:v", _SHEET_NS)
|
||||
if value_node is None or value_node.text is None:
|
||||
return ""
|
||||
raw = value_node.text
|
||||
if cell_type == "s":
|
||||
try:
|
||||
return shared_strings[int(raw)]
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise ValueError("Excel 共享字符串索引无效") from exc
|
||||
if cell_type in {"str", "e"}:
|
||||
return raw
|
||||
if cell_type == "b":
|
||||
return raw == "1"
|
||||
try:
|
||||
number = float(raw)
|
||||
return int(number) if number.is_integer() else number
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
def read_xlsx_rows(path: str | Path) -> tuple[str, list[list[Any]]]:
|
||||
"""只用标准库读取 xlsx 的第一个工作表。"""
|
||||
workbook_path = Path(path)
|
||||
if not workbook_path.is_file():
|
||||
raise FileNotFoundError(f"Excel 文件不存在: {workbook_path}")
|
||||
|
||||
try:
|
||||
with ZipFile(workbook_path) as xlsx:
|
||||
names = set(xlsx.namelist())
|
||||
shared_strings: list[str] = []
|
||||
if "xl/sharedStrings.xml" in names:
|
||||
shared_root = ET.fromstring(xlsx.read("xl/sharedStrings.xml"))
|
||||
shared_strings = [
|
||||
"".join(text.text or "" for text in item.findall(".//a:t", _SHEET_NS))
|
||||
for item in shared_root.findall("a:si", _SHEET_NS)
|
||||
]
|
||||
|
||||
workbook_root = ET.fromstring(xlsx.read("xl/workbook.xml"))
|
||||
first_sheet = workbook_root.find("a:sheets/a:sheet", _SHEET_NS)
|
||||
if first_sheet is None:
|
||||
raise ValueError(f"Excel 中没有工作表: {workbook_path}")
|
||||
sheet_name = first_sheet.attrib.get("name", "Sheet1")
|
||||
relation_id = first_sheet.attrib.get(f"{{{_REL_NS}}}id")
|
||||
if not relation_id:
|
||||
raise ValueError(f"Excel 工作表关系缺失: {workbook_path}")
|
||||
|
||||
rels_root = ET.fromstring(xlsx.read("xl/_rels/workbook.xml.rels"))
|
||||
relation_targets = {
|
||||
relation.attrib["Id"]: relation.attrib["Target"] for relation in rels_root
|
||||
}
|
||||
target = relation_targets.get(relation_id)
|
||||
if not target:
|
||||
raise ValueError(f"Excel 工作表文件关系缺失: {workbook_path}")
|
||||
sheet_path = target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join("xl", target))
|
||||
if sheet_path not in names:
|
||||
raise ValueError(f"Excel 工作表文件不存在: {sheet_path}")
|
||||
|
||||
sheet_root = ET.fromstring(xlsx.read(sheet_path))
|
||||
sparse_rows: list[dict[int, Any]] = []
|
||||
max_column = 0
|
||||
for row in sheet_root.findall("a:sheetData/a:row", _SHEET_NS):
|
||||
values: dict[int, Any] = {}
|
||||
for cell in row.findall("a:c", _SHEET_NS):
|
||||
column = _column_index(cell.attrib.get("r", ""))
|
||||
values[column] = _cell_value(cell, shared_strings)
|
||||
max_column = max(max_column, column)
|
||||
if values:
|
||||
sparse_rows.append(values)
|
||||
except (BadZipFile, KeyError, ET.ParseError) as exc:
|
||||
raise ValueError(f"无法解析 Excel 文件 {workbook_path}: {exc}") from exc
|
||||
|
||||
rows = [
|
||||
[row.get(column, "") for column in range(1, max_column + 1)]
|
||||
for row in sparse_rows
|
||||
]
|
||||
if not rows:
|
||||
raise ValueError(f"Excel 工作表为空: {workbook_path}")
|
||||
return sheet_name, rows
|
||||
|
||||
|
||||
def _header_index(headers: list[Any], candidates: tuple[str, ...]) -> int:
|
||||
normalized = [str(header).strip() for header in headers]
|
||||
for candidate in candidates:
|
||||
if candidate in normalized:
|
||||
return normalized.index(candidate)
|
||||
raise ValueError(f"Excel 缺少列: {'/'.join(candidates)}")
|
||||
|
||||
|
||||
def normalize_task_id(value: Any) -> str:
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
text = str(value).strip()
|
||||
return text[:-2] if text.endswith(".0") and text[:-2].isdigit() else text
|
||||
|
||||
|
||||
def _label(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def read_measurement_template(path: str | Path) -> dict[str, dict[str, str]]:
|
||||
"""读取任务号对应的项点名称与原始公差文本。"""
|
||||
_, rows = read_xlsx_rows(path)
|
||||
headers = rows[0]
|
||||
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
|
||||
name_column = _header_index(headers, ("项点名称", "项目名称", "项目"))
|
||||
tolerance_column = _header_index(headers, ("公差要求", "公差"))
|
||||
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for row in rows[1:]:
|
||||
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
|
||||
if not task_id:
|
||||
continue
|
||||
item_name = _label(row[name_column] if name_column < len(row) else "")
|
||||
if not item_name:
|
||||
raise ValueError(f"模板任务 {task_id} 缺少项点名称")
|
||||
result[task_id] = {
|
||||
"item_name": item_name,
|
||||
"tolerance_label": _label(row[tolerance_column] if tolerance_column < len(row) else ""),
|
||||
}
|
||||
if not result:
|
||||
raise ValueError(f"模板中没有测量任务: {path}")
|
||||
return result
|
||||
|
||||
|
||||
def read_legacy_batch_rows(path: str | Path) -> list[dict[str, Any]]:
|
||||
"""读取旧版技术汇总表中的任务号、平均值和状态。"""
|
||||
_, rows = read_xlsx_rows(path)
|
||||
headers = rows[0]
|
||||
task_column = _header_index(headers, ("任务号", "任务编号", "task_id"))
|
||||
mean_column = _header_index(headers, ("平均值", "mean", "mean_dist", "average_distance"))
|
||||
status_column = _header_index(headers, ("状态", "测量结论", "结论"))
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows[1:]:
|
||||
task_id = normalize_task_id(row[task_column] if task_column < len(row) else "")
|
||||
if not task_id:
|
||||
continue
|
||||
result.append({
|
||||
"task_id": task_id,
|
||||
"mean": row[mean_column] if mean_column < len(row) else None,
|
||||
"status": row[status_column] if status_column < len(row) else "",
|
||||
})
|
||||
if not result:
|
||||
raise ValueError(f"旧版 Excel 中没有批量结果: {path}")
|
||||
return result
|
||||
|
||||
|
||||
def _finite_float(value: Any) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
|
||||
|
||||
def build_measurements(
|
||||
rows: list[dict[str, Any]],
|
||||
template_path: str | Path,
|
||||
) -> list[dict[str, Any]]:
|
||||
template = read_measurement_template(template_path)
|
||||
status_fields = {
|
||||
"成功": ("pass", "成功"),
|
||||
"部分成功": ("warning", "部分成功"),
|
||||
"失败": ("fail", "失败"),
|
||||
}
|
||||
measurements: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
task_id = normalize_task_id(row.get("task_id", ""))
|
||||
if not task_id:
|
||||
raise ValueError("批量结果包含空任务号")
|
||||
template_row = template.get(task_id)
|
||||
if template_row is None:
|
||||
raise ValueError(f"模板中找不到任务 {task_id}")
|
||||
raw_status = str(row.get("status", "")).strip()
|
||||
if raw_status not in status_fields:
|
||||
raise ValueError(f"任务 {task_id} 的状态无效: {raw_status or '<空>'}")
|
||||
status, _ = status_fields[raw_status]
|
||||
measurements.append({
|
||||
"name": f"任务{task_id} · {template_row['item_name']}",
|
||||
"value": _finite_float(row.get("mean")),
|
||||
"unit": "mm",
|
||||
"toleranceLabel": template_row["tolerance_label"],
|
||||
"status": status,
|
||||
"passLabel": "成功",
|
||||
"warningLabel": "部分成功",
|
||||
"failLabel": "失败",
|
||||
})
|
||||
return measurements
|
||||
|
||||
|
||||
def measurements_from_batch_result(result: dict[str, Any], template_path: str | Path) -> list[dict[str, Any]]:
|
||||
rows = result.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("run_contour_batch 返回值缺少 rows")
|
||||
return build_measurements(rows, template_path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="从旧版 flatness-suite Excel 构造通用 measurements")
|
||||
parser.add_argument("--excel", required=True)
|
||||
parser.add_argument("--template", required=True)
|
||||
args = parser.parse_args()
|
||||
measurements = build_measurements(read_legacy_batch_rows(args.excel), args.template)
|
||||
print(json.dumps(measurements, ensure_ascii=False, allow_nan=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -31,6 +31,7 @@ from _plugin_io import (
|
||||
write_error_result,
|
||||
write_result,
|
||||
)
|
||||
from flatness_suite_measurements import measurements_from_batch_result
|
||||
|
||||
|
||||
def _safe_float(v: object) -> float | None:
|
||||
@@ -40,6 +41,25 @@ def _safe_float(v: object) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _config_bool(config: dict, key: str, default: bool = False) -> bool:
|
||||
value = config.get(key, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _cleanup_temporary_artifacts(out_dir: Path) -> None:
|
||||
# 仅删除适配器自己创建、且不会写入 result.json 的固定临时文件。
|
||||
# 不使用通配符,避免误删算法结果或用户要求保留的产物。
|
||||
for name in ("_roi_cropped.ply",):
|
||||
try:
|
||||
(out_dir / name).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
print(f"[flatness-suite] 清理临时文件失败 {name}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def _mesh_cache_path(cad_path: str, mesh_deflection: float) -> Path:
|
||||
"""根据 STEP 路径 + 文件指纹 + 三角化精度生成缓存路径。
|
||||
|
||||
@@ -191,12 +211,21 @@ def main() -> int:
|
||||
if use_batch_mode and module == "contour":
|
||||
emit_progress(0.20, "批量模式:从 datasets/Noise_PointCloud/ 读取所有面板点云")
|
||||
data_root = alg_dir / "datasets"
|
||||
# 所选整车点云作为可视化背景;用户也可在 contextPointCloudPath 里手动指定
|
||||
context_pc = (inputs.get("contextPointCloudPath")
|
||||
# combined_contour.ply 可能包含完整整车点云,单次可达十几 GB。
|
||||
# 平台页面只使用各面板 *_contour.ply,因此默认从源头跳过组合文件;
|
||||
# 仅在用户显式开启时才生成,并作为可下载产物登记。
|
||||
keep_combined_contour = _config_bool(config, "keepCombinedContour", False)
|
||||
context_pc = None
|
||||
if keep_combined_contour:
|
||||
context_pc = (config.get("contextPointCloudPath")
|
||||
or inputs.get("contextPointCloudPath")
|
||||
or pc_path_raw
|
||||
or None)
|
||||
save_dir = str(out_dir / "contour_batch")
|
||||
excel_path = str(out_dir / "contour.xlsx")
|
||||
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
||||
if not result_template_path.is_file():
|
||||
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
|
||||
res = pipeline.run_contour_batch(
|
||||
all_tasks=True,
|
||||
data_root=str(data_root),
|
||||
@@ -205,20 +234,13 @@ def main() -> int:
|
||||
triangle_chunk_size=512,
|
||||
save_path=save_dir,
|
||||
excel_output_path=excel_path,
|
||||
result_template_path=str(result_template_path),
|
||||
combined_context_pointcloud_path=context_pc,
|
||||
save_combined_visualization=keep_combined_contour,
|
||||
no_visualize=True,
|
||||
)
|
||||
emit_progress(0.95, "整理批量结果")
|
||||
batch_stats = res.get("batch_stats") or {}
|
||||
measurements: list[dict] = []
|
||||
for task_id, task_res in (res.get("results") or {}).items():
|
||||
s = task_res.get("distance_stats") or {}
|
||||
if s:
|
||||
measurements.append({
|
||||
"name": f"任务{task_id} 轮廓偏差均值",
|
||||
"value": round(float(s.get("mean", 0)), 4),
|
||||
"unit": "mm", "status": "info",
|
||||
})
|
||||
measurements = measurements_from_batch_result(res, result_template_path)
|
||||
# 各面板热力图 PLY 列表(不含背景点,全部是有意义的颜色)
|
||||
panel_plys = sorted([
|
||||
f"contour_batch/{p.name}"
|
||||
@@ -229,12 +251,19 @@ def main() -> int:
|
||||
overlay3d = {"kind": "ply", "file": panel_plys[0], "files": panel_plys}
|
||||
else:
|
||||
overlay3d = {"kind": "none"}
|
||||
combined_rel = "contour_batch/combined_contour.ply"
|
||||
result_files = [combined_rel] if keep_combined_contour and (out_dir / combined_rel).is_file() else []
|
||||
write_result(out_dir, {
|
||||
"status": "completed",
|
||||
"message": f"批量完成 {len(res.get('results', {}))} 个面板测量",
|
||||
"message": f"批量完成 {len(res.get('rows') or [])} 个面板测量",
|
||||
"measurements": measurements,
|
||||
"overlay3d": overlay3d,
|
||||
"extra": {"module": "contour_batch", "excelPath": "contour.xlsx"},
|
||||
"files": result_files,
|
||||
"extra": {
|
||||
"module": "contour_batch",
|
||||
"excelPath": "contour.xlsx",
|
||||
"combinedContourKept": bool(result_files),
|
||||
},
|
||||
})
|
||||
return 0
|
||||
|
||||
@@ -430,6 +459,8 @@ def main() -> int:
|
||||
extra={"traceback": traceback.format_exc()},
|
||||
)
|
||||
return 1
|
||||
finally:
|
||||
_cleanup_temporary_artifacts(out_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
96
server/plugins/adapters/test_flatness_suite_retention.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import TestCase
|
||||
|
||||
from flatness_suite_run import _cleanup_temporary_artifacts, _config_bool
|
||||
from flatness_suite_measurements import (
|
||||
measurements_from_batch_result,
|
||||
read_legacy_batch_rows,
|
||||
)
|
||||
|
||||
|
||||
TEMPLATE = (
|
||||
Path(__file__).parents[3]
|
||||
/ "alg"
|
||||
/ "平整度、平面度、轮廓测量v1.5"
|
||||
/ "excel"
|
||||
/ "测量结果.xlsx"
|
||||
)
|
||||
|
||||
|
||||
class FlatnessSuiteRetentionConfigTest(TestCase):
|
||||
def test_combined_contour_defaults_to_disabled(self) -> None:
|
||||
self.assertFalse(_config_bool({}, "keepCombinedContour", False))
|
||||
|
||||
def test_explicit_boolean_or_string_can_enable_retention(self) -> None:
|
||||
self.assertTrue(_config_bool({"keepCombinedContour": True}, "keepCombinedContour"))
|
||||
self.assertTrue(_config_bool({"keepCombinedContour": "true"}, "keepCombinedContour"))
|
||||
self.assertFalse(_config_bool({"keepCombinedContour": "false"}, "keepCombinedContour"))
|
||||
|
||||
def test_cleanup_removes_only_known_temporary_artifacts(self) -> None:
|
||||
with TemporaryDirectory() as temp:
|
||||
out_dir = Path(temp)
|
||||
temporary = out_dir / "_roi_cropped.ply"
|
||||
result = out_dir / "contour.ply"
|
||||
temporary.touch()
|
||||
result.touch()
|
||||
|
||||
_cleanup_temporary_artifacts(out_dir)
|
||||
|
||||
self.assertFalse(temporary.exists())
|
||||
self.assertTrue(result.exists())
|
||||
|
||||
|
||||
class FlatnessSuiteBatchMeasurementTest(TestCase):
|
||||
def test_uses_top_level_rows_instead_of_nested_sample_results(self) -> None:
|
||||
result = {
|
||||
"rows": [{"task_id": "11", "mean": 1.25, "status": "成功"}],
|
||||
"results": {
|
||||
"11": {
|
||||
"11": {"distance_stats": {"mean": 999}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
measurements = measurements_from_batch_result(result, TEMPLATE)
|
||||
|
||||
self.assertEqual(len(measurements), 1)
|
||||
self.assertEqual(measurements[0]["name"], "任务11 · 车钩面板的平整度")
|
||||
self.assertEqual(measurements[0]["value"], 1.25)
|
||||
self.assertEqual(measurements[0]["toleranceLabel"], "0.5mm")
|
||||
|
||||
def test_maps_success_partial_success_failure_and_empty_value(self) -> None:
|
||||
result = {
|
||||
"rows": [
|
||||
{"task_id": 11, "mean": 0.1, "status": "成功"},
|
||||
{"task_id": 13, "mean": 0.2, "status": "部分成功"},
|
||||
{"task_id": 17, "mean": None, "status": "失败"},
|
||||
],
|
||||
"results": {},
|
||||
}
|
||||
|
||||
measurements = measurements_from_batch_result(result, TEMPLATE)
|
||||
|
||||
self.assertEqual([item["status"] for item in measurements], ["pass", "warning", "fail"])
|
||||
self.assertEqual(measurements[0]["passLabel"], "成功")
|
||||
self.assertEqual(measurements[1]["warningLabel"], "部分成功")
|
||||
self.assertEqual(measurements[2]["failLabel"], "失败")
|
||||
self.assertIsNone(measurements[2]["value"])
|
||||
|
||||
def test_missing_template_has_explicit_error(self) -> None:
|
||||
with self.assertRaisesRegex(FileNotFoundError, "Excel 文件不存在"):
|
||||
measurements_from_batch_result(
|
||||
{"rows": [{"task_id": 11, "mean": 0.1, "status": "成功"}]},
|
||||
Path("missing-template.xlsx"),
|
||||
)
|
||||
|
||||
def test_new_template_is_not_misread_as_legacy_summary(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "平均值"):
|
||||
read_legacy_batch_rows(TEMPLATE)
|
||||
|
||||
def test_malformed_excel_has_explicit_error(self) -> None:
|
||||
with TemporaryDirectory() as temp:
|
||||
invalid = Path(temp) / "contour.xlsx"
|
||||
invalid.write_text("not an xlsx", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "无法解析 Excel"):
|
||||
read_legacy_batch_rows(invalid)
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "平整度 / 平面度 / 轮廓测量",
|
||||
"shortName": "flatness-suite",
|
||||
"description": "平整度 / 平面度 / 轮廓测量套件(v1.5)。两种模式:① 不选点云文件 → 批量模式,自动读取 alg/datasets/Noise_PointCloud/ 下全部面板点云(对应算法方标准测试流程);② 选择点云文件 → 单点云模式,对所选点云做 contour/plane/curvature 测量。",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"schemaVersion": 1,
|
||||
"category": "几何测量",
|
||||
"requiresPointCloud": false,
|
||||
@@ -15,6 +15,13 @@
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "keepCombinedContour",
|
||||
"label": "保留组合轮廓点云(可能占用十几 GB)",
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "默认关闭:页面使用各面板热力点云,不需要组合文件。仅在需要离线下载包含整车背景的 combined_contour.ply 时开启。"
|
||||
},
|
||||
{
|
||||
"name": "module",
|
||||
"label": "测量模块",
|
||||
@@ -47,10 +54,10 @@
|
||||
{ "name": "roiZMax", "label": "ROI Z 最大值 (mm)", "type": "number", "default": null },
|
||||
{
|
||||
"name": "contextPointCloudPath",
|
||||
"label": "整车背景点云路径(可视化合并用)",
|
||||
"label": "整车背景点云路径(仅保留组合轮廓点云时使用)",
|
||||
"type": "string",
|
||||
"default": null,
|
||||
"description": "可选。计算结果热力图将叠加到此点云上生成合并可视化文件。填整车配准后的完整点云路径(容器内路径),不填则只输出局部热力图。"
|
||||
"description": "仅开启“保留组合轮廓点云”时生效。可填写容器内完整点云路径;留空时使用本次选择的点云作为背景。"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
|
||||
@@ -106,11 +106,17 @@ export interface PluginProgressEvent {
|
||||
/** 测量项(GenericResultView 渲染) */
|
||||
export interface PluginMeasurement {
|
||||
name: string;
|
||||
value: number;
|
||||
/** 失败任务没有有效测量值时为 null,前端显示破折号。 */
|
||||
value: number | null;
|
||||
unit?: string;
|
||||
tolerance?: number;
|
||||
/** 无法安全数值化的原始公差表达式。 */
|
||||
toleranceLabel?: string;
|
||||
/** pass / warning / fail / info */
|
||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||
passLabel?: string;
|
||||
warningLabel?: string;
|
||||
failLabel?: string;
|
||||
}
|
||||
|
||||
/** 误差统计卡片(与原 task_analysis_results.errorStats 兼容) */
|
||||
|
||||
125
server/pointcloudMetadata.test.ts
Normal file
125
server/pointcloudMetadata.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectPointcloudMetadata,
|
||||
MAX_PLY_HEADER_BYTES,
|
||||
parsePlyHeader,
|
||||
PointcloudMetadataParseError,
|
||||
} from "./pointcloudMetadata";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (temporaryDirectories.length > 0) {
|
||||
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function header(lines: string[], newline = "\n") {
|
||||
return Buffer.from([...lines, "end_header", ""].join(newline), "ascii");
|
||||
}
|
||||
|
||||
describe("parsePlyHeader", () => {
|
||||
it("解析 ASCII PLY,并以 element vertex 作为精确点数", () => {
|
||||
const result = parsePlyHeader(header([
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
"element vertex 1000000000",
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property double z",
|
||||
"element face 999",
|
||||
"property list uchar int vertex_indices",
|
||||
]));
|
||||
|
||||
expect(result).toEqual({
|
||||
pointCount: 1_000_000_000,
|
||||
pointDataFormat: "ascii",
|
||||
bytesPerPoint: 16,
|
||||
pointAttributes: [
|
||||
{ name: "x", type: "float32", bytes: 4 },
|
||||
{ name: "y", type: "float32", bytes: 4 },
|
||||
{ name: "z", type: "float64", bytes: 8 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["binary_little_endian", "binary_little_endian"],
|
||||
["binary_big_endian", "binary_big_endian"],
|
||||
] as const)("解析 %s PLY", (format, expected) => {
|
||||
const result = parsePlyHeader(header([
|
||||
"ply",
|
||||
`format ${format} 1.0`,
|
||||
"element vertex 42",
|
||||
"property int16 x",
|
||||
"property ushort intensity",
|
||||
"property uchar red",
|
||||
]));
|
||||
expect(result.pointDataFormat).toBe(expected);
|
||||
expect(result.bytesPerPoint).toBe(5);
|
||||
});
|
||||
|
||||
it("支持 CRLF 文件头和类型别名", () => {
|
||||
const result = parsePlyHeader(header([
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
"comment generated",
|
||||
"element vertex 3",
|
||||
"property char classification",
|
||||
"property uint confidence",
|
||||
"property float32 temperature",
|
||||
], "\r\n"));
|
||||
expect(result.pointAttributes).toEqual([
|
||||
{ name: "classification", type: "int8", bytes: 1 },
|
||||
{ name: "confidence", type: "uint32", bytes: 4 },
|
||||
{ name: "temperature", type: "float32", bytes: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("拒绝无效文件头", () => {
|
||||
expect(() => parsePlyHeader(Buffer.from("not-ply\n"))).toThrow(PointcloudMetadataParseError);
|
||||
});
|
||||
|
||||
it("拒绝未知的顶点属性类型", () => {
|
||||
expect(() => parsePlyHeader(header([
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
"element vertex 1",
|
||||
"property half x",
|
||||
]))).toThrow("未知的顶点属性类型");
|
||||
});
|
||||
|
||||
it("拒绝列表型顶点属性,但忽略其他 element 的列表属性", () => {
|
||||
expect(() => parsePlyHeader(header([
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
"element vertex 1",
|
||||
"property list uchar float samples",
|
||||
]))).toThrow("列表型顶点属性");
|
||||
});
|
||||
|
||||
it("非 PLY 格式直接标记为 unsupported,不读取文件", () => {
|
||||
expect(inspectPointcloudMetadata("/path/does/not/exist.txt", "cloud.txt")).toEqual({
|
||||
metadataStatus: "unsupported",
|
||||
pointCount: null,
|
||||
pointAttributes: null,
|
||||
pointDataFormat: null,
|
||||
bytesPerPoint: null,
|
||||
metadataError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("只读取 16KB 文件头,超长文件头标记为解析失败", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "pointcloud-metadata-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const filePath = join(directory, "oversized.ply");
|
||||
writeFileSync(filePath, `ply\nformat ascii 1.0\ncomment ${"x".repeat(MAX_PLY_HEADER_BYTES)}\nend_header\n`);
|
||||
|
||||
const result = inspectPointcloudMetadata(filePath, "oversized.ply");
|
||||
expect(result.metadataStatus).toBe("error");
|
||||
expect(result.metadataError).toContain("16KB");
|
||||
});
|
||||
});
|
||||
153
server/pointcloudMetadata.ts
Normal file
153
server/pointcloudMetadata.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
PointAttribute,
|
||||
PointcloudDataFormat,
|
||||
PointcloudMetadataValues,
|
||||
} from "../shared/pointcloudMetadata";
|
||||
|
||||
export const MAX_PLY_HEADER_BYTES = 16 * 1024;
|
||||
|
||||
type ScalarType = { type: string; bytes: number };
|
||||
|
||||
const PLY_SCALAR_TYPES: Record<string, ScalarType> = {
|
||||
char: { type: "int8", bytes: 1 },
|
||||
int8: { type: "int8", bytes: 1 },
|
||||
uchar: { type: "uint8", bytes: 1 },
|
||||
uint8: { type: "uint8", bytes: 1 },
|
||||
short: { type: "int16", bytes: 2 },
|
||||
int16: { type: "int16", bytes: 2 },
|
||||
ushort: { type: "uint16", bytes: 2 },
|
||||
uint16: { type: "uint16", bytes: 2 },
|
||||
int: { type: "int32", bytes: 4 },
|
||||
int32: { type: "int32", bytes: 4 },
|
||||
uint: { type: "uint32", bytes: 4 },
|
||||
uint32: { type: "uint32", bytes: 4 },
|
||||
float: { type: "float32", bytes: 4 },
|
||||
float32: { type: "float32", bytes: 4 },
|
||||
double: { type: "float64", bytes: 8 },
|
||||
float64: { type: "float64", bytes: 8 },
|
||||
};
|
||||
|
||||
export class PointcloudMetadataParseError extends Error {}
|
||||
|
||||
export type ParsedPlyMetadata = {
|
||||
pointCount: number;
|
||||
pointAttributes: PointAttribute[];
|
||||
pointDataFormat: PointcloudDataFormat;
|
||||
bytesPerPoint: number;
|
||||
};
|
||||
|
||||
/** 解析内存中的 PLY 文件头。调用方应保证输入不超过 MAX_PLY_HEADER_BYTES。 */
|
||||
export function parsePlyHeader(buffer: Buffer): ParsedPlyMetadata {
|
||||
const text = buffer.toString("latin1");
|
||||
const endMatch = /(?:^|\r?\n)end_header(?:\r?\n|$)/.exec(text);
|
||||
if (!endMatch) {
|
||||
throw new PointcloudMetadataParseError("未在 16KB 内找到 PLY 文件头结束标记");
|
||||
}
|
||||
|
||||
const headerEnd = endMatch.index + endMatch[0].length;
|
||||
const lines = text.slice(0, headerEnd).split(/\r?\n/).map((line) => line.trim());
|
||||
if (lines[0] !== "ply") {
|
||||
throw new PointcloudMetadataParseError("不是有效的 PLY 文件");
|
||||
}
|
||||
|
||||
let pointDataFormat: PointcloudDataFormat | null = null;
|
||||
let pointCount: number | null = null;
|
||||
let currentElement: string | null = null;
|
||||
const pointAttributes: PointAttribute[] = [];
|
||||
|
||||
for (const line of lines.slice(1)) {
|
||||
if (!line || line === "end_header" || line.startsWith("comment ") || line.startsWith("obj_info ")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = line.split(/\s+/);
|
||||
if (tokens[0] === "format") {
|
||||
const value = tokens[1];
|
||||
if (value !== "ascii" && value !== "binary_little_endian" && value !== "binary_big_endian") {
|
||||
throw new PointcloudMetadataParseError("不支持的 PLY 编码");
|
||||
}
|
||||
pointDataFormat = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokens[0] === "element") {
|
||||
currentElement = tokens[1] ?? null;
|
||||
if (currentElement === "vertex") {
|
||||
const count = Number(tokens[2]);
|
||||
if (!Number.isSafeInteger(count) || count < 0) {
|
||||
throw new PointcloudMetadataParseError("PLY 顶点数无效");
|
||||
}
|
||||
pointCount = count;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokens[0] === "property" && currentElement === "vertex") {
|
||||
if (tokens[1] === "list") {
|
||||
throw new PointcloudMetadataParseError("暂不支持列表型顶点属性");
|
||||
}
|
||||
const scalar = PLY_SCALAR_TYPES[tokens[1]];
|
||||
const name = tokens[2];
|
||||
if (!scalar || !name) {
|
||||
throw new PointcloudMetadataParseError("PLY 包含未知的顶点属性类型");
|
||||
}
|
||||
pointAttributes.push({ name, type: scalar.type, bytes: scalar.bytes });
|
||||
}
|
||||
}
|
||||
|
||||
if (!pointDataFormat) {
|
||||
throw new PointcloudMetadataParseError("PLY 文件头缺少编码信息");
|
||||
}
|
||||
if (pointCount === null) {
|
||||
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点数量");
|
||||
}
|
||||
if (pointAttributes.length === 0) {
|
||||
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点属性");
|
||||
}
|
||||
|
||||
return {
|
||||
pointCount,
|
||||
pointAttributes,
|
||||
pointDataFormat,
|
||||
bytesPerPoint: pointAttributes.reduce((sum, attribute) => sum + attribute.bytes, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectPointcloudMetadata(filePath: string, displayName = filePath): PointcloudMetadataValues {
|
||||
const extension = path.extname(displayName || filePath).toLowerCase();
|
||||
if (extension !== ".ply") {
|
||||
return {
|
||||
metadataStatus: "unsupported",
|
||||
pointCount: null,
|
||||
pointAttributes: null,
|
||||
pointDataFormat: null,
|
||||
bytesPerPoint: null,
|
||||
metadataError: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.allocUnsafe(MAX_PLY_HEADER_BYTES);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||
const parsed = parsePlyHeader(buffer.subarray(0, bytesRead));
|
||||
return { metadataStatus: "ready", ...parsed, metadataError: null };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
metadataStatus: "error",
|
||||
pointCount: null,
|
||||
pointAttributes: null,
|
||||
pointDataFormat: null,
|
||||
bytesPerPoint: null,
|
||||
metadataError: error instanceof PointcloudMetadataParseError
|
||||
? error.message
|
||||
: "PLY 文件头解析失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
181
server/pointcloudRename.test.ts
Normal file
181
server/pointcloudRename.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { User } from "../drizzle/schema";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import { appRouter } from "./routers";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getTaskPointcloudFileById: vi.fn(),
|
||||
findTaskPointcloudFileByTaskAndName: vi.fn(),
|
||||
updateTaskPointcloudFileName: vi.fn(),
|
||||
getModelPointcloudFileById: vi.fn(),
|
||||
findModelPointcloudFileByModelAndName: vi.fn(),
|
||||
updateModelPointcloudFileName: vi.fn(),
|
||||
}));
|
||||
|
||||
const now = new Date("2026-07-21T00:00:00Z");
|
||||
const taskPointcloud = {
|
||||
id: 11,
|
||||
taskId: 101,
|
||||
name: "原始点云.ply",
|
||||
fileSize: 123,
|
||||
filePath: "/data/tasks/101/physical-file.PLY",
|
||||
source: "uploaded",
|
||||
sourceResultId: null,
|
||||
sourcePointcloudFileId: null,
|
||||
sourceModelPointcloudId: null,
|
||||
createdAt: now,
|
||||
};
|
||||
const modelPointcloud = {
|
||||
id: 22,
|
||||
modelId: 202,
|
||||
name: "车型点云.txt",
|
||||
fileSize: 456,
|
||||
filePath: "/data/models/202/physical-file.txt",
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
function user(role: User["role"]): User {
|
||||
return {
|
||||
id: 1,
|
||||
username: `${role}-account`,
|
||||
passwordHash: "unused",
|
||||
name: role,
|
||||
email: null,
|
||||
role,
|
||||
isActive: 1,
|
||||
mustChangePassword: 0,
|
||||
sessionVersion: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function caller(role: User["role"] = "admin") {
|
||||
const context: TrpcContext = {
|
||||
user: user(role),
|
||||
req: {} as TrpcContext["req"],
|
||||
res: {} as TrpcContext["res"],
|
||||
};
|
||||
return appRouter.createCaller(context);
|
||||
}
|
||||
|
||||
describe("点云重命名路由", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValue(taskPointcloud);
|
||||
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValue(undefined);
|
||||
vi.mocked(db.updateTaskPointcloudFileName).mockImplementation(async (id, name) => ({
|
||||
...taskPointcloud,
|
||||
id,
|
||||
name,
|
||||
}));
|
||||
vi.mocked(db.getModelPointcloudFileById).mockResolvedValue(modelPointcloud);
|
||||
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValue(undefined);
|
||||
vi.mocked(db.updateModelPointcloudFileName).mockImplementation(async (id, name) => ({
|
||||
...modelPointcloud,
|
||||
id,
|
||||
name,
|
||||
}));
|
||||
});
|
||||
|
||||
it("任务点云去除首尾空格并按实际路径保留扩展名", async () => {
|
||||
const result = await caller().pointcloudFile.rename({ id: 11, baseName: " 新名称 " });
|
||||
|
||||
expect(result?.name).toBe("新名称.PLY");
|
||||
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "新名称.PLY", 11);
|
||||
expect(db.updateTaskPointcloudFileName).toHaveBeenCalledWith(11, "新名称.PLY");
|
||||
});
|
||||
|
||||
it("车型库点云按 modelId 检查重名并返回更新记录", async () => {
|
||||
const result = await caller("super_admin").modelPointcloudFile.rename({ id: 22, baseName: "扫描结果" });
|
||||
|
||||
expect(result?.name).toBe("扫描结果.txt");
|
||||
expect(db.findModelPointcloudFileByModelAndName).toHaveBeenCalledWith(202, "扫描结果.txt", 22);
|
||||
expect(db.updateModelPointcloudFileName).toHaveBeenCalledWith(22, "扫描结果.txt");
|
||||
});
|
||||
|
||||
it.each(["", " "])("拒绝空名称 %#", async (baseName) => {
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "点云名称不能为空",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["目录/名称", "目录\\名称", "名称\n换行"])("拒绝非法名称 %s", async (baseName) => {
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
});
|
||||
|
||||
it("拒绝拼接扩展名后超过 255 个字符的名称", async () => {
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "名".repeat(252) })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "点云名称不能超过 255 个字符(含扩展名)",
|
||||
});
|
||||
});
|
||||
|
||||
it("同一任务中忽略英文大小写禁止重名", async () => {
|
||||
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValueOnce({
|
||||
...taskPointcloud,
|
||||
id: 12,
|
||||
name: "NEW-NAME.ply",
|
||||
});
|
||||
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "new-name" })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "当前任务下已存在同名点云",
|
||||
});
|
||||
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("同一车型中忽略英文大小写禁止重名", async () => {
|
||||
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValueOnce({
|
||||
...modelPointcloud,
|
||||
id: 23,
|
||||
name: "SCAN.txt",
|
||||
});
|
||||
|
||||
await expect(caller().modelPointcloudFile.rename({ id: 22, baseName: "scan" })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "当前车型下已存在同名点云",
|
||||
});
|
||||
});
|
||||
|
||||
it("记录不存在时返回 NOT_FOUND", async () => {
|
||||
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(undefined);
|
||||
vi.mocked(db.getModelPointcloudFileById).mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(caller().pointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
await expect(caller().modelPointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("名称未变化时直接成功且不查询重名、不更新", async () => {
|
||||
const unchanged = { ...taskPointcloud, name: "原始点云.PLY" };
|
||||
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(unchanged);
|
||||
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toEqual(unchanged);
|
||||
expect(db.findTaskPointcloudFileByTaskAndName).not.toHaveBeenCalled();
|
||||
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("名称只改变大小写时排除自身后允许更新", async () => {
|
||||
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toMatchObject({
|
||||
name: "原始点云.PLY",
|
||||
});
|
||||
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "原始点云.PLY", 11);
|
||||
});
|
||||
|
||||
it("普通用户无权调用,管理员和超级管理员可以调用", async () => {
|
||||
await expect(caller("user").pointcloudFile.rename({ id: 11, baseName: "无权修改" })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
await expect(caller("admin").pointcloudFile.rename({ id: 11, baseName: "管理员修改" })).resolves.toMatchObject({
|
||||
name: "管理员修改.PLY",
|
||||
});
|
||||
await expect(caller("super_admin").pointcloudFile.rename({ id: 11, baseName: "超管修改" })).resolves.toMatchObject({
|
||||
name: "超管修改.PLY",
|
||||
});
|
||||
});
|
||||
});
|
||||
60
server/pointcloudSample.test.ts
Normal file
60
server/pointcloudSample.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export type SampledPoint = { x: number; y: number; z: number; error: number; r?: number; g?: number; b?: number };
|
||||
export type SampledPoint = { x: number; y: number; z: number; error?: number; r?: number; g?: number; b?: number };
|
||||
|
||||
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
|
||||
export async function randomSeekSample(
|
||||
@@ -66,9 +66,10 @@ export async function randomSeekSample(
|
||||
const y = parseFloat(parts[1]);
|
||||
const z = parseFloat(parts[2]);
|
||||
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
||||
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
||||
if (isNaN(error)) error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5;
|
||||
points.push({ x, y, z, error });
|
||||
const parsedError = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
||||
const point: SampledPoint = { x, y, z };
|
||||
if (Number.isFinite(parsedError)) point.error = parsedError;
|
||||
points.push(point);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
@@ -173,8 +174,10 @@ function samplePlyBinary(
|
||||
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
|
||||
let xOff = -1, yOff = -1, zOff = -1;
|
||||
let rOff = -1, gOff = -1, bOff = -1;
|
||||
let errorOff = -1;
|
||||
let xType = 'float', yType = 'float', zType = 'float';
|
||||
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
|
||||
let errorType = 'float';
|
||||
let byteOff = 0;
|
||||
for (const p of properties) {
|
||||
const n = p.name.toLowerCase();
|
||||
@@ -184,6 +187,7 @@ function samplePlyBinary(
|
||||
else if (n === 'red') { rOff = byteOff; rType = p.type; }
|
||||
else if (n === 'green') { gOff = byteOff; gType = p.type; }
|
||||
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
|
||||
else if (n === 'error' || n === 'deviation') { errorOff = byteOff; errorType = p.type; }
|
||||
byteOff += p.byteSize;
|
||||
}
|
||||
|
||||
@@ -228,8 +232,11 @@ function samplePlyBinary(
|
||||
const y = readNum(recBuf, yOff, yType);
|
||||
const z = readNum(recBuf, zOff, zType);
|
||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
||||
const pt: SampledPoint = { x, y, z, error };
|
||||
const pt: SampledPoint = { x, y, z };
|
||||
if (errorOff >= 0) {
|
||||
const error = readNum(recBuf, errorOff, errorType);
|
||||
if (Number.isFinite(error)) pt.error = error;
|
||||
}
|
||||
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
|
||||
pt.r = readNum(recBuf, rOff, rType);
|
||||
pt.g = readNum(recBuf, gOff, gType);
|
||||
@@ -289,18 +296,22 @@ function samplePlyAscii(
|
||||
if (!line) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
|
||||
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
|
||||
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1, errorI = -1;
|
||||
header.properties.forEach((p, i) => {
|
||||
const n = p.name.toLowerCase();
|
||||
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
|
||||
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
|
||||
else if (n === 'error' || n === 'deviation') errorI = i;
|
||||
});
|
||||
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
|
||||
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
|
||||
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
|
||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
||||
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
|
||||
const pt: SampledPoint = { x, y, z, error };
|
||||
const pt: SampledPoint = { x, y, z };
|
||||
if (errorI >= 0) {
|
||||
const error = parseFloat(parts[errorI]);
|
||||
if (Number.isFinite(error)) pt.error = error;
|
||||
}
|
||||
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
|
||||
pt.r = parseFloat(parts[ri]);
|
||||
pt.g = parseFloat(parts[gi]);
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
|
||||
// Mock database functions
|
||||
vi.mock("./db", () => ({
|
||||
createProject: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: "Test Project",
|
||||
description: "Test Description",
|
||||
status: "uploading",
|
||||
totalPoints: 0,
|
||||
totalChunks: 10,
|
||||
uploadedChunks: 0,
|
||||
totalSize: 0,
|
||||
lodLevels: 0,
|
||||
boundingBox: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
getProjectsByUserId: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: "Test Project",
|
||||
description: "Test Description",
|
||||
status: "ready",
|
||||
totalPoints: 1000000,
|
||||
totalChunks: 10,
|
||||
uploadedChunks: 10,
|
||||
totalSize: 32000000,
|
||||
lodLevels: 8,
|
||||
boundingBox: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getProjectById: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: "Test Project",
|
||||
description: "Test Description",
|
||||
status: "ready",
|
||||
totalPoints: 1000000,
|
||||
totalChunks: 10,
|
||||
uploadedChunks: 10,
|
||||
totalSize: 32000000,
|
||||
lodLevels: 8,
|
||||
boundingBox: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
updateProject: vi.fn().mockResolvedValue(undefined),
|
||||
createAnalysisResult: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
cadModelId: null,
|
||||
type: "error_analysis",
|
||||
status: "pending",
|
||||
resultData: null,
|
||||
errorStats: null,
|
||||
measurements: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
updateAnalysisResult: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
function createAuthContext(): TrpcContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: 1,
|
||||
openId: "test-user",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
loginMethod: "manus",
|
||||
role: "user",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
} as TrpcContext["req"],
|
||||
res: {
|
||||
clearCookie: vi.fn(),
|
||||
} as unknown as TrpcContext["res"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("project router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a new project", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.project.create({
|
||||
name: "Test Project",
|
||||
description: "Test Description",
|
||||
totalChunks: 10,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe("Test Project");
|
||||
expect(result.status).toBe("uploading");
|
||||
});
|
||||
|
||||
it("lists user projects", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.project.list();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].name).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("gets a project by id", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.project.get({ id: 1 });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(1);
|
||||
expect(result?.name).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("updates a project", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.project.update({
|
||||
id: 1,
|
||||
name: "Updated Project",
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates an analysis result", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.analysis.create({
|
||||
projectId: 1,
|
||||
type: "error_analysis",
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.type).toBe("error_analysis");
|
||||
expect(result.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("runs error analysis simulation", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.analysis.runErrorAnalysis({
|
||||
analysisId: 1,
|
||||
projectId: 1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errorStats).toBeDefined();
|
||||
expect(result.errorStats.min).toBeDefined();
|
||||
expect(result.errorStats.max).toBeDefined();
|
||||
expect(result.errorStats.mean).toBeDefined();
|
||||
});
|
||||
|
||||
it("runs measurement simulation", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.analysis.runMeasurement({
|
||||
analysisId: 1,
|
||||
projectId: 1,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.measurements).toBeDefined();
|
||||
expect(Array.isArray(result.measurements)).toBe(true);
|
||||
expect(result.measurements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("runs compression simulation", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.analysis.runCompression({
|
||||
analysisId: 1,
|
||||
projectId: 1,
|
||||
compressionRatio: 0.3,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.resultData).toBeDefined();
|
||||
expect(result.resultData.compressionRatio).toBe(0.3);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||
import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import * as fs from "fs";
|
||||
@@ -15,10 +15,37 @@ import { stepParseCacheDir } from "./stepParse.js";
|
||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.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 TASKS_DIR = path.join(DATA_DIR, "tasks");
|
||||
|
||||
function pointcloudDisplayName(baseNameInput: string, filePath: string): string {
|
||||
const baseName = baseNameInput.trim();
|
||||
if (!baseName) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" });
|
||||
}
|
||||
if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" });
|
||||
}
|
||||
|
||||
const name = `${baseName}${path.extname(filePath)}`;
|
||||
if (Array.from(name).length > 255) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||
"train-measurement",
|
||||
"coupler-panel",
|
||||
@@ -306,12 +333,164 @@ export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
login: publicProcedure
|
||||
.input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const username = input.username.trim();
|
||||
const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown";
|
||||
if (isLoginRateLimited(ip, username)) {
|
||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" });
|
||||
}
|
||||
const user = await db.getUserByUsername(username);
|
||||
const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null));
|
||||
if (!user || !valid) {
|
||||
recordLoginFailure(ip, username);
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" });
|
||||
}
|
||||
clearLoginFailures(ip, username);
|
||||
await db.updateUser(user.id, { lastLoginAt: new Date() });
|
||||
const token = await createSessionToken(user);
|
||||
ctx.res.cookie(COOKIE_NAME, token, {
|
||||
...getSessionCookieOptions(ctx.req),
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
return publicUser({ ...user, lastLoginAt: new Date() });
|
||||
}),
|
||||
me: sessionProcedure.query(opts => publicUser(opts.ctx.user)),
|
||||
logout: publicProcedure.mutation(({ ctx }) => {
|
||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||
return { success: true } as const;
|
||||
}),
|
||||
changePassword: sessionProcedure
|
||||
.input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" });
|
||||
}
|
||||
if (input.currentPassword === input.newPassword) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" });
|
||||
}
|
||||
validatePassword(input.newPassword);
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
const nextVersion = ctx.user.sessionVersion + 1;
|
||||
await db.updateUser(ctx.user.id, {
|
||||
passwordHash,
|
||||
mustChangePassword: 0,
|
||||
sessionVersion: nextVersion,
|
||||
});
|
||||
const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion });
|
||||
ctx.res.cookie(COOKIE_NAME, token, {
|
||||
...getSessionCookieOptions(ctx.req),
|
||||
maxAge: SESSION_TTL_MS,
|
||||
});
|
||||
return { success: true } as const;
|
||||
}),
|
||||
}),
|
||||
|
||||
users: router({
|
||||
list: superAdminProcedure
|
||||
.input(z.object({ search: z.string().max(100).optional() }).optional())
|
||||
.query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)),
|
||||
create: superAdminProcedure
|
||||
.input(z.object({
|
||||
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"),
|
||||
temporaryPassword: z.string().min(8).max(256),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||
role: z.enum(["user", "admin", "super_admin"]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const row = await db.createUser({
|
||||
username: input.username,
|
||||
passwordHash: await hashPassword(input.temporaryPassword),
|
||||
name: input.name,
|
||||
email: input.email || null,
|
||||
role: input.role,
|
||||
isActive: 1,
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: 0,
|
||||
});
|
||||
if (!row) throw new Error("创建用户失败");
|
||||
return publicUser(row);
|
||||
} catch (error) {
|
||||
if (isDuplicateKeyError(error)) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
update: superAdminProcedure
|
||||
.input(z.object({
|
||||
id: z.number().int().positive(),
|
||||
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
email: z.string().email().max(320).optional().or(z.literal("")),
|
||||
role: z.enum(["user", "admin", "super_admin"]).optional(),
|
||||
}))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.id === ctx.user.id && input.role && input.role !== target.role) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" });
|
||||
}
|
||||
if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" });
|
||||
}
|
||||
const { id, email, ...rest } = input;
|
||||
try {
|
||||
const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) });
|
||||
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
return publicUser(row);
|
||||
} catch (error) {
|
||||
if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
setActive: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" });
|
||||
if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" });
|
||||
}
|
||||
if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" });
|
||||
}
|
||||
const row = await db.updateUser(target.id, {
|
||||
isActive: input.isActive ? 1 : 0,
|
||||
sessionVersion: target.sessionVersion + 1,
|
||||
});
|
||||
return publicUser(row!);
|
||||
}),
|
||||
resetPassword: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" });
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
await db.updateUser(target.id, {
|
||||
passwordHash: await hashPassword(input.temporaryPassword),
|
||||
mustChangePassword: 1,
|
||||
sessionVersion: target.sessionVersion + 1,
|
||||
});
|
||||
return { success: true } as const;
|
||||
}),
|
||||
delete: superAdminProcedure
|
||||
.input(z.object({ id: z.number().int().positive() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" });
|
||||
const target = await db.getUserById(input.id);
|
||||
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
|
||||
if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" });
|
||||
}
|
||||
await db.deleteUser(target.id);
|
||||
return { success: true } as const;
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── 车系 ──────────────────────────────────────────────────────────────────
|
||||
@@ -444,6 +623,23 @@ export const appRouter = router({
|
||||
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
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -596,6 +792,23 @@ export const appRouter = router({
|
||||
return db.listTaskPointcloudFiles(input.taskId);
|
||||
}),
|
||||
|
||||
rename: adminProcedure
|
||||
.input(z.object({ id: z.number(), baseName: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const row = await db.getTaskPointcloudFileById(input.id);
|
||||
if (!row) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" });
|
||||
}
|
||||
const name = pointcloudDisplayName(input.baseName, row.filePath);
|
||||
if (name === row.name) return row;
|
||||
|
||||
const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id);
|
||||
if (duplicate) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" });
|
||||
}
|
||||
return db.updateTaskPointcloudFileName(row.id, name);
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -801,11 +1014,7 @@ export const appRouter = router({
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
// 先取消正在运行或排队的 job,再删数据库记录
|
||||
cancelAlgorithmJob(input.id);
|
||||
cancelLegacyAnalysisJob(input.id);
|
||||
await db.deleteTaskAnalysisResult(input.id);
|
||||
return { success: true };
|
||||
return deleteAnalysisResult(input.id);
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
44
server/session.security.test.ts
Normal file
44
server/session.security.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { User } from "../drizzle/schema";
|
||||
|
||||
vi.mock("./db", () => ({ getUserById: vi.fn() }));
|
||||
|
||||
import * as db from "./db";
|
||||
import { authenticateRequest, createSessionToken } from "./_core/auth";
|
||||
import { ENV } from "./_core/env";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
|
||||
function user(overrides: Partial<User> = {}): User {
|
||||
const now = new Date();
|
||||
return { id: 7, username: "session-user", passwordHash: "unused", name: "Session User", email: null, role: "user", isActive: 1, mustChangePassword: 0, sessionVersion: 3, createdAt: now, updatedAt: now, lastLoginAt: null, ...overrides };
|
||||
}
|
||||
|
||||
function requestWith(token: string) {
|
||||
return { headers: { cookie: `${COOKIE_NAME}=${token}` } } as Parameters<typeof authenticateRequest>[0];
|
||||
}
|
||||
|
||||
describe("会话即时失效", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ENV.cookieSecret = "test-session-secret-with-at-least-32-chars";
|
||||
});
|
||||
|
||||
it("有效账号与匹配版本可恢复会话", async () => {
|
||||
const current = user();
|
||||
vi.mocked(db.getUserById).mockResolvedValue(current);
|
||||
const token = await createSessionToken(current);
|
||||
await expect(authenticateRequest(requestWith(token))).resolves.toMatchObject({ id: 7, sessionVersion: 3 });
|
||||
});
|
||||
|
||||
it("改密或重置密码递增版本后旧会话立即失效", async () => {
|
||||
const token = await createSessionToken(user());
|
||||
vi.mocked(db.getUserById).mockResolvedValue(user({ sessionVersion: 4 }));
|
||||
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("账号被禁用后旧会话立即失效", async () => {
|
||||
const token = await createSessionToken(user());
|
||||
vi.mocked(db.getUserById).mockResolvedValue(user({ isActive: 0 }));
|
||||
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
61
server/users.security.test.ts
Normal file
61
server/users.security.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { User } from "../drizzle/schema";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
getUserById: vi.fn(),
|
||||
countActiveSuperAdmins: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as db from "./db";
|
||||
import { appRouter } from "./routers";
|
||||
|
||||
function makeUser(id: number, role: User["role"] = "super_admin"): User {
|
||||
const now = new Date();
|
||||
return { id, username: `user-${id}`, passwordHash: "unused", name: `User ${id}`, email: null, role, isActive: 1, mustChangePassword: 0, sessionVersion: 0, createdAt: now, updatedAt: now, lastLoginAt: null };
|
||||
}
|
||||
|
||||
function caller() {
|
||||
const ctx: TrpcContext = { user: makeUser(1), req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
|
||||
return appRouter.createCaller(ctx);
|
||||
}
|
||||
|
||||
describe("用户管理安全保护", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("禁止删除自己", async () => {
|
||||
await expect(caller().users.delete({ id: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(db.deleteUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("禁止修改自己的角色", async () => {
|
||||
vi.mocked(db.getUserById).mockResolvedValue(makeUser(1));
|
||||
await expect(caller().users.update({ id: 1, role: "admin" })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(db.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("禁止禁用最后一个有效超级管理员", async () => {
|
||||
vi.mocked(db.getUserById).mockResolvedValue(makeUser(2));
|
||||
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(1);
|
||||
await expect(caller().users.setActive({ id: 2, isActive: false })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(db.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("有多个超级管理员时允许禁用其中一个并使旧会话失效", async () => {
|
||||
const target = makeUser(2);
|
||||
vi.mocked(db.getUserById).mockResolvedValue(target);
|
||||
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(2);
|
||||
vi.mocked(db.updateUser).mockResolvedValue({ ...target, isActive: 0, sessionVersion: 1 });
|
||||
await expect(caller().users.setActive({ id: 2, isActive: false })).resolves.toMatchObject({ isActive: 0, sessionVersion: 1 });
|
||||
expect(db.updateUser).toHaveBeenCalledWith(2, { isActive: 0, sessionVersion: 1 });
|
||||
});
|
||||
|
||||
it("遗留账号未修改用户名并重置密码前不能启用", async () => {
|
||||
const legacy = { ...makeUser(2, "user"), username: "legacy_user_2", passwordHash: null, isActive: 0 };
|
||||
vi.mocked(db.getUserById).mockResolvedValue(legacy);
|
||||
await expect(caller().users.setActive({ id: 2, isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(db.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
export const COOKIE_NAME = "app_session_id";
|
||||
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
|
||||
export const AXIOS_TIMEOUT_MS = 30_000;
|
||||
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
|
||||
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';
|
||||
export const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||
export const UNAUTHED_ERR_MSG = '请先登录';
|
||||
export const NOT_ADMIN_ERR_MSG = '无权执行此操作';
|
||||
export const MUST_CHANGE_PASSWORD_ERR_MSG = '请先修改初始密码';
|
||||
|
||||
21
shared/pointcloudMetadata.ts
Normal file
21
shared/pointcloudMetadata.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type PointcloudMetadataStatus = "pending" | "ready" | "unsupported" | "error";
|
||||
|
||||
export type PointcloudDataFormat =
|
||||
| "ascii"
|
||||
| "binary_little_endian"
|
||||
| "binary_big_endian";
|
||||
|
||||
export type PointAttribute = {
|
||||
name: string;
|
||||
type: string;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export type PointcloudMetadataValues = {
|
||||
metadataStatus: Exclude<PointcloudMetadataStatus, "pending">;
|
||||
pointCount: number | null;
|
||||
pointAttributes: PointAttribute[] | null;
|
||||
pointDataFormat: PointcloudDataFormat | null;
|
||||
bytesPerPoint: number | null;
|
||||
metadataError: string | null;
|
||||
};
|
||||
@@ -14,6 +14,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["server/**/*.test.ts", "server/**/*.spec.ts"],
|
||||
include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/src/**/*.test.ts", "client/src/**/*.spec.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user