Files
20260512-skg-tk/web/components/lightbox.tsx
2026-05-14 05:21:54 +08:00

1224 lines
62 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, Copy, PencilLine, Trash2, Save } from "lucide-react"
import {
frameUrl, cleanedFrameUrl, cutoutUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, cutoutElement, deleteCutout,
pushStoryboardImage, generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind,
} 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
onCopyImage?: (ref: ImageRef) => void
embedded?: boolean
}
const OBJECT_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["left", "左侧"],
["right", "右侧"],
["top", "顶部"],
["bottom", "底部"],
]
const LIVING_VIEW_OPTIONS = [
["front", "正面"],
["back", "背面"],
["side", "侧面"],
["side_walk", "走路"],
["expression_happy", "喜"],
["expression_angry", "怒"],
["expression_sad", "哀"],
["expression_relaxed", "乐/放松"],
["action_sit", "坐"],
["action_hold", "手持"],
["action_use", "使用"],
]
type LightboxTab = "clean" | "scene" | "subject" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "scene", label: "场景图" },
{ key: "subject", label: "主体包" },
{ key: "review", label: "审核" },
]
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, 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 [sceneGenerating, setSceneGenerating] = useState(false)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [addingZh, setAddingZh] = useState(false)
const [addInput, setAddInput] = useState("")
const [assetSize, setAssetSize] = useState<AssetSize>("source")
const [subjectKinds, setSubjectKinds] = useState<Record<string, SubjectKind>>({})
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
const [activeTab, setActiveTab] = useState<LightboxTab>("clean")
const [editingElement, setEditingElement] = useState<{
id: string
name_zh: string
name_en: string
position: string
} | null>(null)
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 latestSceneAsset = f.scene_assets?.[f.scene_assets.length - 1] ?? null
const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b)
const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index]
const subjectAssetCount = elements.reduce((sum, item) => sum + (item.subject_assets?.length ?? 0), 0)
const cutoutCount = elements.reduce((sum, item) => sum + ((item.cutouts?.length ?? 0) || (item.cutout_id ? 1 : 0)), 0)
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
...(latestSceneAsset?.quality_report?.warnings ?? []),
]
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 handleGenerateSceneAsset = async () => {
setSceneGenerating(true)
try {
const updated = await generateSceneAsset(jobId, f.index, { size: assetSize })
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 场景图已生成`)
} catch (e) {
toast.error("场景图生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSceneGenerating(false)
}
}
const handleGenerateSubjectPackage = async (elementId: string) => {
const kind = subjectKinds[elementId] ?? "object"
const defaultViews = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
const views = subjectViews[elementId]?.length ? subjectViews[elementId] : defaultViews
setSubjectGenerating(elementId)
try {
const updated = await generateSubjectAssets(jobId, f.index, elementId, {
subject_kind: kind,
background: subjectBackgrounds[elementId] ?? "white",
size: assetSize,
source_frame_indices: sharedSubjectFrameIndices,
views,
})
onJobUpdate?.(updated)
toast.success(`主体资产包已生成 · ${views.length}`)
} catch (e) {
toast.error("主体资产包生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setSubjectGenerating(null)
}
}
const toggleSubjectView = (elementId: string, view: string, kind: SubjectKind) => {
const defaults = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
setSubjectViews((prev) => {
const current = prev[elementId] ?? defaults
const next = current.includes(view) ? current.filter((x) => x !== view) : [...current, view]
return { ...prev, [elementId]: next }
})
}
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 handleDiscardCleaned = async () => {
try {
const updated = await discardCleanedFrame(jobId, f.index)
onJobUpdate?.(updated)
toast.success(`已丢弃清洗版`)
} catch (e) {
toast.error("丢弃失败:" + (e instanceof Error ? e.message : String(e)))
}
}
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)
if (editingElement?.id === id) setEditingElement(null)
} catch (e) {
toast.error("删除失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleUpdateElement = async () => {
if (!editingElement || !editingElement.name_zh.trim()) return
try {
const updated = await updateElement(jobId, f.index, editingElement.id, {
name_zh: editingElement.name_zh,
name_en: editingElement.name_en,
position: editingElement.position,
})
onJobUpdate?.(updated)
setEditingElement(null)
toast.success("元素已更新")
} catch (e) {
toast.error("更新失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleCutout = async (id: string) => {
setCuttingId(id)
try {
const updated = await cutoutElement(jobId, f.index, id)
onJobUpdate?.(updated)
toast.success("提取完成")
} catch (e) {
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setCuttingId(null)
}
}
const handlePushCutout = async (elementId: string, cutoutId: string, label: string, isLegacy: boolean) => {
if (activeIndex === null) return
try {
const updated = await pushStoryboardImage(jobId, {
kind: "cutout",
frame_idx: activeIndex,
element_id: elementId,
cutout_id: isLegacy ? elementId : cutoutId, // legacy 兼容
label,
})
onJobUpdate?.(updated)
toast.success("已推送到分镜头编排")
} catch (e) {
toast.error("推送失败:" + (e instanceof Error ? e.message : String(e)))
}
}
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 = (() => {
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={embedded
? "h-full overflow-hidden flex flex-col"
: "fixed z-[100] rounded-2xl border border-white/15 bg-black/70 backdrop-blur-2xl overflow-hidden flex flex-col"}
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)",
}}
>
{/* 顶部工具栏 */}
{!embedded && (
<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 items-center gap-1 border-b border-white/10 bg-black/28 px-3 py-2">
{LIGHTBOX_TABS.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition ${
activeTab === tab.key
? "bg-white text-black shadow"
: "bg-white/[0.06] text-white/58 hover:bg-white/[0.12] hover:text-white"
}`}
>
{tab.label}
</button>
))}
<div className="ml-auto hidden items-center gap-2 text-[10px] text-white/42 sm:flex">
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</span>
<span>·</span>
<span>{subjectAssetCount > 0 ? `${subjectAssetCount} 主体资产` : "主体待生成"}</span>
</div>
</div>
{/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
<div
className="flex flex-col items-stretch gap-2 overflow-y-auto pr-1"
style={{ flex: "1 1 320px", minWidth: 200, maxWidth: 420, minHeight: 0 }}
>
{/* 上方:主图 + 画框 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>
{activeTab === "clean" && (
<>
{/* 画框工具栏 */}
{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="relative rounded-lg border border-emerald-400/40 bg-emerald-500/5 p-2 space-y-1.5">
<div className="flex items-center justify-between pr-5">
<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>
<button
onClick={handleDiscardCleaned}
title="丢弃这次清洗结果"
className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-black/40 hover:bg-rose-500/80 text-white/70 hover:text-white inline-flex items-center justify-center transition"
>
<X className="h-2.5 w-2.5" />
</button>
<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>
</>
)}
{activeTab === "scene" && (
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11.5px] font-semibold text-white"></div>
<select
value={assetSize}
onChange={(e) => setAssetSize(e.target.value as AssetSize)}
className="rounded border border-white/10 bg-black/35 px-1.5 py-0.5 text-[10px] text-white/75 outline-none"
title="资产输出尺寸"
>
<option value="source"></option>
<option value="1024">1024</option>
<option value="1536">1536</option>
<option value="2048">2048</option>
</select>
</div>
{latestSceneAsset ? (
<div className="mb-2 overflow-hidden rounded-md border border-emerald-300/25 bg-black/30">
<img src={apiAssetUrl(latestSceneAsset.url)} alt={latestSceneAsset.label} className="max-h-36 w-full object-contain bg-black" />
<div className="flex items-center justify-between gap-2 border-t border-white/10 px-2 py-1 text-[9.5px] text-white/50">
<span>{latestSceneAsset.width}×{latestSceneAsset.height}</span>
{onCopyImage && (
<button
type="button"
onClick={() => onCopyImage({ kind: "asset", frame_idx: f.index, element_id: latestSceneAsset.id, cutout_id: latestSceneAsset.id, label: latestSceneAsset.label })}
className="inline-flex items-center gap-1 rounded bg-violet-500/70 px-1.5 py-0.5 text-white hover:bg-violet-400"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
) : null}
{latestSceneAsset?.quality_report?.warnings?.length ? (
<div className="mb-2 rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1 text-[10px] leading-snug text-amber-100/85">
{latestSceneAsset.quality_report.warnings[0]}
</div>
) : null}
<button
type="button"
onClick={handleGenerateSceneAsset}
disabled={sceneGenerating || cleaning}
className="w-full rounded-md bg-emerald-500/65 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
title="生成一张去水印、高清增强后的场景参考图"
>
{sceneGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成场景图"}
</button>
</div>
)}
{activeTab === "review" && (
<div className="space-y-2 rounded-lg border border-white/10 bg-white/[0.035] p-2">
<div className="text-[11.5px] font-semibold text-white"></div>
<div className="grid grid-cols-2 gap-1.5 text-[10.5px]">
<div className={`rounded border px-2 py-1 ${f.cleaned_applied || hasCleaned ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{f.cleaned_applied ? "已应用清洗" : hasCleaned ? "清洗待确认" : "未清洗"}
</div>
<div className={`rounded border px-2 py-1 ${latestSceneAsset ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{latestSceneAsset ? "场景图已生成" : "场景图未生成"}
</div>
<div className={`rounded border px-2 py-1 ${subjectAssetCount > 0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
{subjectAssetCount > 0 ? `${subjectAssetCount} 张主体资产` : "主体包未生成"}
</div>
<div className={`rounded border px-2 py-1 ${qualityWarnings.length ? "border-amber-300/35 bg-amber-500/12 text-amber-100" : "border-emerald-300/30 bg-emerald-500/10 text-emerald-100"}`}>
{qualityWarnings.length ? `${qualityWarnings.length} 个风险` : "质量可用"}
</div>
</div>
{qualityWarnings.length > 0 && (
<div className="rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
{qualityWarnings.slice(0, 3).map((warning, i) => (
<div key={i}>{warning}</div>
))}
</div>
)}
<div className="text-[10px] leading-relaxed text-white/42">
</div>
</div>
)}
<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" style={{ minWidth: 240 }}>
{activeTab === "clean" && (
<section className="rounded-lg border border-cyan-300/15 bg-cyan-500/[0.08] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-1.5 text-[12.5px] font-semibold text-white"></div>
UI
</section>
)}
{activeTab === "scene" && (
<section className="rounded-lg border border-emerald-300/15 bg-emerald-500/[0.08] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-1.5 text-[12.5px] font-semibold text-white"></div>
线
{f.scene_assets?.length ? (
<div className="mt-2 grid grid-cols-2 gap-2">
{f.scene_assets.slice(-4).map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-md border border-white/10 bg-black/35">
<img src={apiAssetUrl(asset.url)} alt={asset.label} className="h-24 w-full object-contain" />
<div className="px-1.5 py-1 text-[9.5px] font-mono text-white/45">{asset.width}×{asset.height}</div>
</div>
))}
</div>
) : null}
</section>
)}
{activeTab === "review" && (
<section className="rounded-lg border border-white/10 bg-white/[0.035] p-3 text-[11px] leading-relaxed text-white/58">
<div className="mb-2 text-[12.5px] font-semibold text-white"></div>
<div className="space-y-1.5">
<div>{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}</div>
<div>{latestSceneAsset ? `${latestSceneAsset.width}×${latestSceneAsset.height}` : "未生成"}</div>
<div>{cutoutCount} </div>
<div>{subjectAssetCount} </div>
</div>
</section>
)}
{/* 识别 */}
<section className={activeTab === "subject" ? "" : "hidden"}>
<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 className={activeTab === "subject" ? "" : "hidden"}>
<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>
<div className="mb-2 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-[10.5px] leading-relaxed text-white/45">
Vision
</div>
{elements.length > 0 && (
<div className="space-y-2 mb-2">
{elements.map((e) => {
const isCutting = cuttingId === e.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
const isEditing = editingElement?.id === e.id
const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object"
const currentBg = subjectBackgrounds[e.id] ?? e.cutout_background ?? "white"
const viewOptions = currentKind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS
const activeViews = subjectViews[e.id] ?? viewOptions.map(([value]) => value)
const subjectAssets = e.subject_assets ?? []
const isSubjectGenerating = subjectGenerating === e.id
return (
<div
key={e.id}
className={`relative rounded-md border p-2 ${
isEditing
? "bg-violet-500/10 border-violet-300/45"
: "bg-white/[0.04] border-white/10"
}`}
>
{/* 顶部:名字 + 操作 */}
<div className="mb-2 space-y-2">
{isEditing ? (
<div className="space-y-1.5">
<input
value={editingElement.name_zh}
onChange={(ev) => setEditingElement({ ...editingElement, name_zh: ev.target.value })}
placeholder="元素中文名"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[12px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
value={editingElement.name_en}
onChange={(ev) => setEditingElement({ ...editingElement, name_en: ev.target.value })}
placeholder="英文提取提示,可手动修正"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] font-mono text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
<input
value={editingElement.position}
onChange={(ev) => setEditingElement({ ...editingElement, position: ev.target.value })}
placeholder="位置 / 备注,例如:画面左下角、手里拿着"
className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60"
/>
</div>
) : (
<div className="min-w-0">
<div className="flex items-center gap-1 text-white text-[12px] font-medium leading-tight">
<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>
{e.position && (
<div className="mt-0.5 text-[9.5px] leading-tight truncate text-white/35">
{e.position}
</div>
)}
</div>
)}
<div className="flex items-center gap-1">
{isEditing ? (
<>
<button
onClick={handleUpdateElement}
disabled={!editingElement.name_zh.trim()}
className="flex-1 rounded px-2 py-1 text-[10.5px] font-medium inline-flex items-center justify-center gap-1 bg-violet-500 hover:bg-violet-400 text-white disabled:opacity-40"
>
<Save className="h-3 w-3" />
</button>
<button
onClick={() => setEditingElement(null)}
className="rounded px-2 py-1 text-[10.5px] bg-white/10 hover:bg-white/20 text-white"
>
</button>
</>
) : (
<>
<button
onClick={() => setEditingElement({
id: e.id,
name_zh: e.name_zh,
name_en: e.name_en || "",
position: e.position || "",
})}
className="rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-white/8 hover:bg-white/15 text-white/75 hover:text-white border border-white/10"
>
<PencilLine className="h-3 w-3" />
</button>
<button
onClick={() => handleCutout(e.id)}
disabled={isCutting}
title={`${hasAny ? "再提取一张" : "AI 提取"} · 模型识别 + 补全缺失部分(如缺手脚 / 半个台灯)→ 完整清晰白底图5-15s`}
className="flex-1 rounded px-2 py-1 text-[10.5px] font-medium inline-flex items-center justify-center gap-1 transition disabled:opacity-50 disabled:cursor-not-allowed bg-violet-500/30 text-white/90 hover:bg-violet-500/50"
>
{isCutting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkle className="h-3 w-3" />}
{isCutting ? "提取中…" : hasAny ? "再提取" : "AI 提取"}
</button>
<button
onClick={() => handleDeleteElement(e.id)}
className="rounded px-2 py-1 text-[10.5px] inline-flex items-center gap-1 bg-rose-500/15 hover:bg-rose-500/30 text-rose-100 border border-rose-300/20"
title="删除整个元素和它的提取图"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
</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="relative rounded-md border border-white/10 bg-white overflow-hidden"
style={{ width: 92, height: 112 }}
>
<div className="h-[80px] w-full bg-white">
<img src={url} alt={`${e.name_zh} #${ci + 1}`} className="w-full h-full object-contain" />
</div>
{/* 序号 */}
<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>
<div className="flex h-8 border-t border-black/10 bg-black text-white">
{onCopyImage && (
<button
onClick={(ev) => {
ev.preventDefault(); ev.stopPropagation()
const isLegacy = !(e.cutouts && e.cutouts.length > 0)
onCopyImage({
kind: "cutout",
frame_idx: f.index,
element_id: e.id,
cutout_id: isLegacy ? e.id : cid,
label: `${e.name_zh} #${ci + 1}`,
})
}}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-violet-500/70 transition"
title="复制到分镜编排剪贴板"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
{e.cutouts && e.cutouts.length > 0 && (
<button
onClick={(ev) => { ev.preventDefault(); handleDeleteCutout(e.id, cid) }}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-rose-500/80 transition border-l border-white/10"
title="删除这张错误提取图"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
)
})}
</div>
)}
<div className="mt-2 rounded-md border border-violet-300/15 bg-violet-500/[0.08] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[11px] font-semibold text-white/90"></div>
<span className="text-[9.5px] font-mono text-white/35">
{sharedSubjectFrameIndices.length > 1 ? `${sharedSubjectFrameIndices.length} 帧参考` : "当前帧参考"}
</span>
</div>
<div className="mb-2 grid grid-cols-3 gap-1">
<select
value={currentKind}
onChange={(ev) => {
const next = ev.target.value as SubjectKind
setSubjectKinds((prev) => ({ ...prev, [e.id]: next }))
setSubjectViews((prev) => ({ ...prev, [e.id]: (next === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value) }))
}}
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
<option value="object"></option>
<option value="living">/</option>
</select>
<select
value={currentBg}
onChange={(ev) => setSubjectBackgrounds((prev) => ({ ...prev, [e.id]: ev.target.value as AssetBackground }))}
className="rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
<option value="white"></option>
<option value="black"></option>
</select>
<button
type="button"
onClick={() => handleGenerateSubjectPackage(e.id)}
disabled={isSubjectGenerating || activeViews.length === 0}
className="rounded bg-violet-500/70 px-1.5 py-1 text-[10px] font-medium text-white transition hover:bg-violet-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
title="生成多视角 / 动作 / 表情主体资产"
>
{isSubjectGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wand2 className="h-3 w-3" />}
{isSubjectGenerating ? "生成" : "生成"}
</button>
</div>
<div className="mb-2 flex flex-wrap gap-1">
{viewOptions.map(([value, label]) => {
const active = activeViews.includes(value)
return (
<button
key={value}
type="button"
onClick={() => toggleSubjectView(e.id, value, currentKind)}
className={`rounded border px-1.5 py-0.5 text-[9.5px] transition ${
active
? "border-violet-300/60 bg-violet-500/40 text-white"
: "border-white/10 bg-black/25 text-white/45 hover:text-white"
}`}
>
{label}
</button>
)
})}
</div>
{subjectAssets.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{subjectAssets.slice(-12).map((asset) => (
<div key={asset.id} className="relative overflow-hidden rounded-md border border-white/10 bg-white" style={{ width: 88, height: 112 }}>
<img src={apiAssetUrl(asset.url)} alt={asset.label} className="h-[82px] w-full object-contain" />
<div className="absolute left-0 top-0 rounded-br bg-black/70 px-1 text-[8.5px] text-white">
{asset.label.replace(`${e.name_zh} · `, "")}
</div>
<div className="flex h-[30px] border-t border-black/10 bg-black text-white">
{onCopyImage && (
<button
type="button"
onClick={(ev) => {
ev.preventDefault(); ev.stopPropagation()
onCopyImage({
kind: "asset",
frame_idx: f.index,
element_id: asset.id,
cutout_id: asset.id,
label: asset.label,
})
}}
className="flex-1 inline-flex items-center justify-center gap-1 text-[9.5px] hover:bg-violet-500/70 transition"
>
<Copy className="h-2.5 w-2.5" />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</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)
}