auto-save 2026-05-13 09:37 (~4)
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw } from "lucide-react"
|
||||
import { frameUrl, describeFrame, type KeyFrame, type Job } from "@/lib/api"
|
||||
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus } from "lucide-react"
|
||||
import { frameUrl, describeFrame, translateText, type KeyFrame, type Job } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type CustomItem = { id: string; zh: string; en: string; translating: boolean }
|
||||
|
||||
interface Props {
|
||||
jobId: string
|
||||
frames: KeyFrame[]
|
||||
@@ -18,11 +20,38 @@ interface Props {
|
||||
}
|
||||
|
||||
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, embedded = false }: Props) {
|
||||
const [extractPrompt, setExtractPrompt] = useState("")
|
||||
const [describing, setDescribing] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
// 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的
|
||||
const [customsByFrame, setCustomsByFrame] = useState<Record<number, CustomItem[]>>({})
|
||||
const [addInput, setAddInput] = useState("")
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : []
|
||||
const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => {
|
||||
if (activeIndex === null) return
|
||||
const key = activeIndex
|
||||
setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) }))
|
||||
}
|
||||
|
||||
const addCustom = async (zhRaw: string, presetEn?: string) => {
|
||||
const zh = zhRaw.trim()
|
||||
if (!zh || activeIndex === null) return
|
||||
const id = Math.random().toString(36).slice(2, 8)
|
||||
const skipTranslate = !!presetEn
|
||||
updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }])
|
||||
if (skipTranslate) return
|
||||
try {
|
||||
const en = await translateText(zh, "en")
|
||||
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c))
|
||||
} catch (e) {
|
||||
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c))
|
||||
toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id))
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex === null) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -185,23 +214,24 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
</div>
|
||||
)}
|
||||
{desc.objects && desc.objects.length > 0 && (
|
||||
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-2">
|
||||
<div className="text-[9.5px] uppercase tracking-widest text-pink-300 mb-1.5">
|
||||
物体(点击 → 自动填入提取框)
|
||||
<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.5">
|
||||
<div className="space-y-1">
|
||||
{desc.objects.map((o, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setExtractPrompt(o.extract_prompt || o.name)}
|
||||
className="w-full text-left rounded bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 hover:border-pink-300/40 px-2 py-1.5 transition"
|
||||
onClick={() => addCustom(o.name, o.extract_prompt)}
|
||||
title={o.position ? `位置:${o.position}` : undefined}
|
||||
className="w-full text-left rounded bg-white/[0.03] hover:bg-white/[0.08] border border-white/8 hover:border-pink-300/40 px-2 py-1 transition group/o"
|
||||
>
|
||||
<div className="text-white text-[11.5px] font-medium">
|
||||
{o.name}
|
||||
{o.position && <span className="text-white/40 ml-1.5 text-[10px]">· {o.position}</span>}
|
||||
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
|
||||
<span className="truncate">{o.name}</span>
|
||||
<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-[10px] text-white/40 mt-0.5 truncate font-mono">{o.extract_prompt}</div>
|
||||
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -212,29 +242,81 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
||||
)}
|
||||
</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" />
|
||||
自定义提取元素
|
||||
{customs.length > 0 && (
|
||||
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {customs.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={extractPrompt}
|
||||
onChange={(e) => setExtractPrompt(e.target.value)}
|
||||
placeholder="比如:rightmost white bottle"
|
||||
rows={2}
|
||||
className="w-full 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 resize-none focus:ring-2 focus:ring-violet-400/50"
|
||||
/>
|
||||
|
||||
{customs.length > 0 && (
|
||||
<div className="space-y-1.5 mb-2">
|
||||
{customs.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="pr-5 text-white text-[11.5px] font-medium leading-tight truncate">{c.zh}</div>
|
||||
<div className="pr-5 mt-0.5 text-[10px] font-mono leading-tight truncate">
|
||||
{c.translating ? (
|
||||
<span className="inline-flex items-center gap-1 text-white/40">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" /> 翻译中…
|
||||
</span>
|
||||
) : c.en ? (
|
||||
<span className="text-white/45">{c.en}</span>
|
||||
) : (
|
||||
<span className="text-rose-300/60">翻译失败 · 点击重试</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCustom(c.id)}
|
||||
className="absolute right-1.5 top-1.5 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={(e) => setAddInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
if (addInput.trim()) {
|
||||
addCustom(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()) { addCustom(addInput); setAddInput("") } }}
|
||||
disabled={!addInput.trim()}
|
||||
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="添加"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="mt-2 w-full text-[12px] py-1.5 rounded-md bg-violet-500/60 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
|
||||
className="mt-2 w-full text-[11.5px] py-1.5 rounded-md bg-violet-500/40 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
|
||||
title="即将上线:批量调 nano-banana-pro image edit"
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
⚡ 快速提取(下一步实现)
|
||||
⚡ 批量快速提取(下一步实现)
|
||||
</button>
|
||||
<div className="mt-1.5 text-[10px] text-white/30 font-mono">
|
||||
下一步:调 nano-banana-pro image edit
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 已提取 */}
|
||||
|
||||
Reference in New Issue
Block a user