3 Commits

Author SHA1 Message Date
eca5213dab feat: simplify home like jimeng generate 2026-05-25 10:29:55 +08:00
976b318432 auto-save 2026-05-25 10:27 (~2) 2026-05-25 10:27:52 +08:00
04d80c133a auto-save 2026-05-25 10:16 (~2) 2026-05-25 10:16:59 +08:00
4 changed files with 2582 additions and 2658 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md` - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译) - 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-24 重设计):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径是文生图、图生图、文生视频、图生视频和营销图文方案生成;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页结构为左侧创作入口 + 参考图 + 我的任务,中间创作台,右侧当前任务结果;任务详情页固定为 `/detail/?job=<id>`沉淀参考图、生成图、视频候选、提示词和图文方案,并支持继续生成、删除和复用。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架。 - 当前产品方向2026-05-25 即梦 generate 式简化):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径仍是图片、视频和营销图文方案生成,支持文字生成、参考图生成和图文提示词回填;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页默认只保留窄导航栏 + 会话侧栏 + 中央 prompt composer参考图入口是输入框左侧的上传卡图片/视频/图文模式、自动设置和参考上传放在 composer 底部小按钮里,产品、人群、平台、时长和语气默认折叠到“自动”。结果不再占据首屏大面板,只在右下角浮层提示并进入 `/detail/?job=<id>` 沉淀参考图、生成图、视频候选、提示词和图文方案。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架。
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik

File diff suppressed because one or more lines are too long

View File

@@ -2,19 +2,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
ArrowRight, ArrowUp,
BadgeCheck,
Clapperboard, Clapperboard,
Copy, Copy,
ExternalLink, ExternalLink,
FileText, FileText,
Folder,
Image as ImageIcon, Image as ImageIcon,
Layers3, Layers3,
Loader2, Loader2,
PenLine, Menu,
Play, Plus,
RefreshCw,
ShieldCheck,
Sparkles, Sparkles,
Upload, Upload,
Wand2, Wand2,
@@ -40,73 +38,64 @@ import {
type JobSummary, type JobSummary,
} from "@/lib/api" } from "@/lib/api"
type CreationMode = "text-image" | "image-image" | "text-video" | "image-video" type CreationMode = "video" | "image" | "copy"
type BusyTask = "image" | "video" | "copy" | "job" | null type BusyTask = CreationMode | "job" | null
type ModeConfig = { type ModeConfig = {
id: CreationMode id: CreationMode
label: string label: string
short: string
output: "image" | "video"
icon: LucideIcon icon: LucideIcon
needsReference: boolean
tone: string
active: string
placeholder: string placeholder: string
} }
const CREATION_MODES: ModeConfig[] = [ type InspirationCard = {
title: string
mode: CreationMode
prompt: string
}
const OUTPUT_MODES: ModeConfig[] = [
{ {
id: "text-image", id: "video",
label: "文生图", 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, icon: Clapperboard,
needsReference: false, placeholder: "Seedance 2.0 全能参考,视频创意无限可能",
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", id: "image",
label: "图生视频", label: "图",
short: "参考图动起来", icon: ImageIcon,
output: "video", placeholder: "生成一张 9:16 信息流营销图SKG 颈部按摩仪佩戴清楚,真实办公室午休场景。",
icon: Play, },
needsReference: true, {
tone: "border-rose-200 bg-rose-50 text-rose-900", id: "copy",
active: "border-rose-500 bg-rose-600 text-white", label: "图文",
placeholder: "把参考图变成 12 秒竖屏视频,人物自然佩戴产品,轻微转头,产品结构保持稳定,光线真实。", icon: FileText,
placeholder: "写一组 SKG 颈部按摩仪营销图文方案,包含 hook、脚本、caption 和生成提示词。",
}, },
] ]
const PROMPT_PRESETS = [ const PROMPT_PRESETS: InspirationCard[] = [
"真实办公室午休场景,产品佩戴清楚,镜头干净,适合信息流首帧。", {
"年轻上班族下班回家放松,先展示疲惫状态,再自然戴上 SKG 产品。", title: "办公室午休",
"白底产品功能图,高级电商质感,突出外形、佩戴方式和日常使用。", mode: "video",
"TikTok 竖屏视频,前三秒强 hook产品不要变形动作自然可信。", prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。",
},
{
title: "下班回家放松",
mode: "video",
prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。",
},
{
title: "白底产品功能图",
mode: "image",
prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。",
},
{
title: "前三秒 Hook",
mode: "copy",
prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。",
},
] ]
function cx(...items: Array<string | false | null | undefined>) { function cx(...items: Array<string | false | null | undefined>) {
@@ -122,6 +111,10 @@ function latestGeneratedImage(job: Job | null): GeneratedImage | null {
return allGeneratedImages(job)[0] ?? null return allGeneratedImages(job)[0] ?? null
} }
function latestGeneratedVideo(job: Job | null): GeneratedVideo | null {
return [...(job?.generated_videos ?? [])].sort((a, b) => b.created_at - a.created_at)[0] ?? null
}
function videoSrc(job: Job, video: GeneratedVideo) { function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`) return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
} }
@@ -152,35 +145,35 @@ function statusLabel(status?: string) {
} }
export default function Home() { export default function Home() {
const [mode, setMode] = useState<CreationMode>("text-video") const [mode, setMode] = useState<CreationMode>("video")
const [prompt, setPrompt] = useState("") const [prompt, setPrompt] = useState("")
const [product, setProduct] = useState("SKG 颈部按摩仪") const [product, setProduct] = useState("SKG 颈部按摩仪")
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群") const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
const [platform, setPlatform] = useState("TikTok / Reels") const [platform, setPlatform] = useState("TikTok / Reels")
const [tone, setTone] = useState("真实、直接、有购买理由") const [tone, setTone] = useState("真实自然、有购买理由")
const [seconds, setSeconds] = useState(12) const [seconds, setSeconds] = useState(15)
const [referenceFile, setReferenceFile] = useState<File | null>(null) const [referenceFile, setReferenceFile] = useState<File | null>(null)
const [referencePreview, setReferencePreview] = useState("") const [referencePreview, setReferencePreview] = useState("")
const [job, setJob] = useState<Job | null>(null) const [job, setJob] = useState<Job | null>(null)
const [busy, setBusy] = useState<BusyTask>(null) const [busy, setBusy] = useState<BusyTask>(null)
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([]) const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([]) const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
const [showSettings, setShowSettings] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const activeMode = CREATION_MODES.find((item) => item.id === mode) ?? CREATION_MODES[0] const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0]
const ActiveIcon = activeMode.icon
const images = useMemo(() => allGeneratedImages(job), [job]) const images = useMemo(() => allGeneratedImages(job), [job])
const latestImage = latestGeneratedImage(job) const latestImage = latestGeneratedImage(job)
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job]) const latestVideo = latestGeneratedVideo(job)
const runningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress") const runningVideo = (job?.generated_videos ?? []).some((item) => item.status === "queued" || item.status === "in_progress")
const currentReference = referencePreview || sourceFrameSrc(job) const currentReference = referencePreview || sourceFrameSrc(job)
const currentOutputCount = images.length + generatedVideos.length
const canUseReference = !!referenceFile || !!sourceFrameSrc(job) const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
const firstCopy = copyVariants[0]
const refreshJobs = useCallback(async () => { const refreshJobs = useCallback(async () => {
try { try {
setRecentJobs(await listJobs(12)) setRecentJobs(await listJobs(14))
} catch { } catch {
setRecentJobs([]) setRecentJobs([])
} }
@@ -188,7 +181,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
refreshJobs() refreshJobs()
}, [refreshJobs, job?.id, currentOutputCount]) }, [refreshJobs, job?.id, images.length, job?.generated_videos?.length])
useEffect(() => { useEffect(() => {
if (!referenceFile) { if (!referenceFile) {
@@ -245,13 +238,13 @@ export default function Home() {
} }
} }
const promptWithContext = () => (
`${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`
)
const validatePrompt = () => { const validatePrompt = () => {
if (!prompt.trim()) { if (!prompt.trim()) {
toast.error(activeMode.output === "image" ? "先写图片要求" : "先写视频要求") toast.error("先写一句生成要求")
return false
}
if (activeMode.needsReference && !canUseReference) {
toast.error("这个入口需要先上传参考图或选择已有任务")
return false return false
} }
return true return true
@@ -264,8 +257,8 @@ export default function Home() {
try { try {
const target = await ensureJob() const target = await ensureJob()
const updated = await generateImage(target.id, 0, { const updated = await generateImage(target.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}.`, prompt: promptWithContext(),
mode: activeMode.needsReference ? "edit" : "text", mode: canUseReference ? "edit" : "text",
}) })
setJob(updated) setJob(updated)
toast.success("图片已生成") toast.success("图片已生成")
@@ -285,7 +278,7 @@ export default function Home() {
try { try {
const target = await ensureJob() const target = await ensureJob()
const updated = await generateStoryboardVideo(target.id, 0, { const updated = await generateStoryboardVideo(target.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`, prompt: promptWithContext(),
duration: seconds, duration: seconds,
count: 1, count: 1,
first_image: { kind: "keyframe", frame_idx: 0 }, first_image: { kind: "keyframe", frame_idx: 0 },
@@ -318,7 +311,7 @@ export default function Home() {
setCopyVariants(result.variants) setCopyVariants(result.variants)
toast.success("图文方案已生成") toast.success("图文方案已生成")
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : "写文失败" const message = e instanceof Error ? e.message : "写文失败"
setError(message) setError(message)
toast.error(message) toast.error(message)
} finally { } finally {
@@ -327,7 +320,8 @@ export default function Home() {
} }
const runPrimary = () => { const runPrimary = () => {
if (activeMode.output === "image") return runImage() if (mode === "image") return runImage()
if (mode === "copy") return runCopy()
return runVideo() return runVideo()
} }
@@ -360,422 +354,338 @@ export default function Home() {
} }
} }
const useInspiration = (item: InspirationCard) => {
setMode(item.mode)
setPrompt(item.prompt)
setError("")
}
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => { const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
setMode(nextMode) setMode(nextMode)
setPrompt(nextMode === "text-image" || nextMode === "image-image" ? variant.image_prompt_en : variant.video_prompt_en) setPrompt(nextMode === "image" ? variant.image_prompt_en : nextMode === "video" ? variant.video_prompt_en : variant.script_zh)
} }
return ( return (
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]"> <main className="min-h-screen bg-[#090a0f] text-white">
<Toaster richColors position="top-center" /> <Toaster richColors position="top-center" />
<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"> <div className="grid min-h-screen" style={{ gridTemplateColumns: "42px 132px minmax(0, 1fr)" }}>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4"> <aside className="flex min-h-screen flex-col items-center border-r border-white/6 bg-[#090a0f] py-6">
<div className="flex items-center gap-3"> <div className="mb-[230px] flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-400/12 text-cyan-200">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#16231f] text-white"> <Sparkles className="h-4 w-4" />
<Layers3 className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-normal">SKG </h1>
<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>
<div className="flex flex-wrap items-center gap-2 text-sm"> <nav className="grid gap-6 text-[10px] text-white/64">
<a <button type="button" className="group grid justify-items-center gap-1.5 transition hover:text-white">
href="/agent/" <Sparkles className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
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]"
> </button>
<button type="button" className="group grid justify-items-center gap-1.5 text-white transition">
<ExternalLink className="h-3.5 w-3.5" /> <Wand2 className="h-4 w-4 text-cyan-200" />
</button>
<button type="button" onClick={refreshJobs} className="group grid justify-items-center gap-1.5 transition hover:text-white">
<Folder className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
</button>
</nav>
<div className="mt-auto grid justify-items-center gap-5 text-white/42">
<div className="h-5 w-5 rounded-full bg-gradient-to-br from-slate-500 to-slate-800 ring-1 ring-white/12" />
<a href="/agent/" className="text-[10px] transition hover:text-white" title="高级复刻">
<Layers3 className="mx-auto mb-1 h-3.5 w-3.5" />
Agent
</a> </a>
<button type="button" className="rounded-lg p-2 transition hover:bg-white/8 hover:text-white" aria-label="菜单">
<Menu className="h-4 w-4" />
</button>
</div>
</aside>
<aside className="hidden min-h-screen border-r border-white/6 bg-[#15171e] px-2 py-5 md:block">
<div className="mb-4 flex items-center justify-between px-1 text-xs font-semibold text-white/86">
<span></span>
<button type="button" className="rounded-md p-1 text-white/36 transition hover:bg-white/8 hover:text-white" aria-label="收起侧栏">
<Menu className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-2">
<button
type="button"
onClick={() => {
setJob(null)
setPrompt("")
setCopyVariants([])
setReferenceFile(null)
setError("")
}}
className="flex h-8 items-center gap-2 rounded-md bg-white/8 px-2 text-left text-xs font-semibold text-white/86 transition hover:bg-white/12"
>
<Wand2 className="h-3.5 w-3.5 text-white/58" />
</button>
<button
type="button"
onClick={() => setPrompt(activeMode.placeholder)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-xs font-semibold text-white/58 transition hover:bg-white/8 hover:text-white"
>
<FileText className="h-3.5 w-3.5 text-white/40" />
</button>
{job ? ( {job ? (
<a <a
href={`/detail/?job=${job.id}`} 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]" className="mt-2 flex min-h-9 items-center gap-2 rounded-md border border-cyan-200/16 bg-cyan-300/8 px-2 text-left text-xs font-semibold text-cyan-100 transition hover:border-cyan-200/30"
> >
<ExternalLink className="h-3.5 w-3.5" />
<ArrowRight className="h-3.5 w-3.5" />
</a> </a>
) : null} ) : null}
</div> </div>
</header> <div className="mt-8 border-t border-white/6 pt-3">
<div className="mb-2 px-1 text-[11px] font-semibold text-white/34"></div>
<div className="grid gap-1.5">
{recentJobs.slice(0, 5).map((item) => (
<button
key={item.id}
type="button"
onClick={() => loadJob(item.id)}
className={cx(
"min-w-0 rounded-md px-2 py-2 text-left transition hover:bg-white/8",
job?.id === item.id ? "bg-white/8 text-white" : "text-white/42",
)}
>
<span className="block truncate text-[11px] font-semibold">{jobTitle(item)}</span>
<span className="mt-0.5 block truncate text-[10px] text-white/30">{statusLabel(item.status)} · {item.video_count} </span>
</button>
))}
</div>
</div>
</aside>
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]"> <section className="relative min-h-screen overflow-hidden bg-[#0b0c10]">
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3"> <div className="absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_50%_0%,rgba(17,211,239,0.08),transparent_58%)]" />
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3"> <div className="relative flex min-h-screen items-center justify-center px-5 py-16">
<div className="mb-3 flex items-center justify-between"> <section className="mb-20 grid w-full max-w-[520px] -translate-y-12 justify-items-center gap-5">
<h2 className="text-sm font-semibold"></h2> <h1 className="text-center text-lg font-semibold tracking-normal text-white/92"></h1>
<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>
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3"> <section className="relative w-full rounded-2xl border border-white/7 bg-[#1d1f27] p-3 shadow-[0_28px_80px_rgba(0,0,0,0.28)]">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
{referenceFile ? (
<button
type="button"
onClick={() => onFileChange(null)}
className="inline-flex items-center gap-1 text-xs text-[#be3f18] hover:text-[#8f2f14]"
>
<X className="h-3.5 w-3.5" />
</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)}
/>
{currentReference ? (
<MediaAssetTile
src={currentReference}
alt="reference"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={referenceFile?.name || jobTitle(job)}
meta={referenceFile ? "local" : "job"}
onDelete={referenceFile ? () => onFileChange(null) : undefined}
/>
) : (
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
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" className="absolute left-5 top-4 flex h-16 w-12 -rotate-6 items-center justify-center overflow-hidden rounded bg-[#2a2d37] text-white/34 shadow-lg transition hover:text-white"
aria-label="上传素材"
title="上传素材"
> >
<Upload className="mb-2 h-5 w-5" /> {currentReference ? (
<span className="text-sm"></span>
</button>
)}
</section>
<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-[360px] gap-2 overflow-y-auto pr-1">
{recentJobs.length ? recentJobs.map((item) => (
<button
key={item.id}
type="button"
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 <MediaAssetTile
src={apiAssetUrl(item.thumbnail)} src={currentReference}
alt="" alt="reference"
objectFit="cover" objectFit="cover"
className="aspect-square rounded" previewObjectFit="contain"
disablePreview={!item.thumbnail} className="h-full w-full rounded"
disablePreview={!currentReference}
/> />
<span className="min-w-0"> ) : (
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span> <Plus className="h-4 w-4" />
<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-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
)}
</div>
</section>
</aside>
<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>
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
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 === 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 flex-1 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 p-4"> <input
<div className="grid gap-3 md:grid-cols-2"> ref={fileInputRef}
<label className="grid gap-1.5"> type="file"
<span className="text-xs font-medium text-[#52635d]"></span> accept="image/png,image/jpeg,image/webp"
<input className="hidden"
value={product} onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
onChange={(event) => setProduct(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"
/>
</label>
<label className="grid gap-1.5">
<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-[#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-[#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-[#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 min-h-0 gap-2">
<span className="text-xs font-medium text-[#52635d]"></span>
<textarea <textarea
value={prompt} value={prompt}
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
placeholder={activeMode.placeholder} 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" className="min-h-9 w-full resize-none bg-transparent pl-16 pr-8 pt-0.5 text-sm leading-6 text-white outline-none placeholder:text-white/24"
/> />
</label>
<div className="grid gap-3"> {referenceFile ? (
<div className="flex flex-wrap gap-2"> <button
{PROMPT_PRESETS.map((item) => ( type="button"
onClick={() => onFileChange(null)}
className="absolute right-4 top-4 rounded-lg p-1 text-white/34 transition hover:bg-white/8 hover:text-white"
aria-label="移除素材"
title="移除素材"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
<div className="mt-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{OUTPUT_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(
"inline-flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-semibold transition",
selected ? "border-cyan-300/24 bg-cyan-300/10 text-cyan-200" : "border-white/7 bg-black/14 text-white/48 hover:border-white/14 hover:text-white",
)}
>
<Icon className="h-3.5 w-3.5" />
{item.label}
</button>
)
})}
<button <button
key={item}
type="button" type="button"
onClick={() => setPrompt(item)} onClick={() => setShowSettings((value) => !value)}
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2 text-xs text-[#42524c] transition hover:border-[#0f766e]/65 hover:bg-[#edf7f3]" className="inline-flex h-8 items-center rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
> >
{item}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
>
<Upload className="h-3.5 w-3.5" />
{referenceFile ? "已上传" : "参考"}
</button> </button>
))}
</div>
<label className="grid gap-1.5">
<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-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
{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="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>
{job ? <span className="rounded bg-[#f2f5ef] px-2 py-1 font-mono text-[11px] text-[#66746e]">{job.id}</span> : null}
</div>
<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}
</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> </div>
{latestImage ? (
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#424757] text-white/76 transition hover:bg-cyan-300 hover:text-black disabled:cursor-not-allowed disabled:opacity-60"
aria-label="开始生成"
title="开始生成"
>
{busy === mode || busy === "job" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
</button>
</div>
</section>
{showSettings ? (
<section className="grid w-full gap-3 rounded-2xl border border-white/7 bg-[#171922] p-3 md:grid-cols-2">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-white/42"></span>
<select
value={platform}
onChange={(event) => setPlatform(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
>
{["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-white/42"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
<label className="grid gap-1.5 md:col-span-2">
<span className="text-xs font-medium text-white/42"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
/>
</label>
</section>
) : null}
{error ? (
<div className="w-full rounded-2xl border border-rose-300/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{error}</div>
) : null}
<div className="mt-1 flex w-full flex-wrap justify-center gap-2">
{PROMPT_PRESETS.map((item) => (
<button
key={item.title}
type="button"
onClick={() => useInspiration(item)}
className="rounded-xl border border-white/7 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white/38 transition hover:border-cyan-200/22 hover:text-white"
>
{item.title}
</button>
))}
</div>
{(latestImage || latestVideo || firstCopy) && (
<section className="fixed bottom-6 right-6 z-20 w-[320px] rounded-2xl border border-white/8 bg-[#171922]/95 p-3 shadow-2xl backdrop-blur">
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-white/88"></h2>
{job ? <a href={`/detail/?job=${job.id}`} className="text-xs font-semibold text-cyan-200/82 hover:text-cyan-100"></a> : null}
</div>
{latestVideo && job ? (
<MediaAssetTile
kind="video"
src={latestVideo.status === "completed" ? videoSrc(job, latestVideo) : undefined}
poster={apiAssetUrl(latestVideo.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-xl"
label={latestVideo.model}
meta={`${latestVideo.status} · ${Math.round(latestVideo.progress)}%`}
busy={latestVideo.status === "queued" || latestVideo.status === "in_progress"}
onDelete={() => deleteVideo(latestVideo)}
/>
) : latestImage ? (
<MediaAssetTile <MediaAssetTile
src={apiAssetUrl(latestImage.url)} src={apiAssetUrl(latestImage.url)}
alt="generated image" alt="generated image"
objectFit="contain" objectFit="cover"
previewObjectFit="contain" previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md" className="aspect-video w-full rounded-xl"
label={latestImage.model} label={latestImage.model}
meta={latestImage.mode} meta={latestImage.mode}
onDelete={() => deleteImage(latestImage)} onDelete={() => deleteImage(latestImage)}
/> />
) : ( ) : firstCopy ? (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div> <article className="rounded-xl border border-white/8 bg-white/[0.04] p-3">
)} <div className="flex items-start justify-between gap-2">
</div> <h3 className="line-clamp-1 text-sm font-semibold text-white">{firstCopy.title || "营销方案"}</h3>
<button
<div className="grid gap-2"> type="button"
<div className="flex items-center gap-2 text-xs font-semibold text-[#52635d]"> onClick={() => copyText([firstCopy.hook_zh, firstCopy.script_zh, firstCopy.caption_zh].filter(Boolean).join("\n\n"))}
<Clapperboard className="h-3.5 w-3.5" /> className="rounded-lg p-1 text-white/42 transition hover:bg-white/8 hover:text-white"
aria-label="复制文案"
</div> title="复制文案"
{generatedVideos.length ? generatedVideos.slice(0, 4).map((video) => ( >
<div key={video.id} className="grid gap-1.5"> <Copy className="h-4 w-4" />
<MediaAssetTile </button>
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> <p className="mt-2 line-clamp-3 text-xs leading-5 text-white/58">{firstCopy.hook_zh}</p>
)) : ( <div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-8 text-center text-sm text-[#66746e]"></div> <button type="button" onClick={() => useVariant(firstCopy, "image")} className="rounded-lg bg-emerald-300/12 px-2 py-2 text-xs font-semibold text-emerald-100"></button>
)} <button type="button" onClick={() => useVariant(firstCopy, "video")} className="rounded-lg bg-cyan-300/12 px-2 py-2 text-xs font-semibold text-cyan-100"></button>
</div> </div>
</article>
{copyVariants.length ? ( ) : null}
<div className="grid gap-2"> </section>
<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> </section>
</aside> </div>
</section> </section>
</div> </div>
</main> </main>