593 lines
24 KiB
TypeScript
593 lines
24 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import {
|
|
ArrowRight,
|
|
Clapperboard,
|
|
Copy,
|
|
FileText,
|
|
Image as ImageIcon,
|
|
Loader2,
|
|
PenLine,
|
|
Play,
|
|
RefreshCw,
|
|
Sparkles,
|
|
Upload,
|
|
Wand2,
|
|
} from "lucide-react"
|
|
import { Toaster, toast } from "sonner"
|
|
import { MediaAssetTile } from "@/components/media-asset-tile"
|
|
import {
|
|
apiAssetUrl,
|
|
createCreativeImageJob,
|
|
deleteGeneratedImage,
|
|
deleteGeneratedVideo,
|
|
generateCreativeCopy,
|
|
generateImage,
|
|
generateStoryboardVideo,
|
|
getJob,
|
|
listJobs,
|
|
type CreativeCopyVariant,
|
|
type GeneratedImage,
|
|
type GeneratedVideo,
|
|
type Job,
|
|
type JobSummary,
|
|
} from "@/lib/api"
|
|
|
|
type CreatorMode = "video" | "image" | "copy"
|
|
type BusyTask = "image" | "video" | "copy" | null
|
|
|
|
const MODE_ITEMS: Array<{
|
|
id: CreatorMode
|
|
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" },
|
|
]
|
|
|
|
const PROMPT_TEMPLATES = [
|
|
"一张 SKG 颈部按摩仪的信息流广告首帧,真实生活方式,产品清楚可见,画面干净高级",
|
|
"把参考图里的主体变成适合 TikTok 的 9:16 产品短视频,开头 2 秒要抓人,镜头有轻微推进",
|
|
"自动写一条 20 秒 SKG 产品短视频脚本,语气直接,突出日常放松和佩戴场景",
|
|
]
|
|
|
|
function cx(...items: Array<string | false | null | undefined>) {
|
|
return items.filter(Boolean).join(" ")
|
|
}
|
|
|
|
function latestGeneratedImage(job: Job | null): GeneratedImage | null {
|
|
return job?.frames?.[0]?.generated_images?.at(-1) ?? null
|
|
}
|
|
|
|
function videoSrc(job: Job, video: GeneratedVideo) {
|
|
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
|
|
}
|
|
|
|
export default function Home() {
|
|
const [mode, setMode] = useState<CreatorMode>("video")
|
|
const [prompt, setPrompt] = useState("")
|
|
const [product, setProduct] = useState("SKG 颈部按摩仪")
|
|
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
|
|
const [tone, setTone] = useState("直接、可信、有购买理由")
|
|
const [seconds, setSeconds] = useState(20)
|
|
const [referenceFile, setReferenceFile] = useState<File | null>(null)
|
|
const [referencePreview, setReferencePreview] = useState("")
|
|
const [job, setJob] = useState<Job | null>(null)
|
|
const [busy, setBusy] = useState<BusyTask>(null)
|
|
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
|
|
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
|
|
const [error, setError] = useState("")
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const latestImage = latestGeneratedImage(job)
|
|
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job])
|
|
const hasRunningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress")
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
listJobs(8)
|
|
.then((items) => {
|
|
if (!cancelled) setRecentJobs(items)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setRecentJobs([])
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [job?.id])
|
|
|
|
useEffect(() => {
|
|
if (!referenceFile) {
|
|
setReferencePreview("")
|
|
return
|
|
}
|
|
const url = URL.createObjectURL(referenceFile)
|
|
setReferencePreview(url)
|
|
return () => URL.revokeObjectURL(url)
|
|
}, [referenceFile])
|
|
|
|
useEffect(() => {
|
|
if (!job || !hasRunningVideo) return
|
|
const timer = window.setInterval(async () => {
|
|
try {
|
|
setJob(await getJob(job.id))
|
|
} catch {
|
|
window.clearInterval(timer)
|
|
}
|
|
}, 2600)
|
|
return () => window.clearInterval(timer)
|
|
}, [job, hasRunningVideo])
|
|
|
|
const ensureJob = useCallback(async () => {
|
|
if (job) return job
|
|
const created = await createCreativeImageJob(referenceFile)
|
|
setJob(created)
|
|
return created
|
|
}, [job, referenceFile])
|
|
|
|
const onFileChange = (file: File | null) => {
|
|
setReferenceFile(file)
|
|
setJob(null)
|
|
setError("")
|
|
}
|
|
|
|
const runImage = async () => {
|
|
if (!prompt.trim()) {
|
|
toast.error("先写创作要求")
|
|
return
|
|
}
|
|
setBusy("image")
|
|
setError("")
|
|
try {
|
|
const target = await ensureJob()
|
|
const updated = await generateImage(target.id, 0, {
|
|
prompt,
|
|
mode: referenceFile ? "edit" : "text",
|
|
})
|
|
setJob(updated)
|
|
toast.success("图片已生成")
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "生图失败"
|
|
setError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const runVideo = async () => {
|
|
if (!prompt.trim()) {
|
|
toast.error("先写创作要求")
|
|
return
|
|
}
|
|
setBusy("video")
|
|
setError("")
|
|
try {
|
|
const target = await ensureJob()
|
|
const updated = await generateStoryboardVideo(target.id, 0, {
|
|
prompt,
|
|
duration: seconds,
|
|
count: 1,
|
|
first_image: { kind: "keyframe", frame_idx: 0 },
|
|
size: "720x1280",
|
|
})
|
|
setJob(updated)
|
|
toast.success("视频已提交")
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "生视频失败"
|
|
setError(message)
|
|
toast.error(message)
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const runCopy = async () => {
|
|
if (!prompt.trim()) {
|
|
toast.error("先写文案目标")
|
|
return
|
|
}
|
|
setBusy("copy")
|
|
setError("")
|
|
try {
|
|
const result = await generateCreativeCopy({
|
|
goal: prompt,
|
|
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 runPrimary = () => {
|
|
if (mode === "image") return runImage()
|
|
if (mode === "copy") return runCopy()
|
|
return runVideo()
|
|
}
|
|
|
|
const deleteImage = async (image: GeneratedImage) => {
|
|
if (!job) return
|
|
try {
|
|
setJob(await deleteGeneratedImage(job.id, 0, 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("复制失败")
|
|
}
|
|
}
|
|
|
|
const useVariant = (variant: CreativeCopyVariant, nextMode: CreatorMode) => {
|
|
setMode(nextMode)
|
|
setPrompt(nextMode === "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]">
|
|
<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="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>
|
|
<div>
|
|
<h1 className="text-xl font-semibold tracking-normal">SKG Creative Studio</h1>
|
|
<p className="text-sm text-[#5f6f69]">图片、视频、文案</p>
|
|
</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>
|
|
</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>
|
|
|
|
<div className="mt-2 rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-sm font-medium">参考图</span>
|
|
{referenceFile ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onFileChange(null)}
|
|
className="text-xs text-[#c2410c] hover:text-[#9a3412]"
|
|
>
|
|
移除
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
className="hidden"
|
|
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
|
/>
|
|
{referencePreview ? (
|
|
<MediaAssetTile
|
|
src={referencePreview}
|
|
alt="reference"
|
|
objectFit="cover"
|
|
previewObjectFit="contain"
|
|
className="aspect-[4/5] w-full rounded-md"
|
|
label={referenceFile?.name}
|
|
onDelete={() => onFileChange(null)}
|
|
/>
|
|
) : (
|
|
<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"
|
|
>
|
|
<Upload className="mb-2 h-5 w-5" />
|
|
<span className="text-sm">上传图片</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<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" />
|
|
最近任务
|
|
</div>
|
|
<div className="grid max-h-[260px] 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"
|
|
>
|
|
<MediaAssetTile
|
|
src={apiAssetUrl(item.thumbnail)}
|
|
alt=""
|
|
objectFit="cover"
|
|
className="aspect-square rounded"
|
|
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>
|
|
</button>
|
|
)) : (
|
|
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-white px-3 py-4 text-center text-xs text-[#6b7b75]">暂无任务</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
<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"
|
|
>
|
|
{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" ? "生成图片" : "生成视频"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<label className="grid gap-1.5">
|
|
<span className="text-xs font-medium text-[#50645e]">产品</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"
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1.5">
|
|
<span className="text-xs font-medium text-[#50645e]">人群</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"
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1.5">
|
|
<span className="text-xs font-medium text-[#50645e]">时长</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"
|
|
>
|
|
{[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>
|
|
<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"
|
|
/>
|
|
</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" ? (
|
|
<label className="grid gap-1.5">
|
|
<span className="text-xs font-medium text-[#50645e]">语气</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"
|
|
/>
|
|
</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}
|
|
</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>
|
|
</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>
|
|
)}
|
|
</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>
|
|
) : null}
|
|
</div>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|