"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { ArrowLeft, Clapperboard, Copy, FileText, Image as ImageIcon, Loader2, Play, RefreshCw, Sparkles, Wand2, } from "lucide-react" import { Toaster, toast } from "sonner" import { MediaAssetTile } from "@/components/media-asset-tile" import { apiAssetUrl, deleteGeneratedImage, deleteGeneratedVideo, generateCreativeCopy, generateImage, generateStoryboardVideo, getJob, listJobs, type CreativeCopyVariant, type GeneratedImage, type GeneratedVideo, type Job, type JobSummary, } from "@/lib/api" type ImageItem = GeneratedImage & { frameIdx: number } type BusyTask = "image" | "video" | "copy" | "load" | null function cx(...items: Array) { return items.filter(Boolean).join(" ") } function jobTitle(item: Job | JobSummary | null) { if (!item) return "未选择任务" const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "") return raw || item.id } function sourceFrameSrc(job: Job | null) { return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : "" } function videoSrc(job: Job, video: GeneratedVideo) { return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`) } function imageItems(job: Job | null): ImageItem[] { if (!job) return [] return job.frames .flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index }))) .sort((a, b) => b.created_at - a.created_at) } function createdLabel(ts?: number) { if (!ts) return "" return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) } export default function DetailPage() { const [jobId, setJobId] = useState("") const [job, setJob] = useState(null) const [recentJobs, setRecentJobs] = useState([]) const [prompt, setPrompt] = useState("") const [product, setProduct] = useState("SKG 颈部按摩仪") const [audience, setAudience] = useState("久坐办公、低头刷手机的人群") const [tone, setTone] = useState("真实、直接、有购买理由") const [seconds, setSeconds] = useState(12) const [copyVariants, setCopyVariants] = useState([]) const [busy, setBusy] = useState(null) const [error, setError] = useState("") const images = useMemo(() => imageItems(job), [job]) const videos = useMemo(() => job?.generated_videos ?? [], [job]) const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress") const loadJob = useCallback(async (id: string) => { if (!id) return setBusy("load") setError("") try { const loaded = await getJob(id) setJob(loaded) setJobId(id) window.history.replaceState(null, "", `/detail/?job=${id}`) } catch (e) { const message = e instanceof Error ? e.message : "读取任务失败" setError(message) toast.error(message) } finally { setBusy(null) } }, []) const refreshJobs = useCallback(async () => { try { setRecentJobs(await listJobs(20)) } catch { setRecentJobs([]) } }, []) useEffect(() => { const id = new URLSearchParams(window.location.search).get("job") || "" setJobId(id) refreshJobs() if (id) loadJob(id) }, [loadJob, refreshJobs]) useEffect(() => { if (!job || !runningVideo) return let failures = 0 const timer = window.setInterval(async () => { try { setJob(await getJob(job.id)) failures = 0 } catch { // one transient 5xx / network blip must not freeze progress forever; // only give up after sustained failures failures += 1 if (failures >= 10) window.clearInterval(timer) } }, 2600) return () => window.clearInterval(timer) }, [job, runningVideo]) const requireJobAndPrompt = () => { if (!job) { toast.error("先选择任务") return false } if (!prompt.trim()) { toast.error("先写生成要求") return false } return true } const runImage = async () => { if (!requireJobAndPrompt() || !job) return setBusy("image") setError("") try { setJob(await generateImage(job.id, 0, { prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`, mode: sourceFrameSrc(job) ? "edit" : "text", })) toast.success("图片已生成") } catch (e) { const message = e instanceof Error ? e.message : "生图失败" setError(message) toast.error(message) } finally { setBusy(null) } } const runVideo = async () => { if (!requireJobAndPrompt() || !job) return setBusy("video") setError("") try { setJob(await generateStoryboardVideo(job.id, 0, { prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`, duration: seconds, count: 1, first_image: { kind: "keyframe", frame_idx: 0 }, size: "720x1280", })) toast.success("视频已提交") } catch (e) { const message = e instanceof Error ? e.message : "生视频失败" setError(message) toast.error(message) } finally { setBusy(null) } } const runCopy = async () => { const goal = prompt.trim() || `${product} ${audience}` setBusy("copy") setError("") try { const result = await generateCreativeCopy({ goal, product, audience, tone, seconds }) setCopyVariants(result.variants) toast.success("图文方案已生成") } catch (e) { const message = e instanceof Error ? e.message : "写文案失败" setError(message) toast.error(message) } finally { setBusy(null) } } const deleteImage = async (image: ImageItem) => { if (!job) return try { setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id)) toast.success("图片已删除") } catch (e) { toast.error(e instanceof Error ? e.message : "删除失败") } } const deleteVideo = async (video: GeneratedVideo) => { if (!job) return try { setJob(await deleteGeneratedVideo(job.id, video.id)) toast.success("视频已删除") } catch (e) { toast.error(e instanceof Error ? e.message : "删除失败") } } const copyText = async (text: string) => { try { await navigator.clipboard.writeText(text) toast.success("已复制") } catch { toast.error("复制失败") } } return (

任务详情

{jobTitle(job)}

{job ? (

生成图片

{images.length ? (
{images.map((image) => (
deleteImage(image)} />
{createdLabel(image.created_at)}
))}
) : (
暂无图片结果
)}

生成视频

{videos.length ? (
{videos.map((video) => (
deleteVideo(video)} />
{video.error ?
{video.error}
: null}
))}
) : (
暂无视频结果
)}

营销图文

{copyVariants.length ? (
{copyVariants.map((variant, index) => (

{variant.title || `方案 ${index + 1}`}

{variant.hook_zh}

{variant.script_zh}
))}
) : (
暂无图文方案
)}
) : (
请选择任务
)}