feat: redesign marketing creation workspace

This commit is contained in:
2026-05-24 01:48:17 +08:00
parent c1eddda59e
commit 828b86d187
5 changed files with 1047 additions and 293 deletions

View File

@@ -3,17 +3,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ArrowRight,
BadgeCheck,
Clapperboard,
Copy,
ExternalLink,
FileText,
Image as ImageIcon,
Layers3,
Loader2,
PenLine,
Play,
RefreshCw,
ShieldCheck,
Sparkles,
Upload,
Wand2,
X,
type LucideIcon,
} from "lucide-react"
import { Toaster, toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
@@ -34,45 +40,125 @@ import {
type JobSummary,
} from "@/lib/api"
type CreatorMode = "video" | "image" | "copy"
type BusyTask = "image" | "video" | "copy" | null
type CreationMode = "text-image" | "image-image" | "text-video" | "image-video"
type BusyTask = "image" | "video" | "copy" | "job" | null
const MODE_ITEMS: Array<{
id: CreatorMode
type ModeConfig = {
id: CreationMode
label: string
icon: typeof Clapperboard
accent: string
}> = [
{ id: "video", label: "生视频", icon: Clapperboard, accent: "from-orange-500 to-rose-500" },
{ id: "image", label: "生图", icon: ImageIcon, accent: "from-teal-500 to-cyan-500" },
{ id: "copy", label: "写文案", icon: PenLine, accent: "from-blue-500 to-indigo-500" },
short: string
output: "image" | "video"
icon: LucideIcon
needsReference: boolean
tone: string
active: string
placeholder: string
}
const CREATION_MODES: ModeConfig[] = [
{
id: "text-image",
label: "文生图",
short: "文字到图片",
output: "image",
icon: Sparkles,
needsReference: false,
tone: "border-emerald-200 bg-emerald-50 text-emerald-900",
active: "border-emerald-500 bg-emerald-600 text-white",
placeholder: "一张 9:16 信息流营销图SKG 颈部按摩仪佩戴清晰,真实办公室午休场景,画面干净,有高级感。",
},
{
id: "image-image",
label: "图生图",
short: "参考图改图",
output: "image",
icon: ImageIcon,
needsReference: true,
tone: "border-cyan-200 bg-cyan-50 text-cyan-900",
active: "border-cyan-500 bg-cyan-600 text-white",
placeholder: "保留参考图里的姿态和产品佩戴关系,换成更明亮的生活方式广告画面,产品外形不能变。",
},
{
id: "text-video",
label: "文生视频",
short: "文字到短片",
output: "video",
icon: Clapperboard,
needsReference: false,
tone: "border-orange-200 bg-orange-50 text-orange-900",
active: "border-orange-500 bg-orange-600 text-white",
placeholder: "20 秒竖屏短视频:久坐办公的人摘下耳机,戴上 SKG 颈部按摩仪,镜头缓慢推进,强调日常放松。",
},
{
id: "image-video",
label: "图生视频",
short: "参考图动起来",
output: "video",
icon: Play,
needsReference: true,
tone: "border-rose-200 bg-rose-50 text-rose-900",
active: "border-rose-500 bg-rose-600 text-white",
placeholder: "把参考图变成 12 秒竖屏视频,人物自然佩戴产品,轻微转头,产品结构保持稳定,光线真实。",
},
]
const PROMPT_TEMPLATES = [
"一张 SKG 颈部按摩仪的信息流广告首帧,真实生活方式,产品清楚可见,画面干净高级",
"把参考图里的主体变成适合 TikTok 的 9:16 产品短视频,开头 2 秒要抓人,镜头有轻微推进",
"自动写一条 20 秒 SKG 产品短视频脚本,语气直接,突出日常放松和佩戴场景",
const PROMPT_PRESETS = [
"真实办公室午休场景,产品佩戴清楚,镜头干净,适合信息流首帧。",
"年轻上班族下班回家放松,先展示疲惫状态,再自然戴上 SKG 产品。",
"白底产品功能图,高级电商质感,突出外形、佩戴方式和日常使用。",
"TikTok 竖屏短视频,前三秒强 hook产品不要变形动作自然可信。",
]
function cx(...items: Array<string | false | null | undefined>) {
return items.filter(Boolean).join(" ")
}
function allGeneratedImages(job: Job | null): GeneratedImage[] {
if (!job) return []
return job.frames.flatMap((frame) => frame.generated_images ?? []).sort((a, b) => b.created_at - a.created_at)
}
function latestGeneratedImage(job: Job | null): GeneratedImage | null {
return job?.frames?.[0]?.generated_images?.at(-1) ?? null
return allGeneratedImages(job)[0] ?? null
}
function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
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 statusLabel(status?: string) {
if (!status) return "就绪"
const map: Record<string, string> = {
created: "已创建",
downloading: "下载中",
downloaded: "已下载",
splitting: "拆轨中",
frames_extracted: "可创作",
transcribing: "识别中",
transcribed: "已解析",
failed: "失败",
}
return map[status] ?? status
}
export default function Home() {
const [mode, setMode] = useState<CreatorMode>("video")
const [mode, setMode] = useState<CreationMode>("text-video")
const [prompt, setPrompt] = useState("")
const [product, setProduct] = useState("SKG 颈部按摩仪")
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
const [tone, setTone] = useState("直接、可信、有购买理由")
const [seconds, setSeconds] = useState(20)
const [platform, setPlatform] = useState("TikTok / Reels")
const [tone, setTone] = useState("真实、直接、有购买理由")
const [seconds, setSeconds] = useState(12)
const [referenceFile, setReferenceFile] = useState<File | null>(null)
const [referencePreview, setReferencePreview] = useState("")
const [job, setJob] = useState<Job | null>(null)
@@ -82,23 +168,27 @@ export default function Home() {
const [error, setError] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const activeMode = CREATION_MODES.find((item) => item.id === mode) ?? CREATION_MODES[0]
const ActiveIcon = activeMode.icon
const images = useMemo(() => allGeneratedImages(job), [job])
const latestImage = latestGeneratedImage(job)
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job])
const hasRunningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress")
const runningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress")
const currentReference = referencePreview || sourceFrameSrc(job)
const currentOutputCount = images.length + generatedVideos.length
const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
const refreshJobs = useCallback(async () => {
try {
setRecentJobs(await listJobs(12))
} catch {
setRecentJobs([])
}
}, [])
useEffect(() => {
let cancelled = false
listJobs(8)
.then((items) => {
if (!cancelled) setRecentJobs(items)
})
.catch(() => {
if (!cancelled) setRecentJobs([])
})
return () => {
cancelled = true
}
}, [job?.id])
refreshJobs()
}, [refreshJobs, job?.id, currentOutputCount])
useEffect(() => {
if (!referenceFile) {
@@ -111,7 +201,7 @@ export default function Home() {
}, [referenceFile])
useEffect(() => {
if (!job || !hasRunningVideo) return
if (!job || !runningVideo) return
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
@@ -120,33 +210,62 @@ export default function Home() {
}
}, 2600)
return () => window.clearInterval(timer)
}, [job, hasRunningVideo])
}, [job, runningVideo])
const ensureJob = useCallback(async () => {
if (job) return job
setBusy("job")
const created = await createCreativeImageJob(referenceFile)
setJob(created)
await refreshJobs()
return created
}, [job, referenceFile])
}, [job, referenceFile, refreshJobs])
const onFileChange = (file: File | null) => {
setReferenceFile(file)
setJob(null)
setCopyVariants([])
setError("")
}
const runImage = async () => {
if (!prompt.trim()) {
toast.error("先写创作要求")
return
const loadJob = async (id: string) => {
setBusy("job")
setError("")
try {
const loaded = await getJob(id)
setJob(loaded)
setReferenceFile(null)
setCopyVariants([])
} catch (e) {
const message = e instanceof Error ? e.message : "读取任务失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const validatePrompt = () => {
if (!prompt.trim()) {
toast.error(activeMode.output === "image" ? "先写图片要求" : "先写视频要求")
return false
}
if (activeMode.needsReference && !canUseReference) {
toast.error("这个入口需要先上传参考图或选择已有任务")
return false
}
return true
}
const runImage = async () => {
if (!validatePrompt()) return
setBusy("image")
setError("")
try {
const target = await ensureJob()
const updated = await generateImage(target.id, 0, {
prompt,
mode: referenceFile ? "edit" : "text",
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}.`,
mode: activeMode.needsReference ? "edit" : "text",
})
setJob(updated)
toast.success("图片已生成")
@@ -160,16 +279,13 @@ export default function Home() {
}
const runVideo = async () => {
if (!prompt.trim()) {
toast.error("先写创作要求")
return
}
if (!validatePrompt()) return
setBusy("video")
setError("")
try {
const target = await ensureJob()
const updated = await generateStoryboardVideo(target.id, 0, {
prompt,
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
duration: seconds,
count: 1,
first_image: { kind: "keyframe", frame_idx: 0 },
@@ -187,22 +303,20 @@ export default function Home() {
}
const runCopy = async () => {
if (!prompt.trim()) {
toast.error("先写文案目标")
return
}
const goal = prompt.trim() || `${product} ${audience} ${platform}`
setBusy("copy")
setError("")
try {
const result = await generateCreativeCopy({
goal: prompt,
goal,
product,
audience,
platform,
tone,
seconds,
})
setCopyVariants(result.variants)
toast.success("案已生成")
toast.success("图文方案已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "写文案失败"
setError(message)
@@ -213,8 +327,7 @@ export default function Home() {
}
const runPrimary = () => {
if (mode === "image") return runImage()
if (mode === "copy") return runCopy()
if (activeMode.output === "image") return runImage()
return runVideo()
}
@@ -247,69 +360,99 @@ export default function Home() {
}
}
const useVariant = (variant: CreativeCopyVariant, nextMode: CreatorMode) => {
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
setMode(nextMode)
setPrompt(nextMode === "image" ? variant.image_prompt_en : variant.video_prompt_en)
setPrompt(nextMode === "text-image" || nextMode === "image-image" ? variant.image_prompt_en : variant.video_prompt_en)
}
const activeMode = MODE_ITEMS.find((item) => item.id === mode) ?? MODE_ITEMS[0]
const ActiveIcon = activeMode.icon
return (
<main className="min-h-screen bg-[#f7f8f4] text-[#10211f]">
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
<Toaster richColors position="top-center" />
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col px-4 py-4 sm:px-6 lg:px-8">
<header className="flex shrink-0 items-center justify-between border-b border-[#d9ded5] pb-4">
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#10211f] text-white">
<Sparkles className="h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#16231f] text-white">
<Layers3 className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-normal">SKG </h1>
<p className="text-sm text-[#5f6f69]"></p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[#66746e]">
<span className="inline-flex items-center gap-1 rounded border border-[#cbd6d0] bg-white px-2 py-1">
<ShieldCheck className="h-3.5 w-3.5 text-[#0f766e]" />
</span>
<span className="inline-flex items-center gap-1 rounded border border-[#cbd6d0] bg-white px-2 py-1">
<BadgeCheck className="h-3.5 w-3.5 text-[#ea580c]" />
</span>
</div>
</div>
</div>
<div className="hidden items-center gap-2 text-sm text-[#5f6f69] md:flex">
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">gpt-image-2</span>
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">Seedance / Kling / Veo</span>
<div className="flex flex-wrap items-center gap-2 text-sm">
<a
href="/agent/"
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-[#42524c] transition hover:border-[#9aa9a2] hover:text-[#17201d]"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
{job ? (
<a
href={`/detail/?job=${job.id}`}
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#16231f] px-3 text-white transition hover:bg-[#25342f]"
>
<ArrowRight className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</header>
<section className="grid min-h-0 flex-1 gap-4 py-4 lg:grid-cols-[280px_minmax(0,1fr)_390px]">
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
<div className="grid gap-2">
{MODE_ITEMS.map((item) => {
const Icon = item.icon
const selected = item.id === mode
return (
<button
key={item.id}
type="button"
onClick={() => setMode(item.id)}
className={cx(
"flex h-12 items-center justify-between rounded-md border px-3 text-left text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-[#0d9488]/35",
selected ? "border-[#10211f] bg-[#10211f] text-white" : "border-[#d9ded5] bg-[#f7f8f4] text-[#263b36] hover:border-[#9db4ad]",
)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{item.label}
</span>
{selected ? <ArrowRight className="h-4 w-4" /> : null}
</button>
)
})}
</div>
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<span className="rounded bg-[#f2f5ef] px-2 py-1 text-[11px] text-[#66746e]">4 </span>
</div>
<div className="grid gap-2">
{CREATION_MODES.map((item) => {
const Icon = item.icon
const selected = item.id === mode
return (
<button
key={item.id}
type="button"
onClick={() => setMode(item.id)}
className={cx(
"flex min-h-14 items-center justify-between rounded-md border px-3 text-left transition focus:outline-none focus:ring-2 focus:ring-[#0f766e]/25",
selected ? item.active : `${item.tone} hover:border-[#94a39b]`,
)}
>
<span className="flex min-w-0 items-center gap-2">
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-semibold">{item.label}</span>
<span className={cx("block truncate text-[11px]", selected ? "text-white/72" : "text-current/62")}>{item.short}</span>
</span>
</span>
{selected ? <ArrowRight className="h-4 w-4 shrink-0" /> : null}
</button>
)
})}
</div>
</section>
<div className="mt-2 rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium"></span>
<h2 className="text-sm font-semibold"></h2>
{referenceFile ? (
<button
type="button"
onClick={() => onFileChange(null)}
className="text-xs text-[#c2410c] hover:text-[#9a3412]"
className="inline-flex items-center gap-1 text-xs text-[#be3f18] hover:text-[#8f2f14]"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
@@ -321,47 +464,52 @@ export default function Home() {
className="hidden"
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
/>
{referencePreview ? (
{currentReference ? (
<MediaAssetTile
src={referencePreview}
src={currentReference}
alt="reference"
objectFit="cover"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={referenceFile?.name}
onDelete={() => onFileChange(null)}
label={referenceFile?.name || jobTitle(job)}
meta={referenceFile ? "local" : "job"}
onDelete={referenceFile ? () => onFileChange(null) : undefined}
/>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex aspect-[4/5] w-full flex-col items-center justify-center rounded-md border border-dashed border-[#b7c6c0] bg-white text-[#5f6f69] transition hover:border-[#0d9488] hover:text-[#0f766e] focus:outline-none focus:ring-2 focus:ring-[#0d9488]/35"
className="flex aspect-[4/5] w-full flex-col items-center justify-center rounded-md border border-dashed border-[#b8c6bf] bg-[#f7f9f5] text-[#66746e] transition hover:border-[#0f766e] hover:text-[#0f766e] focus:outline-none focus:ring-2 focus:ring-[#0f766e]/25"
>
<Upload className="mb-2 h-5 w-5" />
<span className="text-sm"></span>
</button>
)}
</div>
</section>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<RefreshCw className="h-4 w-4" />
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<button
type="button"
onClick={refreshJobs}
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
aria-label="刷新任务"
title="刷新任务"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid max-h-[260px] gap-2 overflow-y-auto pr-1">
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
{recentJobs.length ? recentJobs.map((item) => (
<button
key={item.id}
type="button"
onClick={async () => {
try {
setJob(await getJob(item.id))
setError("")
} catch (e) {
toast.error(e instanceof Error ? e.message : "读取任务失败")
}
}}
className="grid grid-cols-[46px_minmax(0,1fr)] gap-2 rounded-md border border-[#d9ded5] bg-white p-1.5 text-left transition hover:border-[#0d9488]/70"
onClick={() => loadJob(item.id)}
className={cx(
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
)}
>
<MediaAssetTile
src={apiAssetUrl(item.thumbnail)}
@@ -371,219 +519,262 @@ export default function Home() {
disablePreview={!item.thumbnail}
/>
<span className="min-w-0">
<span className="block truncate text-xs font-medium">{item.url.replace(/^creative:\/\//, "") || item.id}</span>
<span className="block text-[11px] text-[#6b7b75]">{item.frame_count} · {item.video_count} </span>
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
<span className="block text-[11px] text-[#66746e]">{statusLabel(item.status)} · {item.frame_count} · {item.video_count} </span>
</span>
</button>
)) : (
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-white px-3 py-4 text-center text-xs text-[#6b7b75]"></div>
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
)}
</div>
</div>
</section>
</aside>
<section className="flex min-h-[680px] flex-col rounded-lg border border-[#d9ded5] bg-white">
<div className={cx("h-1.5 rounded-t-lg bg-gradient-to-r", activeMode.accent)} />
<div className="flex flex-1 flex-col gap-4 p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-[#eff8f6] text-[#0f766e]">
<ActiveIcon className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">{activeMode.label}</h2>
<p className="text-sm text-[#6b7b75]">{mode === "copy" ? "输入目标,生成可直接进图/视频模型的脚本和提示词" : "写一句要求,必要时加一张参考图"}</p>
</div>
<section className="flex min-h-[720px] flex-col rounded-lg border border-[#d8dfd4] bg-white">
<div className="flex items-center justify-between gap-3 border-b border-[#e1e6dd] p-4">
<div className="flex items-center gap-3">
<div className={cx("flex h-11 w-11 items-center justify-center rounded-md border", activeMode.tone)}>
<ActiveIcon className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-semibold">{activeMode.label}</h2>
<p className="text-sm text-[#66746e]">{activeMode.short}</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={runCopy}
disabled={!!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] px-4 text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <PenLine className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
className="inline-flex h-11 min-w-[132px] items-center justify-center gap-2 rounded-md bg-[#f97316] px-5 text-sm font-semibold text-white transition hover:bg-[#ea580c] focus:outline-none focus:ring-2 focus:ring-[#f97316]/35 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex h-10 min-w-[132px] items-center justify-center gap-2 rounded-md bg-[#ea5b2d] px-5 text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === mode ? <Loader2 className="h-4 w-4 animate-spin" /> : mode === "copy" ? <Wand2 className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{mode === "copy" ? "生成文案" : mode === "image" ? "生成图片" : "生成视频"}
{busy === activeMode.output || busy === "job" ? <Loader2 className="h-4 w-4 animate-spin" /> : activeMode.output === "image" ? <Wand2 className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{activeMode.output === "image" ? "生成图片" : "生成视频"}
</button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid flex-1 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#50645e]"></span>
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#50645e]"></span>
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#50645e]"></span>
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={platform}
onChange={(event) => setPlatform(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{["TikTok / Reels", "Amazon", "独立站", "小红书", "抖音"].map((value) => <option key={value}>{value}</option>)}
</select>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
</div>
<label className="grid flex-1 gap-2">
<span className="text-xs font-medium text-[#50645e]">{mode === "copy" ? "文案目标" : "创作要求"}</span>
<label className="grid min-h-0 gap-2">
<span className="text-xs font-medium text-[#52635d]"></span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={mode === "copy" ? PROMPT_TEMPLATES[2] : mode === "image" ? PROMPT_TEMPLATES[0] : PROMPT_TEMPLATES[1]}
className="min-h-[220px] flex-1 resize-none rounded-lg border border-[#d9ded5] bg-[#f7f8f4] p-4 text-base leading-7 outline-none transition placeholder:text-[#8a9994] focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
placeholder={activeMode.placeholder}
className="min-h-[280px] resize-none rounded-lg border border-[#d8dfd4] bg-[#f7f9f5] p-4 text-base leading-7 outline-none transition placeholder:text-[#8b9993] focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<div className="flex flex-wrap gap-2">
{PROMPT_TEMPLATES.map((item) => (
<button
key={item}
type="button"
onClick={() => setPrompt(item)}
className="rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 py-2 text-xs text-[#40534d] transition hover:border-[#0d9488]/70 hover:bg-[#eef8f5]"
>
{item}
</button>
))}
</div>
{mode === "copy" ? (
<div className="grid gap-3">
<div className="flex flex-wrap gap-2">
{PROMPT_PRESETS.map((item) => (
<button
key={item}
type="button"
onClick={() => setPrompt(item)}
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2 text-xs text-[#42524c] transition hover:border-[#0f766e]/65 hover:bg-[#edf7f3]"
>
{item}
</button>
))}
</div>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#50645e]"></span>
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
) : null}
{error ? (
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{activeMode.needsReference && !canUseReference ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800"></div>
) : null}
{error ? (
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
</div>
</div>
</section>
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
{job ? <span className="rounded bg-[#f1f4ef] px-2 py-1 font-mono text-[11px] text-[#6b7b75]">{job.id}</span> : null}
</div>
<div className="grid gap-3 overflow-y-auto pr-1">
{latestImage ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
<ImageIcon className="h-3.5 w-3.5" />
</div>
<MediaAssetTile
src={apiAssetUrl(latestImage.url)}
alt="generated image"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={latestImage.model}
meta={latestImage.mode}
onDelete={() => deleteImage(latestImage)}
/>
<button
type="button"
onClick={() => setPrompt(latestImage.prompt)}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[#d9ded5] bg-[#f7f8f4] text-sm text-[#40534d] transition hover:border-[#0d9488]/70"
>
<RefreshCw className="h-4 w-4" />
</button>
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-semibold"></h2>
<p className="mt-1 max-w-[260px] truncate text-xs text-[#66746e]">{jobTitle(job)}</p>
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-[#f7f8f4] px-3 py-8 text-center text-sm text-[#6b7b75]"></div>
)}
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
<Clapperboard className="h-3.5 w-3.5" />
</div>
{generatedVideos.length ? generatedVideos.slice(0, 4).map((video) => (
<div key={video.id} className="grid gap-1.5">
<MediaAssetTile
kind="video"
src={video.status === "completed" ? videoSrc(job!, video) : undefined}
poster={apiAssetUrl(video.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-md"
label={video.model}
meta={`${video.status} · ${Math.round(video.progress)}%`}
busy={video.status === "queued" || video.status === "in_progress"}
onDelete={() => deleteVideo(video)}
/>
<div className="h-1 overflow-hidden rounded-full bg-[#edf1ec]">
<div className="h-full rounded-full bg-[#0d9488]" style={{ width: `${Math.max(4, video.progress)}%` }} />
</div>
</div>
)) : (
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-[#f7f8f4] px-3 py-8 text-center text-sm text-[#6b7b75]"></div>
)}
{job ? <span className="rounded bg-[#f2f5ef] px-2 py-1 font-mono text-[11px] text-[#66746e]">{job.id}</span> : null}
</div>
{copyVariants.length ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
<FileText className="h-3.5 w-3.5" />
</div>
{copyVariants.map((variant, index) => (
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
<button
type="button"
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded p-1 text-[#5f6f69] hover:bg-white hover:text-[#10211f]"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-[#263b36]">{variant.hook_zh}</p>
<pre className="mt-2 whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#40534d]">{variant.script_zh}</pre>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => useVariant(variant, "image")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#0d9488] text-xs font-medium text-white hover:bg-[#0f766e]"
>
<ImageIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => useVariant(variant, "video")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#f97316] text-xs font-medium text-white hover:bg-[#ea580c]"
>
<Clapperboard className="h-3.5 w-3.5" />
</button>
</div>
</article>
))}
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{images.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{generatedVideos.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
<div className="text-lg font-semibold">{copyVariants.length}</div>
<div className="text-[11px] text-[#66746e]"></div>
</div>
</div>
{job ? (
<a
href={`/detail/?job=${job.id}`}
className="mt-3 inline-flex h-9 w-full items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-white text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60"
>
<ExternalLink className="h-4 w-4" />
</a>
) : null}
</div>
</section>
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="grid gap-4">
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<ImageIcon className="h-3.5 w-3.5" />
</div>
{latestImage ? (
<MediaAssetTile
src={apiAssetUrl(latestImage.url)}
alt="generated image"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={latestImage.model}
meta={latestImage.mode}
onDelete={() => deleteImage(latestImage)}
/>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<Clapperboard className="h-3.5 w-3.5" />
</div>
{generatedVideos.length ? generatedVideos.slice(0, 4).map((video) => (
<div key={video.id} className="grid gap-1.5">
<MediaAssetTile
kind="video"
src={video.status === "completed" ? videoSrc(job!, video) : undefined}
poster={apiAssetUrl(video.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-md"
label={video.model}
meta={`${video.status} · ${Math.round(video.progress)}%`}
busy={video.status === "queued" || video.status === "in_progress"}
onDelete={() => deleteVideo(video)}
/>
<div className="h-1 overflow-hidden rounded-full bg-[#edf1ec]">
<div className="h-full rounded-full bg-[#0f766e]" style={{ width: `${Math.max(4, video.progress)}%` }} />
</div>
</div>
)) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div>
)}
</div>
{copyVariants.length ? (
<div className="grid gap-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]">
<FileText className="h-3.5 w-3.5" />
</div>
{copyVariants.map((variant, index) => (
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
<button
type="button"
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
<pre className="mt-2 max-h-36 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => useVariant(variant, activeMode.needsReference ? "image-image" : "text-image")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-xs font-semibold text-white hover:bg-[#115e59]"
>
<ImageIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => useVariant(variant, activeMode.needsReference ? "image-video" : "text-video")}
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-xs font-semibold text-white hover:bg-[#d94f25]"
>
<Clapperboard className="h-3.5 w-3.5" />
</button>
</div>
</article>
))}
</div>
) : null}
</div>
</section>
</aside>
</section>
</div>