auto-save 2026-05-13 14:49 (~5)

This commit is contained in:
2026-05-13 14:49:32 +08:00
parent 59f6c16225
commit ffffb1e19c
5 changed files with 258 additions and 52 deletions

View File

@@ -1,24 +1,60 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X, Wand2, Brush } from "lucide-react"
import { type Job, type KeyFrame, effectiveFrameUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
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"
interface Props {
job: Job | null
selectedFrames: Set<number>
focusedFrame: number | null // 当前 focus 的分镜imagegen 节点 / bar 缩略图点击触发)
focusedFrame: number | null
onFocusFrame: (idx: number | null) => void
onJobUpdate?: (j: Job) => void
}
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
const emptyScene = (): StoryboardScene => ({
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
})
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
const [collapsed, setCollapsed] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// hover preview state — portal 渲染到 body 避免被父级 overflow-x-auto clip
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
@@ -163,64 +199,101 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
</div>
</div>
{/* 右:元素 + Phase 2 操作 */}
<div className="flex-1 min-w-0 space-y-3">
<section>
<div className="text-[12px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
<Sparkle className="h-3.5 w-3.5 text-violet-300" />
<span className="text-[10px] text-white/40 font-mono">· {focusCutCount}/{focusElements.length} </span>
{/* 右:编排表单 */}
<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>
{focusElements.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[11px] text-white/40">
·
<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-5 gap-1.5">
{focusElements.map((e) => {
<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 (
<div key={e.id} className="rounded-md bg-white/[0.04] border border-white/10 p-1.5">
<div className="w-full aspect-square rounded bg-white overflow-hidden mb-1">
{src ? (
<img src={src} alt={e.name_zh} className="w-full h-full object-contain" />
) : (
<div className="w-full h-full inline-flex items-center justify-center bg-black/40">
<Sparkle className="h-3.5 w-3.5 text-white/20" />
</div>
)}
<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>
<div className="text-[10px] text-white truncate">{e.name_zh}</div>
</div>
</button>
)
})}
</div>
)}
</section>
{/* Phase 2 操作占位 */}
<section className="rounded-lg border border-dashed border-violet-300/30 bg-violet-500/5 p-2.5">
<div className="text-[11.5px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
<Wand2 className="h-3 w-3 text-violet-300" />
· Phase 2
</div>
<div className="grid grid-cols-3 gap-1.5">
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5">📐 </div>
<div className="text-[9px] text-white/45 leading-tight"> / / </div>
</button>
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5 inline-flex items-center gap-1">
<Brush className="h-3 w-3" />
</div>
<div className="text-[9px] text-white/45 leading-tight"> + </div>
</button>
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
<div className="text-[11px] font-medium text-white mb-0.5">🎬 </div>
<div className="text-[9px] text-white/45 leading-tight"> / </div>
</button>
</div>
</section>
</div>
</div>
</div>
@@ -263,3 +336,50 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
</div>
)
}
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>
)
}