Files
HSAP/platform/web/src/modules/labeling/pages/SimulationStudioPage.tsx
Chengfang Lu e72bc061c5 feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
2026-06-03 11:40:21 +08:00

252 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { hsapApi } from "@/app/hsap-api";
import { Button } from "@/components/ui/Button";
import { Badge, StatusBadge } from "@/components/ui/Badge";
import { PageQueryState } from "@/components/PageQueryState";
type SimJob = Record<string, unknown>;
const SCENES = [
{ key: "urban_highway", label: "城市快速路", icon: "🛣️" },
{ key: "urban_street", label: "城市街道", icon: "🏙️" },
{ key: "rural_road", label: "乡村道路", icon: "🌾" },
{ key: "tunnel", label: "隧道", icon: "🚇" },
{ key: "night_city", label: "夜间城市", icon: "🌃" },
{ key: "rain_highway", label: "雨天高速", icon: "🌧️" },
{ key: "fog_rural", label: "雾天乡村", icon: "🌫️" },
];
const CAMERAS = [
{ key: "truck_front", label: "卡车前视", icon: "🚛", height: "2.5m", fov: "75°" },
{ key: "truck_side", label: "卡车侧视", icon: "🚛", height: "2.5m", fov: "100°" },
{ key: "car_front", label: "轿车前视", icon: "🚗", height: "1.2m", fov: "60°" },
{ key: "car_wide", label: "轿车广角", icon: "🚗", height: "1.2m", fov: "120°" },
];
const WEATHERS = ["clear", "cloudy", "rain", "fog", "night"];
const WEATHER_LABELS: Record<string, string> = { clear: "晴天", cloudy: "多云", rain: "雨天", fog: "大雾", night: "夜间" };
const DENSITY_LABELS: Record<string, string> = { sparse: "稀疏 5-10", medium: "中等 10-30", dense: "密集 30+" };
const ALL_CLASSES = ["Pedestrain", "Car", "Truck", "Bus", "Motor-vehicles", "Tricycle", "cones"];
const JOBS_PAGE = 20;
export const SimulationStudioPage: React.FC = () => {
// Config form
const [scene, setScene] = useState("urban_highway");
const [camera, setCamera] = useState("truck_front");
const [weather, setWeather] = useState("clear");
const [objects, setObjects] = useState<string[]>(["Pedestrain", "Car", "Truck", "Bus"]);
const [density, setDensity] = useState("medium");
const [count, setCount] = useState(100);
const [fovVariant, setFovVariant] = useState(false);
const [note, setNote] = useState("");
const [generating, setGenerating] = useState(false);
const [info, setInfo] = useState<string | null>(null);
// Jobs list
const [jobs, setJobs] = useState<SimJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadJobs = async () => {
setLoading(true); setError(null);
try {
const res = await fetch(`/api/v1/simulate/jobs?limit=50`, {
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
}).then((r) => r.json());
setJobs((res.items || []) as SimJob[]);
} catch (e) { setError(String(e)); }
setLoading(false);
};
useEffect(() => { loadJobs(); }, []);
const toggleObject = (obj: string) => {
setObjects((prev) => prev.includes(obj) ? prev.filter((o) => o !== obj) : [...prev, obj]);
};
const handleGenerate = async () => {
if (objects.length === 0) { setError("请至少选择一种目标类别"); return; }
setGenerating(true); setError(null); setInfo(null);
try {
const res = await fetch("/api/v1/simulate/generate", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
body: JSON.stringify({ scene, camera, weather, objects, density, count, fov_variant: fovVariant, note }),
}).then((r) => r.json());
setInfo(`任务已提交: ${res.id}`);
loadJobs();
} catch (e) { setError(String(e)); }
setGenerating(false);
};
const handleIngest = async (jobId: string) => {
try {
const res = await fetch(`/api/v1/simulate/jobs/${jobId}/ingest?task=adas`, {
method: "POST",
headers: { Authorization: `Bearer ${hsapApi.getToken()}` },
}).then((r) => r.json());
if (res.ok) setInfo(`已入库: ${res.batch}`);
else setError(String(res.error));
loadJobs();
} catch (e) { setError(String(e)); }
};
return (
<div className="page-container">
<div className="page-header">
<h1>仿</h1>
<p> </p>
</div>
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-700">{error}</div>}
<div className="grid grid-cols-2 gap-6">
{/* Left: Config panel */}
<div>
<div className="card mb-4">
<div className="card-header"></div>
{/* Scene */}
<div className="mb-4">
<label className="form-label"></label>
<div className="grid grid-cols-4 gap-1.5">
{SCENES.map((s) => (
<button key={s.key} onClick={() => setScene(s.key)}
className={`p-2 rounded-lg text-xs text-center transition-colors ${
scene === s.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}>
<div className="text-lg">{s.icon}</div>
<div>{s.label}</div>
</button>
))}
</div>
</div>
{/* Camera */}
<div className="mb-4">
<label className="form-label"></label>
<div className="grid grid-cols-2 gap-1.5">
{CAMERAS.map((c) => (
<button key={c.key} onClick={() => setCamera(c.key)}
className={`p-2 rounded-lg text-xs transition-colors ${
camera === c.key ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}>
<div>{c.icon} {c.label}</div>
<div className="text-[10px] opacity-70"> {c.height} · FOV {c.fov}</div>
</button>
))}
</div>
</div>
{/* Weather */}
<div className="mb-4">
<label className="form-label">/</label>
<div className="flex gap-1.5">
{WEATHERS.map((w) => (
<button key={w} onClick={() => setWeather(w)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
weather === w ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-600 hover:bg-gray-100"
}`}>{WEATHER_LABELS[w] || w}</button>
))}
</div>
</div>
{/* Objects */}
<div className="mb-4">
<label className="form-label"> ({objects.length}/7)</label>
<div className="flex flex-wrap gap-1">
{ALL_CLASSES.map((c) => (
<button key={c} onClick={() => toggleObject(c)}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${
objects.includes(c) ? "bg-blue-600 text-white" : "bg-gray-50 text-gray-500"
}`}>{c}</button>
))}
</div>
</div>
{/* Density + Count */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="form-label"></label>
<select className="form-input" value={density} onChange={(e) => setDensity(e.target.value)}>
{Object.entries(DENSITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="form-label"></label>
<select className="form-input" value={count} onChange={(e) => setCount(Number(e.target.value))}>
{[50, 100, 200, 500, 1000, 2000, 5000].map((n) => <option key={n} value={n}>{n} </option>)}
</select>
</div>
</div>
{/* Multi-FOV variant */}
<div className="mb-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={fovVariant} onChange={(e) => setFovVariant(e.target.checked)} className="rounded" />
<span>FOV变体+轿</span>
</label>
</div>
{/* Note */}
<div className="mb-4">
<label className="form-label"></label>
<input className="form-input" value={note} onChange={(e) => setNote(e.target.value)} placeholder="如:补卡车视角雨天行人数据" />
</div>
<Button variant="primary" onClick={handleGenerate} loading={generating} className="w-full">
🚀 仿
</Button>
</div>
</div>
{/* Right: Job history */}
<div>
<div className="card">
<div className="card-header flex items-center justify-between">
<span></span>
<Button size="small" variant="default" onClick={loadJobs}></Button>
</div>
<PageQueryState loading={loading} error={error} empty={jobs.length === 0} emptyMessage="暂无生成记录">
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{jobs.map((job) => {
const params = job.params as Record<string, unknown> | undefined;
return (
<div key={job.id as string} className="border border-gray-100 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono text-gray-400">{(job.id as string).slice(0, 20)}...</span>
<StatusBadge status={(job.status as string) || "queued"} />
</div>
{params && (
<div className="text-xs text-gray-500 space-y-0.5">
<div>{String(params.scene_label ?? "")} · {String(params.camera_label ?? "")} · {WEATHER_LABELS[String(params.weather ?? "")] || String(params.weather ?? "")}</div>
<div className="flex gap-1">
{(params.objects as string[])?.map((o) => <Badge key={o} size="small" variant="default">{o}</Badge>)}
</div>
<div>{DENSITY_LABELS[params.density as string]} · {params.count as number} </div>
{params.note != null && String(params.note) && <div className="text-gray-400 italic">"{String(params.note)}"</div>}
</div>
)}
{job.status === "completed" && !Boolean(job.batch_registered) && (
<button onClick={() => handleIngest(job.id as string)}
className="mt-2 text-xs text-blue-600 hover:underline"> </button>
)}
{Boolean(job.batch_registered) && (
<span className="mt-2 text-xs text-green-600 inline-block"> : {String(job.batch_name ?? "")}</span>
)}
</div>
);
})}
</div>
</PageQueryState>
</div>
</div>
</div>
</div>
);
};