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

554 lines
28 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 { useEffect, useRef, useState, type ReactNode } from "react"
import { createPortal } from "react-dom"
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 [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
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 状态合并:覆盖原 input + download + split
input: !job ? "pending"
: isDownloading ? "running"
: isSplitting ? "running"
: hasFrames || hasTranscript ? "done"
: hasVideo ? "done"
: isFailed && 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 ? "等待" : isDownloading ? "下载中…" : hasVideo ? `${job.width}×${job.height} · ${job.duration.toFixed(1)}s` : "—",
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: "keyframe", title: "关键帧", type: "ai", icon: <ImageIcon className="h-3.5 w-3.5" />, step: 2 },
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 3 },
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 4 },
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 5 },
{ key: "imagegen", title: "生图", type: "ai", icon: <Sparkles className="h-3.5 w-3.5" />, step: 6 },
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 7 },
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 8 },
]
// 单选展开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={`${t.title} · ${colSummary[t.key]}`}
className={`group w-full rounded-md overflow-hidden border flex items-center transition relative ${
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: 28 }}
>
<div className="px-1.5 flex items-center gap-1 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-[11px] font-medium truncate">{t.title}</span>
</div>
<span className={`absolute right-1.5 h-1.5 w-1.5 rounded-full ${STATE_DOT[state]} ring-2 ring-black/40`} />
</button>
)
}
return (
<div className="h-full flex flex-col gap-1 p-1.5">
{/* 起点:输入(含下载+拆分) */}
<Tile tkey="input" />
{/* 分叉:上路 关键帧/生图/生视频 */}
<div className="border-l-2 border-violet-400/30 pl-1.5 ml-1 space-y-1">
<Tile tkey="keyframe" />
<Tile tkey="imagegen" />
<Tile tkey="videogen" />
</div>
{/* 分叉:下路 转录/翻译/改写 */}
<div className="border-l-2 border-pink-400/30 pl-1.5 ml-1 space-y-1">
<Tile tkey="asr" />
<Tile tkey="translate" />
<Tile tkey="rewrite" />
</div>
{/* 合流 */}
<Tile tkey="compose" />
{/* 展开面板 — 用 portal 渲染到 body 避免 backdrop-filter 影响 fixed 定位 */}
{expanded.size > 0 && mounted && createPortal(
<div
className="fixed z-[100]"
style={{ left: 130, 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>,
document.body
)}
</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 && job && (
<>
{/* 元数据 */}
<KanbanCard tone="orange" tags={["视频源", job.url.startsWith("upload://") ? "上传" : "yt-dlp"]} title={`${job.width}×${job.height} · ${job.duration.toFixed(1)}s`}>
<div className="text-[10.5px] text-[var(--text-soft)] truncate font-mono">
{job.url.startsWith("upload://") ? `📎 ${job.url.slice(9)}` : `🔗 ${job.url}`}
</div>
</KanbanCard>
{/* 拆分流 */}
<KanbanCard tone="amber" tags={["ffmpeg"]} title="拆分音视频流">
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-md bg-violet-500/10 border border-violet-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-violet-300"></div>
<div className="text-[var(--text-strong)]"> </div>
</div>
<div className="rounded-md bg-pink-500/10 border border-pink-400/20 px-2 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300"></div>
<div className="text-[var(--text-strong)]"> ASR (wav)</div>
</div>
</div>
</KanbanCard>
{/* 解析按钮 */}
<KanbanCard tone="green" tags={["下一步"]} title={hasFrames ? "重新解析" : "启动全流程"}>
<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>
<div className="kanban-meta">
= ASR
</div>
</KanbanCard>
</>
)}
</>
)}
{/* ---- Download — 只显示元数据,视频播放器移到 Keyframe section ---- */}
{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={["视频源", job!.url.startsWith("upload://") ? "上传" : "yt-dlp"]} title={job!.url.startsWith("upload://") ? "本地上传" : "TK 下载"}>
<div className="text-[11px] text-[var(--text-soft)] truncate font-mono mt-1">
{job!.url.startsWith("upload://") ? `📎 ${job!.url.slice(9)}` : `🔗 ${job!.url}`}
</div>
</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">~9MB</div></div>
</div>
<div className="kanban-meta">
/
</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="violet" tags={["视频"]} title="拖时间轴选帧">
<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"
/>
<div className="kanban-meta">
<span className="font-mono"> {videoT.toFixed(2)}s</span>
<span className="ml-auto">{job.width}×{job.height} · {job.duration.toFixed(1)}s</span>
</div>
</KanbanCard>
<KanbanCard tone="green" tags={["手动加帧"]} title="把当前时间点抽为关键帧">
<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-[12.5px] py-2 rounded-md bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-50 inline-flex items-center justify-center gap-1.5 font-medium"
>
{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>
)
}
}