Files
20260512-skg-tk/web/components/dashboard.tsx
2026-05-12 19:09:08 +08:00

518 lines
25 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>
)
}
type KanbanTone = "violet" | "pink" | "orange" | "blue" | "green" | "cyan" | "rose" | "amber"
function KanbanCard({
tone = "violet",
tags,
title,
className = "",
onClick,
meta,
children,
}: {
tone?: KanbanTone
tags?: string[]
title?: ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
meta?: ReactNode
children?: ReactNode
}) {
return (
<div className={`kanban-card kanban-${tone} ${onClick ? "cursor-pointer" : ""} ${className}`} onClick={onClick}>
{tags && tags.length > 0 && (
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
{tags.map((t) => (
<span key={t} className="kanban-tag">#{t}</span>
))}
</div>
)}
{title && <h3 className="text-[13.5px] font-semibold text-[var(--text-strong)] leading-snug mb-1.5">{title}</h3>}
{children}
{meta && <div className="kanban-meta">{meta}</div>}
</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 tileRefs = useRef<Record<string, HTMLButtonElement | null>>({})
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())
const Tile = ({ tkey, rowSpan }: { tkey: string; rowSpan?: boolean }) => {
const t = TILES.find((x) => x.key === tkey)!
const state = colState[t.key]
const isOpen = expanded.has(t.key)
return (
<button
ref={(el) => { tileRefs.current[t.key] = el }}
type="button"
onClick={() => toggleTile(t.key)}
title={colSummary[t.key]}
className={`group rounded-md overflow-hidden border flex items-stretch transition ${
rowSpan ? "row-span-2" : ""
} ${isOpen ? "border-violet-400/60 ring-2 ring-violet-400/40" : "border-white/10 hover:border-white/20"}`}
style={{ height: rowSpan ? "auto" : 30 }}
>
<div className="px-2 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-[11.5px] font-medium whitespace-nowrap">{t.title}</span>
</div>
<div className="px-1.5 flex items-center gap-1 bg-black/40">
<span className={`h-1.5 w-1.5 rounded-full ${STATE_DOT[state]}`} />
<ChevronDown className={`h-3 w-3 text-white/60 transition ${isOpen ? "rotate-180" : ""}`} />
</div>
</button>
)
}
return (
<div className="w-full">
{/* Tile Bar — DAG 拓扑布局input/download/split → (关键帧路 / 转录路) → compose */}
<div className="px-3 pt-2 pb-1.5 flex items-center gap-1.5">
<Tile tkey="input" />
<Tile tkey="download" />
<Tile tkey="split" />
{/* 分叉:上下两路 */}
<div className="flex-1 grid grid-rows-2 gap-1 mx-1">
<div className="flex gap-1.5 items-center">
<Tile tkey="keyframe" />
<Tile tkey="imagegen" />
<Tile tkey="videogen" />
</div>
<div className="flex gap-1.5 items-center">
<Tile tkey="asr" />
<Tile tkey="translate" />
<Tile tkey="rewrite" />
</div>
</div>
<Tile tkey="compose" />
</div>
{/* 展开面板 — 从屏幕左侧滑出,竖向 sidebar drawer */}
{expanded.size > 0 && (
<div
className="fixed z-40 left-4 transition-transform duration-200"
style={{ top: 80, bottom: 16, width: 380 }}
>
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
<section
key={t.key}
className="rounded-xl border border-white/10 bg-black/50 backdrop-blur-xl overflow-hidden flex flex-col shadow-2xl h-full"
style={{ animation: "drawer-in 0.22s cubic-bezier(0.32, 0.72, 0, 1)" }}
>
<div className="flex items-center justify-between px-3 py-2" 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/70 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="overflow-y-auto p-3 bg-black/20 flex-1">
{renderSection(t.key)}
</div>
</section>
))}
</div>
)}
</div>
)
function renderSection(key: string): ReactNode {
return (
<div className="space-y-3">
{/* ---- Input — Kanban ---- */}
{key === "input" && (
<>
<KanbanCard tone="violet" tags={["链接", "上传"]} title="输入源">
<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/30 border border-white/15 outline-none text-[var(--text-strong)] placeholder:text-[var(--text-faint)] focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-40 mt-1"
/>
<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>
</KanbanCard>
{hasVideo && (
<KanbanCard tone="green" tags={["下一步"]} title="解析视频">
<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 mt-1 ${!isAnalyzing && !data.analyzing && !hasFrames ? "animate-pulse" : ""}`}
>
{(isAnalyzing || data.analyzing) ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> </> : hasFrames ? "重新解析" : "▶ 开始解析"}
</button>
{job && (
<div className="kanban-meta">
<span className="font-mono truncate">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : job.url}
</span>
</div>
)}
</KanbanCard>
)}
</>
)}
{/* ---- Download — Kanban ---- */}
{key === "download" && (
!hasVideo ? (
<KanbanCard tone="orange" tags={["yt-dlp"]} title={isDownloading ? "下载中…" : "等待提交"}>
<div className="text-[11.5px] text-[var(--text-soft)]">TikTok / yt-dlp </div>
</KanbanCard>
) : (
<>
<KanbanCard tone="orange" tags={["视频源"]} title={job!.url.startsWith("upload://") ? "本地上传" : "yt-dlp 下载"}>
<video
ref={videoRef}
src={videoUrl(job!.id)}
controls
onTimeUpdate={(e) => setVideoT((e.target as HTMLVideoElement).currentTime)}
className="block w-full bg-black rounded-md mt-1"
/>
</KanbanCard>
<KanbanCard tone="amber" tags={["元数据"]} title="视频信息">
<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-[12.5px] 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-[12.5px] 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-[12.5px] mt-0.5">{job!.url.startsWith("upload://") ? "上传" : "TK"}</div></div>
</div>
</KanbanCard>
</>
)
)}
{/* ---- Split — Kanban ---- */}
{key === "split" && (
<>
<KanbanCard tone="amber" tags={["ffmpeg", "视频流"]} title="→ 关键帧抽取">
<div className="text-[11px] text-[var(--text-soft)]">fast seek + Laplacian </div>
</KanbanCard>
<KanbanCard tone="amber" tags={["ffmpeg", "音频流"]} title="→ ASR 输入">
<div className="text-[11px] text-[var(--text-soft)]">16kHz mono · pcm_s16le wav</div>
<div className="kanban-meta">
<code className="text-[10px]">-vn -ac 1 -ar 16000</code>
</div>
</KanbanCard>
</>
)}
{/* ---- Keyframe — Kanban 卡片 ---- */}
{key === "keyframe" && (
<div className="space-y-3">
{hasVideo && job && (
<KanbanCard tone="green" tags={["手动加帧"]} title="从视频任意时间点抽 1 张">
<button
type="button"
disabled={addingFrame}
onClick={async () => {
const t = videoRef.current?.currentTime ?? videoT
setAddingFrame(true)
try { await data.onAddManualFrame(t) } finally { setAddingFrame(false) }
}}
className="mt-1 w-full text-[12px] py-1.5 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white 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" />}
{addingFrame ? "抽帧中…" : `${videoT.toFixed(1)}s 加为关键帧`}
</button>
</KanbanCard>
)}
{!hasFrames ? (
<KanbanCard tone="pink" tags={["分镜"]} title="等待解析后抽取">
<div className="text-[11.5px] text-[var(--text-soft)]"> 30 pHash + 5 </div>
</KanbanCard>
) : (
job!.frames.map((f) => {
const isSel = data.selectedFrames.has(f.index)
return (
<KanbanCard
key={f.index}
tone={isSel ? "green" : "pink"}
tags={[`分镜 ${f.index + 1}`, `${f.timestamp.toFixed(1)}s`]}
className={isSel ? "ring-2 ring-emerald-400/60" : ""}
meta={
<button
type="button"
onClick={(e) => { e.stopPropagation(); data.onToggleFrame(f.index) }}
className={`ml-auto text-[10.5px] px-2 py-0.5 rounded-full inline-flex items-center gap-1 ${
isSel
? "bg-emerald-500 text-white"
: "bg-white/10 text-[var(--text-soft)] border border-white/15 hover:bg-white/20"
}`}
>
<Check className="h-2.5 w-2.5" />
{isSel ? "已选用" : "选用此帧"}
</button>
}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); data.onExpandFrame(f.index) }}
className="block w-full rounded-md overflow-hidden bg-black"
title="点击放大"
>
<img
src={frameUrl(job!.id, f.index)}
alt={`frame ${f.index}`}
className="block w-full h-auto"
style={{ objectFit: "contain" }}
/>
</button>
</KanbanCard>
)
})
)}
</div>
)}
{/* ---- ASR / Translate — Kanban 段落卡 ---- */}
{(key === "asr" || key === "translate") && (
!hasTranscript ? (
<KanbanCard tone={key === "asr" ? "blue" : "cyan"} tags={[key === "asr" ? "ASR" : "Translate"]} title="等待数据">
<div className="text-[11.5px] text-[var(--text-soft)]">
{colState.asr === "running" ? "Gemini 转录中…" : "需要先完成关键帧抽取"}
</div>
</KanbanCard>
) : (
job!.transcript.map((s) => (
<KanbanCard
key={s.index}
tone={key === "asr" ? "blue" : "cyan"}
tags={[`段落 ${s.index + 1}`, `${s.start.toFixed(1)}s → ${s.end.toFixed(1)}s`]}
>
<div className="text-[12.5px] text-[var(--text-strong)] leading-snug mb-1.5">
<span className="kanban-tag mr-1.5" style={{ padding: "1px 6px", fontSize: 9.5 }}>EN</span>
{s.en}
</div>
<div className="text-[12.5px] text-[var(--text-strong)] leading-snug">
<span className="kanban-tag mr-1.5" style={{ padding: "1px 6px", fontSize: 9.5 }}>ZH</span>
{s.zh || <span className="text-[var(--text-faint)] italic"></span>}
</div>
</KanbanCard>
))
)
)}
{/* ---- 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>
)
}
}