Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-13 11:56:39 +08:00

727 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useRef, useState } from "react"
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, addElement, deleteElement, cutoutElement,
type KeyFrame, type Job,
} from "@/lib/api"
import { toast } from "sonner"
interface Props {
jobId: string
frames: KeyFrame[]
activeIndex: number | null
selected: Set<number>
onClose: () => void
onChange: (idx: number) => void
onToggleSelect: (idx: number) => void
onJobUpdate?: (job: Job) => void
onSwitchPanel?: (key: string) => void
embedded?: boolean
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [applying, setApplying] = useState(false)
const [cuttingId, setCuttingId] = useState<string | null>(null)
const [addingZh, setAddingZh] = useState(false)
const [addInput, setAddInput] = useState("")
const [mounted, setMounted] = useState(false)
// 画框模式 + 多选区(相对坐标 0-1
type Region = { x: number; y: number; w: number; h: number }
const [cropMode, setCropMode] = useState(false)
const [regions, setRegions] = useState<Region[]>([])
const [draftRegion, setDraftRegion] = useState<Region | null>(null) // 当前正在拖的
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
const [extractNamePrompt, setExtractNamePrompt] = useState(false) // 提取模式:要用户填名字
const [extractName, setExtractName] = useState("")
const [extracting, setExtracting] = useState(false)
const imgWrapRef = useRef<HTMLDivElement>(null)
useEffect(() => setMounted(true), [])
// 切换分镜时清空选区
useEffect(() => {
setCropMode(false)
setRegions([])
setDraftRegion(null)
setDragStart(null)
setExtractNamePrompt(false)
setExtractName("")
}, [activeIndex])
useEffect(() => {
if (activeIndex === null) return
const onKey = (e: KeyboardEvent) => {
const inField = ["INPUT", "TEXTAREA"].includes((e.target as HTMLElement).tagName)
if (e.key === "Escape") onClose()
if (!inField && activeIndex !== null) {
const pos = frames.findIndex((x) => x.index === activeIndex)
if (e.key === "ArrowLeft" && pos > 0) onChange(frames[pos - 1].index)
if (e.key === "ArrowRight" && pos < frames.length - 1) onChange(frames[pos + 1].index)
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
onToggleSelect(activeIndex)
}
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [activeIndex, frames.length, onClose, onChange, onToggleSelect])
const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined
const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1
if (activeIndex === null || !f || !mounted) return null
const isSelected = selected.has(f.index)
const desc = f.description
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
const handleDescribe = async () => {
setDescribing(true)
try {
const updated = await describeFrame(jobId, f.index)
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 识别完成`)
} catch (e) {
toast.error("识别失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setDescribing(false)
}
}
const handleCleanup = async (useRegions = false) => {
setCleaning(true)
try {
const usable = useRegions ? regions.filter((r) => r.w >= 0.03 && r.h >= 0.03) : null
const updated = await cleanupFrame(jobId, f.index, usable && usable.length > 0 ? usable : null)
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 清洗完成${usable && usable.length > 0 ? `${usable.length} 个区域)` : ""}`)
if (useRegions) { setCropMode(false); setRegions([]); setDraftRegion(null) }
} catch (e) {
toast.error("清洗失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCleaning(false)
}
}
const handleExtractRegion = async () => {
// 提取语义只在恰好 1 个框时支持
if (regions.length !== 1 || !extractName.trim()) return
const r = regions[0]
setExtracting(true)
try {
const added = await addElement(jobId, f.index, {
name_zh: extractName.trim(),
source: "region",
region: r,
})
onJobUpdate?.(added)
const fr = added.frames.find((x) => x.index === f.index)
const newEl = fr?.elements?.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]
if (newEl) {
const cut = await cutoutElement(jobId, f.index, newEl.id)
onJobUpdate?.(cut)
toast.success(`${extractName.trim()}」已提取并加入元素清单`)
} else {
toast.success(`${extractName.trim()}」已加入元素清单 · 但抠图未触发`)
}
setCropMode(false); setRegions([]); setExtractNamePrompt(false); setExtractName("")
} catch (e) {
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setExtracting(false)
}
}
// 画框 mouse handlers — 坐标基于 img wrapper 相对位置
const getRelXY = (clientX: number, clientY: number) => {
const el = imgWrapRef.current
if (!el) return null
const r = el.getBoundingClientRect()
return {
x: Math.max(0, Math.min(1, (clientX - r.left) / r.width)),
y: Math.max(0, Math.min(1, (clientY - r.top) / r.height)),
}
}
const onCropMouseDown = (e: React.MouseEvent) => {
if (!cropMode) return
e.preventDefault()
const p = getRelXY(e.clientX, e.clientY)
if (!p) return
setDragStart(p)
setDraftRegion({ x: p.x, y: p.y, w: 0, h: 0 })
}
const onCropMouseMove = (e: React.MouseEvent) => {
if (!cropMode || !dragStart) return
const p = getRelXY(e.clientX, e.clientY)
if (!p) return
setDraftRegion({
x: Math.min(dragStart.x, p.x),
y: Math.min(dragStart.y, p.y),
w: Math.abs(p.x - dragStart.x),
h: Math.abs(p.y - dragStart.y),
})
}
const onCropMouseUp = () => {
if (draftRegion && draftRegion.w >= 0.02 && draftRegion.h >= 0.02) {
setRegions((prev) => [...prev, draftRegion])
}
setDraftRegion(null)
setDragStart(null)
}
const handleApplyCleaned = async () => {
setApplying(true)
try {
const updated = await applyCleanedFrame(jobId, f.index)
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 已替换为清洗版`)
} catch (e) {
toast.error("替换失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setApplying(false)
}
}
const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => {
const zh = name_zh.trim()
if (!zh) return
setAddingZh(true)
try {
const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source })
onJobUpdate?.(updated)
} catch (e) {
toast.error("加入失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setAddingZh(false)
}
}
const handleDeleteElement = async (id: string) => {
try {
const updated = await deleteElement(jobId, f.index, id)
onJobUpdate?.(updated)
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleCutout = async (id: string, background: "white" | "black" = "white") => {
setCuttingId(id)
try {
const updated = await cutoutElement(jobId, f.index, id, background)
onJobUpdate?.(updated)
toast.success(`抠图完成(${background === "white" ? "白底" : "黑底"}`)
} catch (e) {
toast.error("抠图失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCuttingId(null)
}
}
// cleaned_url 是 /jobs/.../cleaned.jpg?t=<timestamp> 形式(后端写时带)
// 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust
const cleanedSrc = (() => {
if (!f.cleaned_url) return null
const ts = f.cleaned_url.match(/t=(\d+)/)?.[1]
return cleanedFrameUrl(jobId, f.index, ts)
})()
// bust cache替换后 frames/{idx}.jpg 内容已变,要刷新
const mainSrc = `${frameUrl(jobId, f.index)}${f.cleaned_applied ? "?applied=1" : ""}`
const content = (
<div
onClick={(e) => e.stopPropagation()}
className={`rounded-2xl border border-white/15 overflow-hidden flex flex-col ${embedded ? "" : "fixed z-[100] bg-black/70 backdrop-blur-2xl"}`}
style={embedded ? {
height: "100%",
background: "transparent",
} : {
top: 80,
right: 16,
width: 740,
maxHeight: "calc(100vh - 96px)",
boxShadow: "0 40px 100px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.05)",
animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
{/* 顶部工具栏 */}
<div
className="flex items-center justify-between px-4 py-2 text-white"
style={{ background: "linear-gradient(135deg, #f59e0b, #ef4444)" }}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); if (arrayPos > 0) onChange(frames[arrayPos - 1].index) }}
disabled={arrayPos <= 0}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={(e) => { e.stopPropagation(); if (arrayPos < frames.length - 1) onChange(frames[arrayPos + 1].index) }}
disabled={arrayPos >= frames.length - 1}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="h-4 w-4" />
</button>
<span className="text-[11px] font-mono text-white/70 ml-1">
{String(arrayPos + 1).padStart(2, "0")} / {String(frames.length).padStart(2, "0")}
<span className="mx-1.5 text-white/30">·</span>
<span className="text-white/60">{f.timestamp.toFixed(2)}s</span>
</span>
</div>
<button
onClick={onClose}
className="h-7 w-7 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center justify-center"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
<div className="flex flex-col items-stretch gap-2 flex-shrink-0" style={{ width: 320 }}>
{/* 上方:主图 + 画框 overlay */}
<div
ref={imgWrapRef}
className={`relative ${cropMode ? "cursor-crosshair select-none" : ""}`}
onMouseDown={onCropMouseDown}
onMouseMove={onCropMouseMove}
onMouseUp={onCropMouseUp}
onMouseLeave={onCropMouseUp}
>
<img
src={mainSrc}
alt={`frame ${f.index}`}
className="rounded-lg object-contain w-full pointer-events-none"
style={{ maxHeight: hasCleaned ? "38vh" : "62vh" }}
draggable={false}
/>
<div className="absolute top-2 left-2 text-[9.5px] px-1.5 py-0.5 rounded backdrop-blur bg-black/50 text-white/80 pointer-events-none">
{f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"}
</div>
{/* 已确认的多个选区 */}
{cropMode && regions.map((r, i) => (
<div key={i} className="absolute pointer-events-none border-2 border-cyan-300/90 bg-cyan-300/10"
style={{
left: `${r.x * 100}%`,
top: `${r.y * 100}%`,
width: `${r.w * 100}%`,
height: `${r.h * 100}%`,
}}
>
<span className="absolute -top-4 left-0 text-[9px] px-1 py-0 rounded-sm bg-cyan-300 text-black font-bold leading-tight">#{i + 1}</span>
</div>
))}
{/* 当前正在拖的草稿框 */}
{cropMode && draftRegion && draftRegion.w > 0 && draftRegion.h > 0 && (
<div className="absolute pointer-events-none border-2 border-cyan-300 border-dashed shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
style={{
left: `${draftRegion.x * 100}%`,
top: `${draftRegion.y * 100}%`,
width: `${draftRegion.w * 100}%`,
height: `${draftRegion.h * 100}%`,
}}
/>
)}
{/* 画框模式角标(小,左上) — 不再遮挡画面 */}
{cropMode && (
<div className="absolute top-2 right-2 text-[9.5px] px-1.5 py-0.5 rounded backdrop-blur bg-cyan-500/85 text-white pointer-events-none font-medium">
· {regions.length}
</div>
)}
</div>
{/* 画框工具栏 */}
{cropMode ? (
extractNamePrompt ? (
// 提取模式:要用户填名字(仅 1 框时进入此模式)
<div className="space-y-1.5">
<input
autoFocus
value={extractName}
onChange={(e) => setExtractName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing && extractName.trim()) {
e.preventDefault()
handleExtractRegion()
}
if (e.key === "Escape") { setExtractNamePrompt(false); setExtractName("") }
}}
placeholder="给这个元素起个中文名(如:左下角药瓶)"
className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-black/40 border border-violet-300/50 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40"
/>
<div className="flex items-center gap-1.5">
<button
onClick={handleExtractRegion}
disabled={extracting || !extractName.trim()}
className="flex-1 px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
>
{extracting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{extracting ? "提取中…5-15 秒)" : "✓ 提取"}
</button>
<button
onClick={() => { setExtractNamePrompt(false); setExtractName("") }}
className="px-2 py-1.5 rounded-md text-[11px] bg-white/10 hover:bg-white/20 text-white"
title="返回"
>
<ChevronLeft className="h-3 w-3" />
</button>
</div>
</div>
) : (
// 画框模式 — 可连续画多框
<div className="space-y-1.5">
<div className="text-[10px] text-white/55 leading-snug">
{regions.length === 0
? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)"
: `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCleanup(true)}
disabled={cleaning || regions.length === 0}
className="flex-1 px-1.5 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition bg-cyan-500 hover:bg-cyan-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
title="批量清洗所有框内"
>
{cleaning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
{cleaning ? "去掉中" : `✓ 去掉${regions.length > 1 ? ` ${regions.length}` : ""}`}
</button>
<button
onClick={() => { if (regions.length === 1) setExtractNamePrompt(true) }}
disabled={regions.length !== 1}
className="flex-1 px-1.5 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-30 disabled:cursor-not-allowed"
title={regions.length === 1 ? "提取框内为元素" : regions.length === 0 ? "先画一个框" : "提取仅在恰好 1 个框时可用"}
>
<Wand2 className="h-3 w-3" />
🪄
</button>
<button
onClick={() => setRegions((prev) => prev.slice(0, -1))}
disabled={regions.length === 0}
className="px-1.5 py-1.5 rounded-md text-[10.5px] bg-white/10 hover:bg-white/20 text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="撤销上一个框"
>
</button>
<button
onClick={() => { setCropMode(false); setRegions([]); setDraftRegion(null); setDragStart(null) }}
className="px-1.5 py-1.5 rounded-md text-[10.5px] bg-white/10 hover:bg-white/20 text-white"
title="退出画框"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
)
) : (
<button
onClick={() => { setCropMode(true); setRegions([]) }}
className="w-full px-3 py-1.5 rounded-md text-[10.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-white/[0.06] hover:bg-cyan-500/30 border border-white/15 hover:border-cyan-300/50 text-white/80 hover:text-white"
title="可连续画多个框 · 批量清洗或单框提取"
>
<Crop className="h-3 w-3" />
📐
</button>
)}
{/* 下方:清洗版(有待应用版本时显示) */}
{hasCleaned && cleanedSrc && (
<div className="rounded-lg border border-emerald-400/40 bg-emerald-500/5 p-2 space-y-1.5">
<div className="flex items-center justify-between">
<div className="text-[10px] text-emerald-300 inline-flex items-center gap-1 font-medium">
<Sparkle className="h-2.5 w-2.5" />
</div>
<span className="text-[9px] text-white/40"></span>
</div>
<img
src={cleanedSrc}
alt={`cleaned ${f.index}`}
className="rounded-md object-contain w-full"
style={{ maxHeight: "32vh" }}
/>
<button
onClick={handleApplyCleaned}
disabled={applying}
className="w-full px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-40 disabled:cursor-not-allowed"
title="替换原图为这张干净版 · 后续抠图 / 分镜头编排都基于干净版"
>
{applying ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{applying ? "替换中…" : "✓ 替换原图"}
</button>
</div>
)}
{/* 清洗按钮(全图) */}
<button
onClick={() => handleCleanup(false)}
disabled={cleaning || cropMode}
className="w-full px-3 py-1.5 rounded-md text-[11.5px] font-medium inline-flex items-center justify-center gap-1.5 transition bg-gradient-to-r from-cyan-500/80 to-emerald-500/80 hover:from-cyan-500 hover:to-emerald-500 text-white disabled:opacity-40 disabled:cursor-not-allowed"
title="清掉水印 / @用户名 / 字幕 / 平台 logo"
>
{cleaning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkle className="h-3.5 w-3.5" />}
{cleaning ? "清洗中…5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "🧹 清洗水印"}
</button>
<button
onClick={() => onToggleSelect(f.index)}
className={`w-full px-3 py-1.5 rounded-md text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition ${
isSelected
? "bg-emerald-500 text-white hover:bg-emerald-400"
: "bg-white/10 text-white hover:bg-white/20"
}`}
>
<Check className="h-3.5 w-3.5" />
{isSelected ? "已选用" : "选用此帧"}
</button>
</div>
{/* 右侧识别 + 元素清单 */}
<div className="flex flex-col gap-2.5 overflow-y-auto flex-1 min-h-0">
{/* 识别 */}
<section>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-white text-[12.5px] font-semibold">
<Eye className="h-3.5 w-3.5" />
Vision
{desc && <span className="text-[10px] text-emerald-400 font-mono ml-1"></span>}
</div>
<button
onClick={handleDescribe}
disabled={describing}
className="text-[10.5px] text-white/70 hover:text-white px-2 py-0.5 rounded border border-white/20 hover:border-white/40 disabled:opacity-50 inline-flex items-center gap-1"
title="调 Gemini Vision 识别画面元素"
>
{describing ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />}
{desc ? "重新识别" : "识别"}
</button>
</div>
{!desc ? (
<div className="rounded-lg border border-dashed border-white/15 bg-white/[0.03] p-3 text-[11.5px] text-white/50 leading-relaxed">
{describing ? (
<div className="flex items-center gap-1.5 text-white/60">
<Loader2 className="h-3 w-3 animate-spin" />
Gemini Vision
</div>
) : (
<> Gemini / / </>
)}
</div>
) : (
<div className="space-y-2.5 text-[11.5px]">
{desc.scene && (
<div className="rounded-md bg-violet-500/10 border border-violet-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-violet-300 mb-1"></div>
<div className="text-white leading-relaxed">{desc.scene}</div>
</div>
)}
{desc.style && (
<div className="rounded-md bg-amber-500/10 border border-amber-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-amber-300 mb-1"></div>
<div className="text-white leading-relaxed">{desc.style}</div>
</div>
)}
{desc.objects && desc.objects.length > 0 && (
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300/90 mb-1.5">
·
</div>
<div className="space-y-1">
{desc.objects.map((o, i) => {
const alreadyIn = elements.some((e) => e.name_zh === o.name)
return (
<button
key={i}
onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")}
disabled={alreadyIn}
title={alreadyIn ? "已加入元素清单" : (o.position ? `位置:${o.position}` : undefined)}
className={`w-full text-left rounded border px-2 py-1 transition group/o ${
alreadyIn
? "bg-emerald-500/10 border-emerald-400/30 cursor-default"
: "bg-white/[0.03] hover:bg-white/[0.08] border-white/8 hover:border-pink-300/40"
}`}
>
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
<span className="truncate">{o.name}</span>
{alreadyIn ? (
<Check className="h-2.5 w-2.5 text-emerald-300 shrink-0 ml-auto" />
) : (
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
)}
</div>
{o.extract_prompt && (
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</section>
{/* 元素清单(持久化) */}
<section>
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
<Sparkles className="h-3.5 w-3.5" />
{elements.length > 0 && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {elements.length}</span>
)}
<span className="text-[9.5px] text-white/35 font-normal ml-auto"> </span>
</div>
{elements.length > 0 && (
<div className="space-y-1.5 mb-2">
{elements.map((e) => {
const isCutting = cuttingId === e.id
const hasCutout = !!e.cutout_id
const bg = e.cutout_background ?? "white"
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"
>
{/* 抠图缩略图 / 占位真实底色white/black */}
<div
className="flex-shrink-0 rounded border border-white/8 flex items-center justify-center overflow-hidden"
style={{ width: 36, height: 36, background: hasCutout ? (bg === "black" ? "#000" : "#fff") : "rgba(0,0,0,0.4)" }}
>
{hasCutout ? (
<a
href={cutoutUrl(jobId, f.index, e.id)}
target="_blank"
rel="noreferrer"
title={`点击查看 · ${bg === "white" ? "白底" : "黑底"}`}
className="block w-full h-full"
>
<img
src={cutoutUrl(jobId, f.index, e.id)}
alt={e.name_zh}
className="w-full h-full object-contain"
/>
</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>
)}
{hasCutout && (
<span className={`text-[8.5px] font-mono uppercase ${bg === "white" ? "text-white/80" : "text-white/50"}`}>
{bg}
</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>
{/* 底色 toggle — 选 white/black 后点抠图 */}
<div className="shrink-0 inline-flex rounded overflow-hidden border border-white/15">
<button
onClick={() => handleCutout(e.id, "white")}
disabled={isCutting}
title="白底抠图"
className={`text-[9.5px] px-1.5 py-0.5 transition ${
hasCutout && bg === "white"
? "bg-white text-black"
: "bg-white/[0.05] text-white/70 hover:bg-white/15"
}`}
>W</button>
<button
onClick={() => handleCutout(e.id, "black")}
disabled={isCutting}
title="黑底抠图"
className={`text-[9.5px] px-1.5 py-0.5 transition ${
hasCutout && bg === "black"
? "bg-black text-white"
: "bg-black/30 text-white/70 hover:bg-black/60"
}`}
>B</button>
</div>
{/* 抠图状态指示(不再是按钮,因为 W/B toggle 即触发) */}
{isCutting ? (
<span className="shrink-0 text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-1 bg-violet-500/30 text-white/90">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
) : !hasCutout ? (
<span className="shrink-0 text-[9.5px] text-white/35 self-center"></span>
) : null}
{/* 删除 */}
<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>
)
})}
</div>
)}
{/* 添加 */}
<div className="flex items-center gap-1.5">
<input
value={addInput}
onChange={(ev) => setAddInput(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter" && !ev.nativeEvent.isComposing) {
ev.preventDefault()
if (addInput.trim() && !addingZh) {
handleAddElement(addInput)
setAddInput("")
}
}
}}
placeholder="加自定义元素 · 中文回车自动翻英文"
className="flex-1 text-[12px] px-2.5 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"
/>
<button
onClick={() => { if (addInput.trim() && !addingZh) { handleAddElement(addInput); setAddInput("") } }}
disabled={!addInput.trim() || addingZh}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-violet-500/60 hover:bg-violet-500/80 text-white inline-flex items-center justify-center gap-1 disabled:opacity-30 disabled:cursor-not-allowed transition shrink-0"
title="添加"
>
{addingZh ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-1.5 text-[10px] text-white/35 leading-relaxed">
·
</div>
</section>
</div>
</div>
<div className="px-4 py-1.5 text-[10px] text-white/40 font-mono text-center border-t border-white/5 bg-white/[0.02]">
/ · Space · ESC
</div>
</div>
)
return embedded ? content : createPortal(content, document.body)
}