544 lines
24 KiB
TypeScript
544 lines
24 KiB
TypeScript
"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>
|
|
)
|
|
}
|