feat: redesign marketing creation workspace
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
"type" : "oauth_app"
|
"type" : "oauth_app"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
"description" : "SKG 营销内容多人创作平台:默认首页面向公司团队成员的个人隔离创作空间,主路径为文生图、图生图、文生视频、图生视频和营销图文方案生成;每个登录用户只看到自己的任务和结果。任务详情页沉淀参考图、生成图、视频候选、提示词和图文方案,可继续生成、删除和复用。旧 TK 复刻\/一键出片能力保留为高级入口,不再作为默认工作台。",
|
||||||
"kind" : "app",
|
"kind" : "app",
|
||||||
"name" : "SKG 营销内容工作台",
|
"name" : "SKG 营销内容工作台",
|
||||||
"ownership" : "company",
|
"ownership" : "company",
|
||||||
|
|||||||
2
RULES.md
2
RULES.md
@@ -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-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
- 当前产品方向(2026-05-24 重设计):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径是文生图、图生图、文生视频、图生视频和营销图文方案生成;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页结构为左侧创作入口 + 参考图 + 我的任务,中间创作台,右侧当前任务结果;任务详情页固定为 `/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
543
web/app/detail/page.tsx
Normal file
543
web/app/detail/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Clapperboard,
|
||||||
|
Copy,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Toaster, toast } from "sonner"
|
||||||
|
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||||
|
import {
|
||||||
|
apiAssetUrl,
|
||||||
|
deleteGeneratedImage,
|
||||||
|
deleteGeneratedVideo,
|
||||||
|
generateCreativeCopy,
|
||||||
|
generateImage,
|
||||||
|
generateStoryboardVideo,
|
||||||
|
getJob,
|
||||||
|
listJobs,
|
||||||
|
type CreativeCopyVariant,
|
||||||
|
type GeneratedImage,
|
||||||
|
type GeneratedVideo,
|
||||||
|
type Job,
|
||||||
|
type JobSummary,
|
||||||
|
} from "@/lib/api"
|
||||||
|
|
||||||
|
type ImageItem = GeneratedImage & { frameIdx: number }
|
||||||
|
type BusyTask = "image" | "video" | "copy" | "load" | null
|
||||||
|
|
||||||
|
function cx(...items: Array<string | false | null | undefined>) {
|
||||||
|
return items.filter(Boolean).join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobTitle(item: Job | JobSummary | null) {
|
||||||
|
if (!item) return "未选择任务"
|
||||||
|
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
|
||||||
|
return raw || item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFrameSrc(job: Job | null) {
|
||||||
|
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSrc(job: Job, video: GeneratedVideo) {
|
||||||
|
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageItems(job: Job | null): ImageItem[] {
|
||||||
|
if (!job) return []
|
||||||
|
return job.frames
|
||||||
|
.flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index })))
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdLabel(ts?: number) {
|
||||||
|
if (!ts) return ""
|
||||||
|
return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailPage() {
|
||||||
|
const [jobId, setJobId] = useState("")
|
||||||
|
const [job, setJob] = useState<Job | null>(null)
|
||||||
|
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [product, setProduct] = useState("SKG 颈部按摩仪")
|
||||||
|
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
|
||||||
|
const [tone, setTone] = useState("真实、直接、有购买理由")
|
||||||
|
const [seconds, setSeconds] = useState(12)
|
||||||
|
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
|
||||||
|
const [busy, setBusy] = useState<BusyTask>(null)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const images = useMemo(() => imageItems(job), [job])
|
||||||
|
const videos = useMemo(() => job?.generated_videos ?? [], [job])
|
||||||
|
const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress")
|
||||||
|
|
||||||
|
const loadJob = useCallback(async (id: string) => {
|
||||||
|
if (!id) return
|
||||||
|
setBusy("load")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const loaded = await getJob(id)
|
||||||
|
setJob(loaded)
|
||||||
|
setJobId(id)
|
||||||
|
window.history.replaceState(null, "", `/detail/?job=${id}`)
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "读取任务失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRecentJobs(await listJobs(20))
|
||||||
|
} catch {
|
||||||
|
setRecentJobs([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = new URLSearchParams(window.location.search).get("job") || ""
|
||||||
|
setJobId(id)
|
||||||
|
refreshJobs()
|
||||||
|
if (id) loadJob(id)
|
||||||
|
}, [loadJob, refreshJobs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job || !runningVideo) return
|
||||||
|
const timer = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
setJob(await getJob(job.id))
|
||||||
|
} catch {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
}
|
||||||
|
}, 2600)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [job, runningVideo])
|
||||||
|
|
||||||
|
const requireJobAndPrompt = () => {
|
||||||
|
if (!job) {
|
||||||
|
toast.error("先选择任务")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
toast.error("先写生成要求")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const runImage = async () => {
|
||||||
|
if (!requireJobAndPrompt() || !job) return
|
||||||
|
setBusy("image")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
setJob(await generateImage(job.id, 0, {
|
||||||
|
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`,
|
||||||
|
mode: sourceFrameSrc(job) ? "edit" : "text",
|
||||||
|
}))
|
||||||
|
toast.success("图片已生成")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "生图失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runVideo = async () => {
|
||||||
|
if (!requireJobAndPrompt() || !job) return
|
||||||
|
setBusy("video")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
setJob(await generateStoryboardVideo(job.id, 0, {
|
||||||
|
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
|
||||||
|
duration: seconds,
|
||||||
|
count: 1,
|
||||||
|
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||||
|
size: "720x1280",
|
||||||
|
}))
|
||||||
|
toast.success("视频已提交")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "生视频失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCopy = async () => {
|
||||||
|
const goal = prompt.trim() || `${product} ${audience}`
|
||||||
|
setBusy("copy")
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const result = await generateCreativeCopy({ goal, product, audience, tone, seconds })
|
||||||
|
setCopyVariants(result.variants)
|
||||||
|
toast.success("图文方案已生成")
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "写文案失败"
|
||||||
|
setError(message)
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteImage = async (image: ImageItem) => {
|
||||||
|
if (!job) return
|
||||||
|
try {
|
||||||
|
setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id))
|
||||||
|
toast.success("图片已删除")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteVideo = async (video: GeneratedVideo) => {
|
||||||
|
if (!job) return
|
||||||
|
try {
|
||||||
|
setJob(await deleteGeneratedVideo(job.id, video.id))
|
||||||
|
toast.success("视频已删除")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success("已复制")
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
|
||||||
|
<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">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-[#cbd6d0] bg-white text-[#35443f] transition hover:border-[#0f766e]/60"
|
||||||
|
aria-label="返回工作台"
|
||||||
|
title="返回工作台"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-normal">任务详情</h1>
|
||||||
|
<p className="mt-1 max-w-[620px] truncate text-sm text-[#66746e]">{jobTitle(job)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => jobId && loadJob(jobId)}
|
||||||
|
disabled={!jobId || !!busy}
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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_minmax(0,1fr)] gap-3">
|
||||||
|
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<h2 className="text-sm font-semibold">任务信息</h2>
|
||||||
|
<div className="mt-3 grid gap-2 text-sm">
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">ID</span>
|
||||||
|
<span className="font-mono text-xs">{job?.id || jobId || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">图片</span>
|
||||||
|
<span>{images.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||||
|
<span className="text-[#66746e]">视频</span>
|
||||||
|
<span>{videos.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sourceFrameSrc(job) ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<MediaAssetTile
|
||||||
|
src={sourceFrameSrc(job)}
|
||||||
|
alt="reference"
|
||||||
|
objectFit="contain"
|
||||||
|
previewObjectFit="contain"
|
||||||
|
className="aspect-[4/5] w-full rounded-md"
|
||||||
|
label="参考图"
|
||||||
|
meta={job?.frames?.[0]?.index ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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-[540px] gap-2 overflow-y-auto pr-1">
|
||||||
|
{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
|
||||||
|
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-semibold">{jobTitle(item)}</span>
|
||||||
|
<span className="block text-[11px] text-[#66746e]">{item.frame_count} 图源 · {item.video_count} 视频</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!recentJobs.length ? (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无任务</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-4">
|
||||||
|
{job ? (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-4 w-4 text-[#0f766e]" />
|
||||||
|
<h2 className="text-base font-semibold">生成图片</h2>
|
||||||
|
</div>
|
||||||
|
{images.length ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{images.map((image) => (
|
||||||
|
<div key={image.id} className="grid gap-1.5">
|
||||||
|
<MediaAssetTile
|
||||||
|
src={apiAssetUrl(image.url)}
|
||||||
|
alt="generated image"
|
||||||
|
objectFit="contain"
|
||||||
|
previewObjectFit="contain"
|
||||||
|
className="aspect-[4/5] w-full rounded-md"
|
||||||
|
label={image.model}
|
||||||
|
meta={image.mode}
|
||||||
|
onDelete={() => deleteImage(image)}
|
||||||
|
/>
|
||||||
|
<div className="truncate text-[11px] text-[#66746e]">{createdLabel(image.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图片结果</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clapperboard className="h-4 w-4 text-[#ea5b2d]" />
|
||||||
|
<h2 className="text-base font-semibold">生成视频</h2>
|
||||||
|
</div>
|
||||||
|
{videos.length ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{videos.map((video) => (
|
||||||
|
<div key={video.id} className="grid gap-1.5 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2">
|
||||||
|
<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-[#e4ebe6]">
|
||||||
|
<div className="h-full rounded-full bg-[#ea5b2d]" style={{ width: `${Math.max(4, video.progress)}%` }} />
|
||||||
|
</div>
|
||||||
|
{video.error ? <div className="text-xs text-rose-700">{video.error}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无视频结果</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-[#2563eb]" />
|
||||||
|
<h2 className="text-base font-semibold">营销图文</h2>
|
||||||
|
</div>
|
||||||
|
{copyVariants.length ? (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{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-44 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图文方案</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[520px] items-center justify-center rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] text-sm text-[#66746e]">
|
||||||
|
请选择任务
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<h2 className="text-sm font-semibold">继续生成</h2>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<label className="grid gap-1.5">
|
||||||
|
<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-[#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>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
placeholder="继续生成一组更高端的营销图,或者提交一条快速视频要求。"
|
||||||
|
className="min-h-40 resize-none rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3 text-sm leading-6 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={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>
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<section className="grid gap-2 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runImage}
|
||||||
|
disabled={!job || !!busy}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "image" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||||
|
生成图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runVideo}
|
||||||
|
disabled={!job || !!busy}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy === "video" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||||
|
生成视频
|
||||||
|
</button>
|
||||||
|
<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] 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" /> : <Sparkles className="h-4 w-4" />}
|
||||||
|
生成图文方案
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold">提示词</h2>
|
||||||
|
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
|
||||||
|
{[...images.slice(0, 4).map((item) => item.prompt), ...videos.slice(0, 4).map((item) => item.prompt)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={`${index}-${item.slice(0, 20)}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrompt(item)}
|
||||||
|
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2 text-left text-xs leading-5 text-[#42524c] transition hover:border-[#0f766e]/60"
|
||||||
|
>
|
||||||
|
{item.slice(0, 180)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!images.length && !videos.length ? (
|
||||||
|
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无提示词</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
735
web/app/page.tsx
735
web/app/page.tsx
@@ -3,17 +3,23 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
BadgeCheck,
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Layers3,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenLine,
|
PenLine,
|
||||||
Play,
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Upload,
|
Upload,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { MediaAssetTile } from "@/components/media-asset-tile"
|
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||||
@@ -34,45 +40,125 @@ import {
|
|||||||
type JobSummary,
|
type JobSummary,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
|
||||||
type CreatorMode = "video" | "image" | "copy"
|
type CreationMode = "text-image" | "image-image" | "text-video" | "image-video"
|
||||||
type BusyTask = "image" | "video" | "copy" | null
|
type BusyTask = "image" | "video" | "copy" | "job" | null
|
||||||
|
|
||||||
const MODE_ITEMS: Array<{
|
type ModeConfig = {
|
||||||
id: CreatorMode
|
id: CreationMode
|
||||||
label: string
|
label: string
|
||||||
icon: typeof Clapperboard
|
short: string
|
||||||
accent: string
|
output: "image" | "video"
|
||||||
}> = [
|
icon: LucideIcon
|
||||||
{ id: "video", label: "生视频", icon: Clapperboard, accent: "from-orange-500 to-rose-500" },
|
needsReference: boolean
|
||||||
{ id: "image", label: "生图", icon: ImageIcon, accent: "from-teal-500 to-cyan-500" },
|
tone: string
|
||||||
{ id: "copy", label: "写文案", icon: PenLine, accent: "from-blue-500 to-indigo-500" },
|
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 = [
|
const PROMPT_PRESETS = [
|
||||||
"一张 SKG 颈部按摩仪的信息流广告首帧,真实生活方式,产品清楚可见,画面干净高级",
|
"真实办公室午休场景,产品佩戴清楚,镜头干净,适合信息流首帧。",
|
||||||
"把参考图里的主体变成适合 TikTok 的 9:16 产品短视频,开头 2 秒要抓人,镜头有轻微推进",
|
"年轻上班族下班回家放松,先展示疲惫状态,再自然戴上 SKG 产品。",
|
||||||
"自动写一条 20 秒 SKG 产品短视频脚本,语气直接,突出日常放松和佩戴场景",
|
"白底产品功能图,高级电商质感,突出外形、佩戴方式和日常使用。",
|
||||||
|
"TikTok 竖屏短视频,前三秒强 hook,产品不要变形,动作自然可信。",
|
||||||
]
|
]
|
||||||
|
|
||||||
function cx(...items: Array<string | false | null | undefined>) {
|
function cx(...items: Array<string | false | null | undefined>) {
|
||||||
return items.filter(Boolean).join(" ")
|
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 {
|
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) {
|
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`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function Home() {
|
||||||
const [mode, setMode] = useState<CreatorMode>("video")
|
const [mode, setMode] = useState<CreationMode>("text-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 [tone, setTone] = useState("直接、可信、有购买理由")
|
const [platform, setPlatform] = useState("TikTok / Reels")
|
||||||
const [seconds, setSeconds] = useState(20)
|
const [tone, setTone] = useState("真实、直接、有购买理由")
|
||||||
|
const [seconds, setSeconds] = useState(12)
|
||||||
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)
|
||||||
@@ -82,23 +168,27 @@ export default function Home() {
|
|||||||
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 ActiveIcon = activeMode.icon
|
||||||
|
const images = useMemo(() => allGeneratedImages(job), [job])
|
||||||
const latestImage = latestGeneratedImage(job)
|
const latestImage = latestGeneratedImage(job)
|
||||||
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
refreshJobs()
|
||||||
listJobs(8)
|
}, [refreshJobs, job?.id, currentOutputCount])
|
||||||
.then((items) => {
|
|
||||||
if (!cancelled) setRecentJobs(items)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setRecentJobs([])
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [job?.id])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!referenceFile) {
|
if (!referenceFile) {
|
||||||
@@ -111,7 +201,7 @@ export default function Home() {
|
|||||||
}, [referenceFile])
|
}, [referenceFile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job || !hasRunningVideo) return
|
if (!job || !runningVideo) return
|
||||||
const timer = window.setInterval(async () => {
|
const timer = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
setJob(await getJob(job.id))
|
setJob(await getJob(job.id))
|
||||||
@@ -120,33 +210,62 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, 2600)
|
}, 2600)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [job, hasRunningVideo])
|
}, [job, runningVideo])
|
||||||
|
|
||||||
const ensureJob = useCallback(async () => {
|
const ensureJob = useCallback(async () => {
|
||||||
if (job) return job
|
if (job) return job
|
||||||
|
setBusy("job")
|
||||||
const created = await createCreativeImageJob(referenceFile)
|
const created = await createCreativeImageJob(referenceFile)
|
||||||
setJob(created)
|
setJob(created)
|
||||||
|
await refreshJobs()
|
||||||
return created
|
return created
|
||||||
}, [job, referenceFile])
|
}, [job, referenceFile, refreshJobs])
|
||||||
|
|
||||||
const onFileChange = (file: File | null) => {
|
const onFileChange = (file: File | null) => {
|
||||||
setReferenceFile(file)
|
setReferenceFile(file)
|
||||||
setJob(null)
|
setJob(null)
|
||||||
|
setCopyVariants([])
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const runImage = async () => {
|
const loadJob = async (id: string) => {
|
||||||
if (!prompt.trim()) {
|
setBusy("job")
|
||||||
toast.error("先写创作要求")
|
setError("")
|
||||||
return
|
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")
|
setBusy("image")
|
||||||
setError("")
|
setError("")
|
||||||
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: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}.`,
|
||||||
mode: referenceFile ? "edit" : "text",
|
mode: activeMode.needsReference ? "edit" : "text",
|
||||||
})
|
})
|
||||||
setJob(updated)
|
setJob(updated)
|
||||||
toast.success("图片已生成")
|
toast.success("图片已生成")
|
||||||
@@ -160,16 +279,13 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runVideo = async () => {
|
const runVideo = async () => {
|
||||||
if (!prompt.trim()) {
|
if (!validatePrompt()) return
|
||||||
toast.error("先写创作要求")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusy("video")
|
setBusy("video")
|
||||||
setError("")
|
setError("")
|
||||||
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: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
|
||||||
duration: seconds,
|
duration: seconds,
|
||||||
count: 1,
|
count: 1,
|
||||||
first_image: { kind: "keyframe", frame_idx: 0 },
|
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||||
@@ -187,22 +303,20 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runCopy = async () => {
|
const runCopy = async () => {
|
||||||
if (!prompt.trim()) {
|
const goal = prompt.trim() || `${product} ${audience} ${platform}`
|
||||||
toast.error("先写文案目标")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setBusy("copy")
|
setBusy("copy")
|
||||||
setError("")
|
setError("")
|
||||||
try {
|
try {
|
||||||
const result = await generateCreativeCopy({
|
const result = await generateCreativeCopy({
|
||||||
goal: prompt,
|
goal,
|
||||||
product,
|
product,
|
||||||
audience,
|
audience,
|
||||||
|
platform,
|
||||||
tone,
|
tone,
|
||||||
seconds,
|
seconds,
|
||||||
})
|
})
|
||||||
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)
|
||||||
@@ -213,8 +327,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runPrimary = () => {
|
const runPrimary = () => {
|
||||||
if (mode === "image") return runImage()
|
if (activeMode.output === "image") return runImage()
|
||||||
if (mode === "copy") return runCopy()
|
|
||||||
return runVideo()
|
return runVideo()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,69 +360,99 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useVariant = (variant: CreativeCopyVariant, nextMode: CreatorMode) => {
|
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
|
||||||
setMode(nextMode)
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-[#f7f8f4] text-[#10211f]">
|
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
|
||||||
<Toaster richColors position="top-center" />
|
<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">
|
<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 shrink-0 items-center justify-between border-b border-[#d9ded5] pb-4">
|
<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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#10211f] text-white">
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#16231f] text-white">
|
||||||
<Sparkles className="h-5 w-5" />
|
<Layers3 className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-normal">SKG 营销内容工作台</h1>
|
<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>
|
</div>
|
||||||
<div className="hidden items-center gap-2 text-sm text-[#5f6f69] md:flex">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">gpt-image-2</span>
|
<a
|
||||||
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">Seedance / Kling / Veo</span>
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid min-h-0 flex-1 gap-4 py-4 lg:grid-cols-[280px_minmax(0,1fr)_390px]">
|
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
|
||||||
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
|
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||||
<div className="grid gap-2">
|
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
{MODE_ITEMS.map((item) => {
|
<div className="mb-3 flex items-center justify-between">
|
||||||
const Icon = item.icon
|
<h2 className="text-sm font-semibold">创作入口</h2>
|
||||||
const selected = item.id === mode
|
<span className="rounded bg-[#f2f5ef] px-2 py-1 text-[11px] text-[#66746e]">4 模式</span>
|
||||||
return (
|
</div>
|
||||||
<button
|
<div className="grid gap-2">
|
||||||
key={item.id}
|
{CREATION_MODES.map((item) => {
|
||||||
type="button"
|
const Icon = item.icon
|
||||||
onClick={() => setMode(item.id)}
|
const selected = item.id === mode
|
||||||
className={cx(
|
return (
|
||||||
"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",
|
<button
|
||||||
selected ? "border-[#10211f] bg-[#10211f] text-white" : "border-[#d9ded5] bg-[#f7f8f4] text-[#263b36] hover:border-[#9db4ad]",
|
key={item.id}
|
||||||
)}
|
type="button"
|
||||||
>
|
onClick={() => setMode(item.id)}
|
||||||
<span className="flex items-center gap-2">
|
className={cx(
|
||||||
<Icon className="h-4 w-4" />
|
"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",
|
||||||
{item.label}
|
selected ? item.active : `${item.tone} hover:border-[#94a39b]`,
|
||||||
</span>
|
)}
|
||||||
{selected ? <ArrowRight className="h-4 w-4" /> : null}
|
>
|
||||||
</button>
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
)
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
})}
|
<span className="min-w-0">
|
||||||
</div>
|
<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">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">参考图</span>
|
<h2 className="text-sm font-semibold">参考图</h2>
|
||||||
{referenceFile ? (
|
{referenceFile ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onFileChange(null)}
|
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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -321,47 +464,52 @@ export default function Home() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{referencePreview ? (
|
{currentReference ? (
|
||||||
<MediaAssetTile
|
<MediaAssetTile
|
||||||
src={referencePreview}
|
src={currentReference}
|
||||||
alt="reference"
|
alt="reference"
|
||||||
objectFit="cover"
|
objectFit="contain"
|
||||||
previewObjectFit="contain"
|
previewObjectFit="contain"
|
||||||
className="aspect-[4/5] w-full rounded-md"
|
className="aspect-[4/5] w-full rounded-md"
|
||||||
label={referenceFile?.name}
|
label={referenceFile?.name || jobTitle(job)}
|
||||||
onDelete={() => onFileChange(null)}
|
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-[#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" />
|
<Upload className="mb-2 h-5 w-5" />
|
||||||
<span className="text-sm">上传图片</span>
|
<span className="text-sm">上传图片</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
|
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<RefreshCw className="h-4 w-4" />
|
<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>
|
||||||
<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) => (
|
{recentJobs.length ? recentJobs.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={() => loadJob(item.id)}
|
||||||
try {
|
className={cx(
|
||||||
setJob(await getJob(item.id))
|
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
|
||||||
setError("")
|
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
|
||||||
} 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
|
<MediaAssetTile
|
||||||
src={apiAssetUrl(item.thumbnail)}
|
src={apiAssetUrl(item.thumbnail)}
|
||||||
@@ -371,219 +519,262 @@ export default function Home() {
|
|||||||
disablePreview={!item.thumbnail}
|
disablePreview={!item.thumbnail}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-xs font-medium">{item.url.replace(/^creative:\/\//, "") || item.id}</span>
|
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
|
||||||
<span className="block text-[11px] text-[#6b7b75]">{item.frame_count} 图 · {item.video_count} 视频</span>
|
<span className="block text-[11px] text-[#66746e]">{statusLabel(item.status)} · {item.frame_count} 图源 · {item.video_count} 视频</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="flex min-h-[680px] flex-col rounded-lg border border-[#d9ded5] bg-white">
|
<section className="flex min-h-[720px] flex-col rounded-lg border border-[#d8dfd4] bg-white">
|
||||||
<div className={cx("h-1.5 rounded-t-lg bg-gradient-to-r", activeMode.accent)} />
|
<div className="flex items-center justify-between gap-3 border-b border-[#e1e6dd] p-4">
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 sm:p-5">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className={cx("flex h-11 w-11 items-center justify-center rounded-md border", activeMode.tone)}>
|
||||||
<div className="flex items-center gap-3">
|
<ActiveIcon className="h-5 w-5" />
|
||||||
<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>
|
</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"
|
type="button"
|
||||||
onClick={runPrimary}
|
onClick={runPrimary}
|
||||||
disabled={!!busy}
|
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" />}
|
{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" />}
|
||||||
{mode === "copy" ? "生成文案" : mode === "image" ? "生成图片" : "生成视频"}
|
{activeMode.output === "image" ? "生成图片" : "生成视频"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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
|
<input
|
||||||
value={product}
|
value={product}
|
||||||
onChange={(event) => setProduct(event.target.value)}
|
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>
|
||||||
<label className="grid gap-1.5">
|
<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
|
<input
|
||||||
value={audience}
|
value={audience}
|
||||||
onChange={(event) => setAudience(event.target.value)}
|
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>
|
||||||
<label className="grid gap-1.5">
|
<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
|
<select
|
||||||
value={seconds}
|
value={seconds}
|
||||||
onChange={(event) => setSeconds(Number(event.target.value))}
|
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>)}
|
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} 秒</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="grid flex-1 gap-2">
|
<label className="grid min-h-0 gap-2">
|
||||||
<span className="text-xs font-medium text-[#50645e]">{mode === "copy" ? "文案目标" : "创作要求"}</span>
|
<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={mode === "copy" ? PROMPT_TEMPLATES[2] : mode === "image" ? PROMPT_TEMPLATES[0] : PROMPT_TEMPLATES[1]}
|
placeholder={activeMode.placeholder}
|
||||||
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"
|
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>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="grid gap-3">
|
||||||
{PROMPT_TEMPLATES.map((item) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
{PROMPT_PRESETS.map((item) => (
|
||||||
key={item}
|
<button
|
||||||
type="button"
|
key={item}
|
||||||
onClick={() => setPrompt(item)}
|
type="button"
|
||||||
className="rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 py-2 text-xs text-[#40534d] transition hover:border-[#0d9488]/70 hover:bg-[#eef8f5]"
|
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>
|
{item}
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
{mode === "copy" ? (
|
|
||||||
<label className="grid gap-1.5">
|
<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
|
<input
|
||||||
value={tone}
|
value={tone}
|
||||||
onChange={(event) => setTone(event.target.value)}
|
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>
|
</label>
|
||||||
) : 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>
|
||||||
{error ? (
|
) : null}
|
||||||
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
{error ? (
|
||||||
) : null}
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
|
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||||
<h2 className="text-sm font-semibold">结果</h2>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{job ? <span className="rounded bg-[#f1f4ef] px-2 py-1 font-mono text-[11px] text-[#6b7b75]">{job.id}</span> : null}
|
<div>
|
||||||
</div>
|
<h2 className="text-sm font-semibold">当前任务</h2>
|
||||||
|
<p className="mt-1 max-w-[260px] truncate text-xs text-[#66746e]">{jobTitle(job)}</p>
|
||||||
<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>
|
||||||
) : (
|
{job ? <span className="rounded bg-[#f2f5ef] px-2 py-1 font-mono text-[11px] text-[#66746e]">{job.id}</span> : null}
|
||||||
<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>
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
{copyVariants.length ? (
|
<div className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-2 py-2">
|
||||||
<div className="grid gap-2">
|
<div className="text-lg font-semibold">{images.length}</div>
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
|
<div className="text-[11px] text-[#66746e]">图片</div>
|
||||||
<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>
|
</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}
|
) : 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>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user