Files
20260512-skg-tk/web/components/dashboard.tsx
2026-05-12 18:35:34 +08:00

458 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useRef, useState, type ReactNode } from "react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, Check,
ChevronDown, X,
} from "lucide-react"
import { type Job, frameUrl, videoUrl } from "@/lib/api"
import { type NodeData } from "@/components/nodes"
type ColType = "input" | "process" | "ai" | "output"
const TYPE_GRAD: Record<ColType, string> = {
input: "linear-gradient(135deg, #6366f1, #a855f7)",
process: "linear-gradient(135deg, #f59e0b, #ef4444)",
ai: "linear-gradient(135deg, #d946ef, #ec4899)",
output: "linear-gradient(135deg, #10b981, #06b6d4)",
}
type ColState = "pending" | "running" | "done" | "failed"
const STATE_DOT: Record<ColState, string> = {
pending: "bg-white/25",
running: "bg-violet-300 shadow-[0_0_8px_rgba(167,139,250,0.8)] animate-pulse",
done: "bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.7)]",
failed: "bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.7)]",
}
function MiniCard({ children, className = "", onClick }: { children: ReactNode; className?: string; onClick?: (e: React.MouseEvent) => void }) {
return (
<div
className={`rounded-lg bg-white/[0.04] dark:bg-white/[0.04] border border-white/10 backdrop-blur-md p-2.5 text-[var(--text-strong)] ${onClick ? "cursor-pointer hover:bg-white/[0.07] hover:border-white/20 transition" : ""} ${className}`}
onClick={onClick}
>
{children}
</div>
)
}
interface Props {
data: NodeData
}
export function Dashboard({ data }: Props) {
const { job } = data
const [url, setUrl] = useState("")
const [videoT, setVideoT] = useState(0)
const [addingFrame, setAddingFrame] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const fileRef = useRef<HTMLInputElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
/* 状态推导 */
const hasVideo = !!job?.video_url
const isDownloading = job?.status === "downloading" || job?.status === "created"
const isSplitting = job?.status === "splitting"
const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status)
const hasFrames = (job?.frames.length ?? 0) > 0
const hasTranscript = (job?.transcript.length ?? 0) > 0
const hasZh = job?.transcript.some((s) => s.zh) ?? false
const isFailed = job?.status === "failed"
const colState: Record<string, ColState> = {
input: !job ? "pending" : "done",
download: !job ? "pending" : isDownloading ? "running" : hasVideo ? "done" : isFailed && job.progress < 30 ? "failed" : "pending",
split: !job ? "pending" : isSplitting ? "running" : hasFrames ? "done" : isFailed && job.progress >= 30 && job.progress < 50 ? "failed" : "pending",
keyframe: !job ? "pending" : (isSplitting && !hasFrames) ? "running" : hasFrames ? "done" : isFailed && job.progress >= 50 && job.progress < 70 ? "failed" : "pending",
asr: !job ? "pending" : job.status === "transcribing" ? "running" : hasTranscript ? "done" : isFailed && job.progress >= 70 ? "failed" : "pending",
translate: !job ? "pending" : job.status === "transcribing" ? "running" : hasZh ? "done" : "pending",
rewrite: "pending",
imagegen: "pending",
videogen: "pending",
compose: "pending",
}
/* 每列摘要 = tile 副标题 */
const colSummary: Record<string, string> = {
input: job ? (job.url.startsWith("upload://") ? "📎 上传" : "🔗 链接") : "等待",
download: hasVideo && job ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : isDownloading ? "下载中…" : "—",
split: hasFrames ? "wav 已生成" : isSplitting ? "拆轨中…" : "—",
keyframe: hasFrames ? `${data.selectedFrames.size}/${job!.frames.length} 选用` : "—",
asr: hasTranscript ? `${job!.transcript.length}` : "—",
translate: hasZh ? `${job!.transcript.filter((s) => s.zh).length}` : "—",
rewrite: "占位",
imagegen: data.selectedFrames.size > 0 ? `${data.selectedFrames.size} 张待生` : "占位",
videogen: "占位",
compose: "占位",
}
const TILES: Array<{ key: string; title: string; type: ColType; icon: ReactNode; step: number }> = [
{ key: "input", title: "输入", type: "input", icon: <Link2 className="h-3.5 w-3.5" />, step: 1 },
{ key: "download", title: "下载", type: "process", icon: <Download className="h-3.5 w-3.5" />, step: 2 },
{ key: "split", title: "拆分", type: "process", icon: <Scissors className="h-3.5 w-3.5" />, step: 3 },
{ key: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 4 },
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 5 },
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 6 },
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 7 },
{ key: "imagegen", title: "生图", type: "ai", icon: <Sparkles className="h-3.5 w-3.5" />, step: 8 },
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 9 },
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 10 },
]
// 单选展开toggle 同一 key = 收起;点其他 key = 切换
const toggleTile = (key: string) => setExpanded((prev) => (prev.has(key) ? new Set() : new Set([key])))
const closeTile = (_key: string) => setExpanded(new Set())
return (
<div className="w-full">
{/* Tile Bar — 一直显示 */}
<div className="px-4 pt-3 pb-2 flex items-center gap-1.5 overflow-x-auto">
{TILES.map((t) => {
const state = colState[t.key]
const isOpen = expanded.has(t.key)
return (
<button
key={t.key}
type="button"
onClick={() => toggleTile(t.key)}
className={`shrink-0 group rounded-lg overflow-hidden border transition flex flex-col text-left ${
isOpen ? "border-violet-400/60 ring-2 ring-violet-400/40" : "border-white/8 hover:border-white/20"
}`}
style={{ width: 134 }}
>
<div className="relative px-2.5 py-1.5 flex items-center gap-1.5" style={{ background: TYPE_GRAD[t.type] }}>
<span className="text-white/70 text-[9px] font-mono">{String(t.step).padStart(2, "0")}</span>
<span className="text-white">{t.icon}</span>
<span className="text-white text-[12px] font-medium">{t.title}</span>
<span className="ml-auto flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${STATE_DOT[state]}`} />
<ChevronDown className={`h-3 w-3 text-white/70 transition ${isOpen ? "rotate-180" : ""}`} />
</span>
</div>
<div className="px-2.5 py-1 bg-black/40 text-[10px] text-[var(--text-soft)] truncate">
{colSummary[t.key]}
</div>
</button>
)
})}
</div>
{/* 展开面板 — 多卡同时展开,按 step 顺序排列 */}
{expanded.size > 0 && (
<div className="relative mx-4 mb-3 rounded-xl border border-white/10 bg-black/30 backdrop-blur-xl overflow-hidden" style={{ maxHeight: "52vh" }}>
<div className="flex items-center justify-between px-4 py-2 border-b border-white/8">
<span className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]"></span>
<button
onClick={() => setExpanded(new Set())}
className="text-[var(--text-faint)] hover:text-[var(--text-strong)]"
title="收起"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: "calc(52vh - 40px)" }}>
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
<section key={t.key} className="rounded-lg border border-white/8 overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5" style={{ background: TYPE_GRAD[t.type] }}>
<div className="flex items-center gap-1.5">
<span className="text-white/70 text-[9px] font-mono">{String(t.step).padStart(2, "0")}</span>
<span className="text-white">{t.icon}</span>
<span className="text-[12.5px] font-semibold text-white">{t.title}</span>
<span className="text-[10px] text-white/60 ml-2">{colSummary[t.key]}</span>
</div>
<button
onClick={() => closeTile(t.key)}
className="text-white/70 hover:text-white"
title="收起此卡"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="p-3 bg-black/20">
{renderSection(t.key)}
</div>
</section>
))}
</div>
</div>
)}
</div>
)
function renderSection(key: string): ReactNode {
return (
<>
{/* ---- Input ---- */}
{key === "input" && (
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5"> / </div>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="粘贴 TikTok 链接"
disabled={isDownloading || data.submitting}
className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/10 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40"
/>
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={isDownloading || data.submitting || !url.trim()}
onClick={() => data.onSubmitUrl(url.trim())}
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-white text-black hover:bg-white/90 disabled:opacity-30 inline-flex items-center justify-center gap-1"
>
{(data.submitting || isDownloading) && <Loader2 className="h-3 w-3 animate-spin" />}
{isDownloading ? "下载中" : "提交"}
</button>
<button
type="button"
disabled={isDownloading || data.submitting}
onClick={() => fileRef.current?.click()}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/[0.06] border border-white/15 hover:bg-white/[0.12] inline-flex items-center gap-1 disabled:opacity-30"
>
<Upload className="h-3 w-3" />
</button>
<input
ref={fileRef}
type="file"
accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm,.mkv,.m4v"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) data.onUploadFile(f)
e.target.value = ""
}}
/>
</div>
</MiniCard>
{hasVideo && (
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5"></div>
<button
type="button"
disabled={isAnalyzing || data.analyzing}
onClick={data.onAnalyze}
className={`w-full text-[12.5px] py-2 rounded-md bg-gradient-to-r from-indigo-500 to-violet-500 text-white hover:opacity-95 disabled:opacity-40 inline-flex items-center justify-center gap-1.5 font-semibold shadow-lg shadow-violet-500/30 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
>
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> </> : hasFrames ? "重新解析" : "▶ 开始解析"}
</button>
{job && (
<div className="mt-2 text-[10px] font-mono text-[var(--text-faint)] truncate">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
</div>
)}
</MiniCard>
)}
</div>
)}
{/* ---- Download ---- */}
{key === "download" && hasVideo && job && (
<div className="grid grid-cols-[auto_1fr] gap-4 max-w-4xl items-start">
<MiniCard className="p-0 overflow-hidden" >
<video
ref={videoRef}
src={videoUrl(job.id)}
controls
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="block bg-black"
style={{ width: 260 }}
/>
</MiniCard>
<div className="space-y-2">
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5"></div>
<div className="grid grid-cols-3 gap-3 text-[11px] font-mono">
<div><div className="text-[var(--text-faint)] text-[9.5px]"></div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.width}×{job.height}</div></div>
<div><div className="text-[var(--text-faint)] text-[9.5px]"></div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.duration.toFixed(1)}s</div></div>
<div><div className="text-[var(--text-faint)] text-[9.5px]"></div><div className="text-[var(--text-strong)] text-[13px] mt-0.5">{job.url.startsWith("upload://") ? "上传" : "yt-dlp"}</div></div>
</div>
</MiniCard>
</div>
</div>
)}
{key === "download" && !hasVideo && (
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">{isDownloading ? "yt-dlp 下载中…" : "等待提交"}</div>
)}
{/* ---- Split ---- */}
{key === "split" && (
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1"></div>
<div className="text-[13px] text-[var(--text-strong)]"> </div>
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg fast seek + Laplacian </div>
</MiniCard>
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1"></div>
<div className="text-[13px] text-[var(--text-strong)]"> ASR (16kHz mono wav)</div>
<div className="text-[10.5px] text-[var(--text-faint)] mt-1">ffmpeg -vn -ac 1 -ar 16000</div>
</MiniCard>
</div>
)}
{/* ---- Keyframe ---- */}
{key === "keyframe" && (
<div className="space-y-3">
{hasVideo && job && (
<MiniCard>
<button
type="button"
disabled={addingFrame}
onClick={async () => {
const t = videoRef.current?.currentTime ?? videoT
setAddingFrame(true)
try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
}}
className="w-full text-[12px] py-2 rounded-md border border-dashed border-emerald-400/40 bg-emerald-400/5 hover:bg-emerald-400/10 text-emerald-300 disabled:opacity-50 inline-flex items-center justify-center gap-1.5"
>
{addingFrame ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
+ {videoT.toFixed(1)}s
</button>
</MiniCard>
)}
{!hasFrames ? (
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center"> 5 </div>
) : (
<div className="grid grid-cols-5 gap-3">
{job!.frames.map((f) => {
const isSel = data.selectedFrames.has(f.index)
return (
<MiniCard key={f.index} className={`p-0 overflow-hidden ${isSel ? "ring-2 ring-emerald-400 border-emerald-400" : ""}`}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
className="block w-full aspect-video bg-black relative overflow-hidden"
>
<img src={frameUrl(job!.id, f.index)} alt={`frame ${f.index}`} className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute top-1 left-1 bg-black/70 text-white text-[9px] font-mono px-1.5 py-0.5 rounded">#{f.index + 1}</div>
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-[9px] font-mono px-1.5 py-0.5 rounded">{f.timestamp.toFixed(1)}s</div>
</button>
<div className="px-2 py-1.5 flex items-center justify-between">
<span className="text-[10.5px] text-[var(--text-soft)]"> {f.index + 1}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); data.onToggleFrame(f.index) }}
className={`text-[10px] px-1.5 py-0.5 rounded inline-flex items-center gap-0.5 ${isSel ? "bg-emerald-500 text-white" : "bg-white/10 text-[var(--text-soft)] border border-white/10"}`}
>
<Check className="h-2.5 w-2.5" />
{isSel ? "已选" : "选用"}
</button>
</div>
</MiniCard>
)
})}
</div>
)}
</div>
)}
{/* ---- ASR / Translate ---- */}
{(key === "asr" || key === "translate") && (
!hasTranscript ? (
<div className="text-[11.5px] text-[var(--text-faint)] py-4 text-center">
{colState.asr === "running" ? "Gemini 转录中…" : "等待关键帧抽取"}
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{job!.transcript.map((s) => (
<MiniCard key={s.index}>
<div className="text-[9.5px] font-mono text-[var(--text-faint)] mb-1.5">
{s.start.toFixed(1)}s {s.end.toFixed(1)}s
</div>
<div className="text-[12px] text-[var(--text-strong)] leading-snug mb-1">
<span className="text-[var(--text-faint)] mr-1 text-[10px]">EN</span>{s.en}
</div>
<div className="text-[12px] text-[var(--text-strong)] leading-snug">
<span className="text-[var(--text-faint)] mr-1 text-[10px]">ZH</span>{s.zh || <span className="text-[var(--text-faint)]"></span>}
</div>
</MiniCard>
))}
</div>
)
)}
{/* ---- Rewrite ---- */}
{key === "rewrite" && (
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5"></div>
<textarea
rows={4}
placeholder="SKG 产品关键卖点(占位)"
disabled
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/30 border border-dashed border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
/>
</MiniCard>
<MiniCard>
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)] mb-1.5"> & </div>
<div className="text-[11.5px] text-[var(--text-soft)] mb-1"><span className="font-mono text-[var(--text-strong)]">gemini-2.5-pro</span></div>
<div className="text-[10.5px] text-[var(--text-faint)]"></div>
</MiniCard>
</div>
)}
{/* ---- ImageGen ---- */}
{key === "imagegen" && (
<div className="space-y-3">
<MiniCard>
<div className="grid grid-cols-2 gap-2 text-[11.5px]">
<div className="rounded-md bg-violet-500/10 border border-violet-400/30 px-2.5 py-2">
<div className="text-[9.5px] text-violet-300 uppercase tracking-widest"></div>
<div className="text-[var(--text-strong)]">nano-banana-pro</div>
<div className="text-[10px] text-[var(--text-faint)]">Gemini 3 Pro Image</div>
</div>
<div className="rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-2">
<div className="text-[9.5px] text-[var(--text-faint)] uppercase tracking-widest"></div>
<div className="text-[var(--text-strong)]">gpt-image-2</div>
<div className="text-[10px] text-[var(--text-faint)]">OpenAI</div>
</div>
</div>
</MiniCard>
{data.selectedFrames.size === 0 ? (
<div className="text-[11.5px] text-[var(--text-faint)] py-3 text-center"></div>
) : (
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: data.selectedFrames.size }).map((_, i) => (
<MiniCard key={i} className="aspect-video flex items-center justify-center">
<span className="text-[11px] text-[var(--text-faint)]">#{i + 1} </span>
</MiniCard>
))}
</div>
)}
</div>
)}
{/* ---- VideoGen ---- */}
{key === "videogen" && (
<div className="grid grid-cols-3 gap-3 max-w-2xl">
{["Sora 2 · SKG", "Seedance · 外部", "Kling · 外部"].map((m) => (
<MiniCard key={m}>
<div className="text-[10.5px] text-[var(--text-soft)]">{m}</div>
</MiniCard>
))}
</div>
)}
{/* ---- Compose ---- */}
{key === "compose" && (
<div className="grid grid-cols-2 gap-3 max-w-2xl">
<MiniCard className="aspect-video flex items-center justify-center">
<span className="text-[11.5px] text-[var(--text-faint)]"> · </span>
</MiniCard>
<MiniCard>
<div className="text-[11px] text-[var(--text-soft)]">
+ / TTS
</div>
<div className="text-[10px] text-[var(--text-faint)] mt-1"> ffmpeg · API</div>
</MiniCard>
</div>
)}
</div>
</div>
)}
</div>
)
}