jim800121chen 5c1c37d151 feat(frontend): classification 結果呈現與推論期控制項
detection 畫框、classification 改在影像右上角疊標籤(不畫框),
並在推論面板提供即時切換解析方式與上傳 label 檔。

- 新增 InferenceOverlay 依 taskType 分派;ClassificationOverlay 用
  DOM 而非 canvas,讓 CJK 排版與 aria-live 交給瀏覽器處理
- 標籤防閃爍:挑戰者需連續 3 幀居冠才切換,或信心度領先 15% 直接切;
  低於門檻立即清空(顯示過期標籤比空白更糟)
- 身分比對優先用 classIndex,避免換 label 檔時被誤判為換類別
- 推論設定卡片:即時切 detection/classification、上傳 .txt label、
  清除 label,皆不需重燒
- classification-result 原本無條件渲染,導致 detection 模式下側欄
  永遠顯示「分類結果」與空圖表,改為依 taskType 切換
- 清掉 camera-overlay 每幀執行的 debug console.log
- 補 ResizeObserver stub,原本任何渲染 InferencePanel 的測試都會
  在 jsdom 直接拋錯

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:27:28 +08:00

103 lines
3.6 KiB
TypeScript

'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { CameraInferenceView } from '@/components/camera/camera-inference-view';
import { InferencePanel } from '@/components/inference/inference-panel';
import { FlashDialog } from '@/components/devices/flash-dialog';
import { useDeviceStore } from '@/stores/device-store';
import { useInferenceStore } from '@/stores/inference-store';
import { useInferenceOptionsStore } from '@/stores/inference-options-store';
import { useInferenceStream } from '@/hooks/use-inference-stream';
import { useCameraStore } from '@/stores/camera-store';
import { useResolvedParams } from '@/hooks/use-resolved-params';
import { api } from '@/lib/api';
export default function WorkspaceClient() {
const { deviceId } = useResolvedParams();
const { selectedDevice, fetchDevice } = useDeviceStore();
const { isRunning, setRunning, reset } = useInferenceStore();
const resetInferenceOptions = useInferenceOptionsStore((s) => s.reset);
const { isStreaming, sourceType } = useCameraStore();
// For image/video mode, inference runs automatically as part of the pipeline
const isMediaMode = sourceType === 'image' || sourceType === 'video' || sourceType === 'batch_image';
// Enable WebSocket stream when inference is running OR when media pipeline is active
useInferenceStream(deviceId, isRunning || (isStreaming && isMediaMode));
// Auto-set isRunning when media upload starts streaming
useEffect(() => {
if (isStreaming && isMediaMode) {
setRunning(true);
}
}, [isStreaming, isMediaMode, setRunning]);
const { fetchCameras } = useCameraStore();
useEffect(() => {
if (deviceId) {
fetchDevice(deviceId);
fetchCameras();
}
return () => {
reset();
// Options are per-session and per-device; leaving them set would apply a
// label mapping uploaded for one device to the next one opened.
resetInferenceOptions();
};
}, [deviceId, fetchDevice, fetchCameras, reset, resetInferenceOptions]);
const handleStartInference = async () => {
await api.post(`/devices/${deviceId}/inference/start`);
setRunning(true);
};
const handleStopInference = async () => {
await api.post(`/devices/${deviceId}/inference/stop`);
setRunning(false);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/devices/${deviceId}`}>
<Button variant="ghost" size="sm">{'← 返回'}</Button>
</Link>
<h1 className="text-xl font-bold">
{'工作區:'} {selectedDevice?.name || deviceId}
</h1>
</div>
<div className="flex gap-2">
<FlashDialog deviceId={deviceId} />
{/* Only show manual inference controls in camera mode */}
{!isMediaMode && (
<>
{isRunning ? (
<Button variant="destructive" onClick={handleStopInference}>
</Button>
) : (
<Button onClick={handleStartInference} disabled={!isStreaming} data-tour-id="start-inference-btn">
</Button>
)}
</>
)}
</div>
</div>
<div className="flex gap-4 min-h-0">
<div className="flex-1 min-w-0">
<CameraInferenceView deviceId={deviceId} />
</div>
<div className="w-80 shrink-0">
<InferencePanel deviceId={deviceId} />
</div>
</div>
</div>
);
}