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

524 lines
26 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 }: { tkey: string }) => {
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 w-full rounded-md overflow-hidden border flex items-stretch transition ${
isOpen ? "border-violet-400/70 ring-2 ring-violet-400/40 shadow-lg shadow-violet-500/20" : "border-white/10 hover:border-white/20"
}`}
style={{ height: 30 }}
>
<div className="px-1.5 flex items-center gap-1.5 flex-1 min-w-0" style={{ background: TYPE_GRAD[t.type] }}>
<span className="text-white shrink-0">{t.icon}</span>
<span className="text-white text-[11.5px] font-medium truncate">{t.title}</span>
</div>
<div className="px-1.5 flex items-center bg-black/40 shrink-0">
<span className={`h-1.5 w-1.5 rounded-full ${STATE_DOT[state]}`} />
</div>
</button>
)
}
return (
<div className="h-full flex flex-col gap-1.5 p-2.5">
<div className="text-[9.5px] uppercase tracking-[0.25em] text-[var(--text-faint)] px-1 pb-0.5">Pipeline</div>
{/* 主线input / download / split */}
<Tile tkey="input" />
<Tile tkey="download" />
<Tile tkey="split" />
{/* 分叉:上路 关键帧/生图/生视频 */}
<div className="border-l-2 border-violet-400/30 pl-2 ml-2 space-y-1.5">
<div className="text-[9px] uppercase tracking-widest text-[var(--text-faint)]"></div>
<Tile tkey="keyframe" />
<Tile tkey="imagegen" />
<Tile tkey="videogen" />
</div>
{/* 分叉:下路 转录/翻译/改写 */}
<div className="border-l-2 border-pink-400/30 pl-2 ml-2 space-y-1.5">
<div className="text-[9px] uppercase tracking-widest text-[var(--text-faint)]"></div>
<Tile tkey="asr" />
<Tile tkey="translate" />
<Tile tkey="rewrite" />
</div>
{/* 合流 */}
<Tile tkey="compose" />
{/* 展开面板 — 离 sidebar 一段距离,明显"飞出"悬浮 */}
{expanded.size > 0 && (
<div
className="fixed z-40"
style={{ left: 168, top: 16, bottom: 16, width: 400 }}
>
{TILES.filter((t) => expanded.has(t.key)).map((t) => (
<section
key={t.key}
className="rounded-2xl border border-white/15 bg-black/60 backdrop-blur-2xl overflow-hidden flex flex-col h-full"
style={{
animation: "drawer-in 0.24s cubic-bezier(0.32, 0.72, 0, 1)",
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.05)",
}}
>
<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 — Kanban ---- */}
{key === "rewrite" && (
<>
<KanbanCard tone="green" tags={["产品信息"]} title="SKG 产品卖点">
<textarea
rows={5}
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 mt-1"
/>
</KanbanCard>
<KanbanCard tone="green" tags={["模型"]} title="gemini-2.5-pro">
<div className="text-[11px] text-[var(--text-soft)]"> + </div>
<div className="kanban-meta"></div>
</KanbanCard>
</>
)}
{/* ---- ImageGen — Kanban ---- */}
{key === "imagegen" && (
<>
<KanbanCard tone="rose" tags={["推荐"]} title="nano-banana-pro">
<div className="text-[11px] text-[var(--text-soft)]">Gemini 3 Pro Image · SKG /v1/images/generations</div>
</KanbanCard>
<KanbanCard tone="rose" tags={["备选"]} title="gpt-image-2">
<div className="text-[11px] text-[var(--text-soft)]">OpenAI · SKG </div>
</KanbanCard>
{data.selectedFrames.size === 0 ? (
<KanbanCard tone="pink" tags={["待启动"]} title="未选关键帧">
<div className="text-[11px] text-[var(--text-soft)]"> 1+ 1 </div>
</KanbanCard>
) : (
Array.from({ length: data.selectedFrames.size }).map((_, i) => (
<KanbanCard
key={i}
tone="pink"
tags={[`生成图 ${i + 1}`]}
title={`分镜 ${i + 1} → AI 图`}
>
<div className="aspect-video bg-black/40 rounded-md flex items-center justify-center text-[11px] text-[var(--text-faint)]">
</div>
</KanbanCard>
))
)}
</>
)}
{/* ---- VideoGen — Kanban ---- */}
{key === "videogen" && (
<>
<KanbanCard tone="violet" tags={["SKG 网关"]} title="Sora 2">
<div className="text-[11px] text-[var(--text-soft)]">/v1/videos IT</div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Seedance">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>
</KanbanCard>
<KanbanCard tone="violet" tags={["外部"]} title="Kling">
<div className="text-[11px] text-[var(--text-soft)]"> · API key</div>
</KanbanCard>
<KanbanCard tone="rose" tags={["占位"]} title="生成视频列表">
<div className="text-[11px] text-[var(--text-soft)]"> 1 5-10s prompt</div>
</KanbanCard>
</>
)}
{/* ---- Compose — Kanban ---- */}
{key === "compose" && (
<>
<KanbanCard tone="cyan" tags={["成品"]} title="最终视频">
<div className="aspect-video bg-black/40 rounded-md flex items-center justify-center text-[11.5px] text-[var(--text-faint)] mt-1">
</div>
</KanbanCard>
<KanbanCard tone="cyan" tags={["ffmpeg"]} title="字幕 + TTS 合成">
<div className="text-[11px] text-[var(--text-soft)]"> + + TTS mp4</div>
<div className="kanban-meta"> · API </div>
</KanbanCard>
</>
)}
</div>
)
}
}