auto-save 2026-05-13 15:00 (~2)

This commit is contained in:
2026-05-13 15:00:36 +08:00
parent 7a5c07b79d
commit dfa5600925
2 changed files with 16 additions and 215 deletions

View File

@@ -1,9 +1,8 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X, Loader2, Check } from "lucide-react"
import { type Job, type KeyFrame, type StoryboardScene, effectiveFrameUrl, hasCutout, representativeCutoutUrl, updateStoryboard } from "@/lib/api"
import { toast } from "sonner"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X } from "lucide-react"
import { type Job, type KeyFrame, cutoutUrl, effectiveFrameUrl, hasCutout } from "@/lib/api"
interface Props {
job: Job | null
@@ -13,48 +12,13 @@ interface Props {
onJobUpdate?: (j: Job) => void
}
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const [hover, setHover] = useState<{ frame: KeyFrame; seq: number; rect: DOMRect } | null>(null)
const btnRefs = useRef<Record<number, HTMLButtonElement | null>>({})
// 表单 state每次切到新 focus frame 加载该帧的 storyboard
const [form, setForm] = useState<StoryboardScene>(emptyScene())
const [saving, setSaving] = useState(false)
const [savedTick, setSavedTick] = useState(0)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!job || focusedFrame === null) return
const f = job.frames.find((x) => x.index === focusedFrame)
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
}, [focusedFrame, job?.id])
// 自动保存:表单变化 600ms 后调 API
const queueSave = (next: StoryboardScene) => {
setForm(next)
if (!job || focusedFrame === null) return
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(async () => {
setSaving(true)
try {
const updated = await updateStoryboard(job.id, focusedFrame, next)
onJobUpdate?.(updated)
setSavedTick((t) => t + 1)
} catch (e) {
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}, 600)
}
if (!job) return null
const frames = job.frames
@@ -75,7 +39,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
? job.frames.filter((f) => selectedFrames.has(f.index) && f.timestamp <= focusFrame.timestamp).length
: 0
const focusElements = focusFrame?.elements ?? []
const focusCutCount = focusElements.filter((e) => hasCutout(e)).length
return (
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
@@ -235,135 +198,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
)
})()}
{/* 旧 form 面板已暂时移除(用户要求只展示提取图) */}
{false && focusFrame && (
<div style={{ display: "none" }}>
{/* 占位防止 saving / form / FieldX 等变量未使用报错 */}
<span>{saving}{savedTick}{form.subject}{focusCutCount}</span>
<FieldText label="" value="" onChange={() => {}} />
<FieldNum label="" value={0} onChange={() => {}} />
<FieldTextarea label="" value="" onChange={() => {}} />
</div>
)}
{/* 旧 form 面板代码(保留 fallback 渲染) */}
{false && focusFrame && !collapsed && (
<div
className="border-t border-white/10 bg-black/20 overflow-y-auto"
style={{ maxHeight: "50vh" }}
>
<div className="flex gap-4 p-4">
<div className="flex-shrink-0 flex flex-col gap-1.5" style={{ width: 260 }}>
<img
src={effectiveFrameUrl(job.id, focusFrame)}
alt={`frame ${focusFrame.index}`}
className="w-full rounded-lg object-contain bg-black"
style={{ maxHeight: "38vh" }}
/>
<div className="text-[10px] text-white/50 text-center">
{focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"} · {focusSeq} · {focusFrame.timestamp.toFixed(2)}s
</div>
</div>
{/* 右:编排表单 */}
<div className="flex-1 min-w-0 space-y-2.5">
{/* 保存状态 */}
<div className="flex items-center justify-between">
<div className="text-[12px] font-semibold text-white flex items-center gap-1.5">
<LayoutGrid className="h-3.5 w-3.5 text-violet-300" />
</div>
<span className="text-[10px] text-white/40 font-mono inline-flex items-center gap-1">
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" /></>)
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" /></>)
: "字段变更自动保存"}
</span>
</div>
{/* 5 个字段 — 2×2 grid + 跨列的 action */}
<div className="grid grid-cols-2 gap-2">
<FieldText
label="主体" placeholder="如:戴头带的骨架人"
value={form.subject}
onChange={(v) => queueSave({ ...form, subject: v })}
/>
<FieldText
label="产品" placeholder="如Goli 营养软糖"
value={form.product}
onChange={(v) => queueSave({ ...form, product: v })}
/>
<FieldText
label="场景" placeholder="如:药店柜台 / 夜晚卧室"
value={form.scene}
onChange={(v) => queueSave({ ...form, scene: v })}
/>
<FieldNum
label="时长 (秒)" placeholder="3.5"
value={form.duration}
onChange={(v) => queueSave({ ...form, duration: v })}
/>
</div>
<FieldTextarea
label="在干什么"
placeholder="如:骨架人递给顾客一瓶 Goli · 顾客接过并查看 · 灯光柔和"
value={form.action}
onChange={(v) => queueSave({ ...form, action: v })}
rows={2}
/>
{/* 参考图区 — 多选该分镜已提取元素 */}
<section>
<div className="flex items-center justify-between mb-1">
<span className="text-[10.5px] text-white/55 font-medium"></span>
<span className="text-[9.5px] text-white/35 font-mono">
{form.reference_ids.length} / {focusElements.filter((e) => hasCutout(e)).length}
</span>
</div>
{focusElements.filter((e) => hasCutout(e)).length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[10.5px] text-white/40">
· AI
</div>
) : (
<div className="grid grid-cols-6 gap-1.5">
{focusElements.filter((e) => hasCutout(e)).map((e) => {
const src = representativeCutoutUrl(job.id, focusFrame.index, e)
const checked = form.reference_ids.includes(e.id)
return (
<button
key={e.id}
onClick={() => {
const next = checked
? form.reference_ids.filter((x) => x !== e.id)
: [...form.reference_ids, e.id]
queueSave({ ...form, reference_ids: next })
}}
title={e.name_zh}
className={`relative rounded-md overflow-hidden border-2 transition ${
checked
? "border-emerald-400 ring-2 ring-emerald-400/40"
: "border-white/15 hover:border-white/40"
}`}
style={{ aspectRatio: "1/1" }}
>
{src && <img src={src} alt={e.name_zh} className="absolute inset-0 w-full h-full object-contain bg-white" />}
{checked && (
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-emerald-500 text-white inline-flex items-center justify-center">
<Check className="h-2.5 w-2.5" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8.5px] text-white bg-black/70 truncate">
{e.name_zh}
</div>
</button>
)
})}
</div>
)}
</section>
</div>
</div>
</div>
)}
{/* Hover 大图预览 · 浮在缩略图下方(不挡其他界面) */}
{mounted && hover && (() => {
@@ -403,49 +237,3 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
)
}
function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<label className="block">
<div className="text-[10px] text-white/55 mb-1">{label}</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
</label>
)
}
function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) {
return (
<label className="block">
<div className="text-[10px] text-white/55 mb-1">{label}</div>
<input
type="number"
step="0.1"
min="0"
value={value || ""}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
placeholder={placeholder}
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
</label>
)
}
function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) {
return (
<label className="block">
<div className="text-[10px] text-white/55 mb-1">{label}</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40 resize-none"
/>
</label>
)
}