auto-save 2026-05-13 14:38 (~4)
This commit is contained in:
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop } from "lucide-react"
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout,
|
||||
type KeyFrame, type Job,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
@@ -225,14 +225,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
try {
|
||||
const updated = await cutoutElement(jobId, f.index, id)
|
||||
onJobUpdate?.(updated)
|
||||
toast.success("裁切完成")
|
||||
toast.success("提取完成")
|
||||
} catch (e) {
|
||||
toast.error("裁切失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setCuttingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCutout = async (elementId: string, cutoutId: string) => {
|
||||
try {
|
||||
const updated = await deleteCutout(jobId, f.index, elementId, cutoutId)
|
||||
onJobUpdate?.(updated)
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
|
||||
// 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust
|
||||
const cleanedSrc = (() => {
|
||||
@@ -605,88 +614,111 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</div>
|
||||
|
||||
{elements.length > 0 && (
|
||||
<div className="space-y-1.5 mb-2">
|
||||
<div className="space-y-2 mb-2">
|
||||
{elements.map((e) => {
|
||||
const isCutting = cuttingId === e.id
|
||||
const hasCutout = !!e.cutout_id
|
||||
// 合并新旧字段:cutouts 优先,否则 fallback 用 cutout_id
|
||||
const cutouts: string[] = (e.cutouts && e.cutouts.length > 0)
|
||||
? e.cutouts
|
||||
: (e.cutout_id ? [e.cutout_id] : [])
|
||||
const hasAny = cutouts.length > 0
|
||||
const hasRegion = !!e.region
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5 flex items-start gap-2"
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 p-2"
|
||||
>
|
||||
{/* 裁切缩略图 / 占位 */}
|
||||
<div
|
||||
className="flex-shrink-0 rounded border border-white/8 flex items-center justify-center overflow-hidden bg-black/40"
|
||||
style={{ width: 36, height: 36 }}
|
||||
>
|
||||
{hasCutout ? (
|
||||
<a
|
||||
href={cutoutUrl(jobId, f.index, e.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击查看原图"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img
|
||||
src={cutoutUrl(jobId, f.index, e.id)}
|
||||
alt={e.name_zh}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<Sparkle className="h-3.5 w-3.5 text-white/25" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pr-5">
|
||||
<div className="flex items-center gap-1 text-white text-[11.5px] font-medium leading-tight truncate">
|
||||
<span className="truncate">{e.name_zh}</span>
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
{e.source === "region" && (
|
||||
<span className="text-[8.5px] text-cyan-300/80 font-mono uppercase">box</span>
|
||||
)}
|
||||
{/* 顶部:名字 + 操作 */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0 pr-5">
|
||||
<div className="flex items-center gap-1 text-white text-[12px] font-medium leading-tight truncate">
|
||||
<span className="truncate">{e.name_zh}</span>
|
||||
{e.source === "auto" && (
|
||||
<span className="text-[8.5px] text-pink-300/70 font-mono uppercase">auto</span>
|
||||
)}
|
||||
{e.source === "region" && (
|
||||
<span className="text-[8.5px] text-cyan-300/80 font-mono uppercase">box</span>
|
||||
)}
|
||||
{cutouts.length > 0 && (
|
||||
<span className="text-[8.5px] text-white/40 font-mono">· {cutouts.length} 张</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
|
||||
{e.name_en || <span className="text-white/30">(无英文)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-mono leading-tight truncate text-white/45">
|
||||
{e.name_en || <span className="text-white/30">(无英文)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 裁切 / 抠图按钮(有 region 走 PIL crop,无 region 走模型抠图) */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={
|
||||
hasRegion
|
||||
? hasCutout ? "重新裁切原图区域" : "从原图裁切该区域(保留原表情/形体 · 瞬时)"
|
||||
: hasCutout ? "重新抠图" : "调 nano-banana 抠白底图(5-15s · 模型重画可能有差异)"
|
||||
}
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
hasCutout
|
||||
? "bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30"
|
||||
: hasRegion
|
||||
{/* 提取按钮(每次新增一张,不覆盖) */}
|
||||
<button
|
||||
onClick={() => handleCutout(e.id)}
|
||||
disabled={isCutting}
|
||||
title={
|
||||
hasRegion
|
||||
? `${hasAny ? "再提取一张" : "提取"}(从原图裁切框内 · 保留原表情形体 · 瞬时)`
|
||||
: `${hasAny ? "再提取一张" : "提取"}(调 nano-banana 模型生白底图 · 5-15s)`
|
||||
}
|
||||
className={`shrink-0 text-[10.5px] px-2 py-1 rounded inline-flex items-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium ${
|
||||
hasRegion
|
||||
? "bg-cyan-500/30 text-white/90 hover:bg-cyan-500/50"
|
||||
: "bg-violet-500/30 text-white/90 hover:bg-violet-500/50"
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Sparkle className="h-2.5 w-2.5" />}
|
||||
{isCutting
|
||||
? (hasRegion ? "裁切中" : "抠图中")
|
||||
: hasCutout
|
||||
? "重抠"
|
||||
: (hasRegion ? "裁切" : "抠图")}
|
||||
</button>
|
||||
}`}
|
||||
>
|
||||
{isCutting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
|
||||
{isCutting ? "提取中" : hasAny ? "再提取" : "提取"}
|
||||
</button>
|
||||
|
||||
{/* 删除 */}
|
||||
<button
|
||||
onClick={() => handleDeleteElement(e.id)}
|
||||
className="absolute right-1 top-1 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除该元素"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
{/* 删除整条元素 */}
|
||||
<button
|
||||
onClick={() => handleDeleteElement(e.id)}
|
||||
className="absolute right-1 top-1 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
|
||||
title="删除该元素"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 多张提取图横向 grid */}
|
||||
{hasAny && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{cutouts.map((cid, ci) => {
|
||||
// 旧数据兼容:当 e.cutouts 为空、靠 cutout_id fallback 时,cid 实际是 element_id 而非 versioned id
|
||||
const url = (e.cutouts && e.cutouts.length > 0)
|
||||
? cutoutUrl(jobId, f.index, e.id, cid)
|
||||
: cutoutUrl(jobId, f.index, e.id)
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
className="group/img relative rounded-md border border-white/10 bg-white overflow-hidden"
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="点击新窗口查看原图"
|
||||
className="block w-full h-full"
|
||||
>
|
||||
<img src={url} alt={`${e.name_zh} #${ci + 1}`} className="w-full h-full object-contain" />
|
||||
</a>
|
||||
{/* 序号 */}
|
||||
<div className="absolute bottom-0 left-0 text-[8.5px] font-mono text-white bg-black/70 backdrop-blur px-1 rounded-tr">
|
||||
#{ci + 1}
|
||||
</div>
|
||||
{/* 删除该张 — 仅 v2 多图支持,老 fallback 不显示 */}
|
||||
{e.cutouts && e.cutouts.length > 0 && (
|
||||
<button
|
||||
onClick={(ev) => { ev.preventDefault(); handleDeleteCutout(e.id, cid) }}
|
||||
className="absolute right-0.5 top-0.5 h-4 w-4 rounded-sm bg-black/70 text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition"
|
||||
title="删除该张"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user