diff --git a/.memory/worklog.json b/.memory/worklog.json index 264ff27..b3696f2 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -293,6 +293,13 @@ "message": "auto-save 2026-05-12 19:58 (+1, ~4)", "hash": "375494e", "files_changed": 5 + }, + { + "ts": "2026-05-12T20:04:48+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 20:04 (~3)", + "hash": "ca0d6f1", + "files_changed": 3 } ] } diff --git a/web/app/page.tsx b/web/app/page.tsx index 6c7b389..1c3f471 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -57,12 +57,42 @@ const EDGES_RAW: Array<[string, string]> = [ export default function Home() { const { resolvedTheme } = useTheme() - const [job, setJob] = useState(null) + const [jobs, setJobs] = useState([]) + const [activeJobId, setActiveJobId] = useState(null) + const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) const [submitting, setSubmitting] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [selectedFrames, setSelectedFrames] = useState>(new Set()) const [expandedFrame, setExpandedFrame] = useState(null) const [videoLightboxOpen, setVideoLightboxOpen] = useState(false) + + // 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active + const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => { + setJobs((prev) => { + const current = prev.find((j) => j.id === activeJobId) ?? null + const next = typeof updater === "function" ? (updater as (p: Job | null) => Job | null)(current) : updater + if (!next) return prev + const idx = prev.findIndex((j) => j.id === next.id) + if (idx < 0) { + setActiveJobId(next.id) + return [...prev, next] + } + const arr = [...prev] + arr[idx] = next + return arr + }) + }, [activeJobId]) + + // 新增 job + 设为 active + const addJob = useCallback((j: Job) => { + setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j]) + setActiveJobId(j.id) + }, []) + + const handleSwitchJob = useCallback((id: string) => { + setActiveJobId(id) + setSelectedFrames(new Set()) + }, []) const pollRef = useRef | null>(null) const handleSubmit = useCallback(async (url: string) => { @@ -70,14 +100,14 @@ export default function Home() { setSelectedFrames(new Set()) try { const created = await createJob(url) - setJob(created) + addJob(created) toast.success(`已创建任务 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } - }, []) + }, [addJob]) const handleUpload = useCallback(async (file: File) => { setSubmitting(true) @@ -85,14 +115,14 @@ export default function Home() { try { toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) const created = await uploadJob(file) - setJob(created) + addJob(created) toast.success(`已上传 ${created.id.slice(0, 8)}`) } catch (e) { toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) } finally { setSubmitting(false) } - }, []) + }, [addJob]) const handleAnalyze = useCallback(async () => { if (!job) return @@ -130,23 +160,29 @@ export default function Home() { }) }, []) - // URL ?job=xxx 自动恢复 job state + // URL ?job=xxx,yyy 自动恢复多个 job useEffect(() => { const params = new URLSearchParams(window.location.search) - const id = params.get("job") - if (id && !job) { - getJob(id).then(setJob).catch(() => toast.error(`找不到 job ${id}`)) - } + const idsStr = params.get("job") ?? "" + const ids = idsStr.split(",").filter(Boolean) + if (ids.length === 0) return + Promise.all(ids.map((id) => getJob(id).catch(() => null))).then((results) => { + const valid = results.filter((j): j is Job => !!j) + if (valid.length > 0) { + setJobs(valid) + setActiveJobId(valid[valid.length - 1].id) + } + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // 写回 URL(不刷新页面) + // 写回 URL(所有 jobs id 用 , 分隔) useEffect(() => { - if (!job) return + if (jobs.length === 0) return const url = new URL(window.location.href) - url.searchParams.set("job", job.id) + url.searchParams.set("job", jobs.map((j) => j.id).join(",")) window.history.replaceState({}, "", url.toString()) - }, [job?.id]) + }, [jobs.length]) // 轮询 Job(downloaded / transcribed / failed 三态停止) const prevStatusRef = useRef(null) @@ -174,6 +210,8 @@ export default function Home() { const nodeData: NodeData = useMemo(() => ({ job, + jobs, + activeJobId, submitting, analyzing, selectedFrames, @@ -184,7 +222,8 @@ export default function Home() { onExpandFrame: setExpandedFrame, onAddManualFrame: handleAddManualFrame, onOpenVideoLightbox: () => setVideoLightboxOpen(true), - }), [job, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame]) + onSwitchJob: handleSwitchJob, + }), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob]) // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) const [nodes, setNodes, onNodesChange] = useNodesState( diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index f2bf7a2..587d138 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -9,7 +9,9 @@ import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell" import { type Job, frameUrl, videoUrl } from "@/lib/api" export interface NodeData { - job: Job | null + job: Job | null // 当前 active job + jobs: Job[] // 所有 job 列表 + activeJobId: string | null submitting: boolean analyzing: boolean selectedFrames: Set @@ -20,6 +22,7 @@ export interface NodeData { onExpandFrame: (idx: number) => void onAddManualFrame: (t: number) => void onOpenVideoLightbox: () => void + onSwitchJob: (id: string) => void } /* ---- 状态映射工具 ---- */ @@ -79,37 +82,62 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an return (
- {/* 视频缩略图浮于节点上方 — 跟关键帧缩略图同尺寸(小),点击稍微放大可选帧 */} - {hasVideo && job && !videoExpanded && ( -
+ {/* 多视频缩略图浮条 — 每个 job 一张 + 末尾「+」按钮再上传 */} + {!videoExpanded && d.jobs.length > 0 && ( +
+ {d.jobs.map((j) => { + const isActive = j.id === d.activeJobId + const ready = !!j.video_url + return ( + + ) + })} + {/* + 再加一个 */}
)}