auto-save 2026-05-12 16:02 (+2, ~6)
This commit is contained in:
@@ -27,6 +27,13 @@
|
||||
"message": "auto-save 2026-05-12 15:47 (+2, ~3)",
|
||||
"hash": "2e45ad9",
|
||||
"files_changed": 96
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-12T15:57:18+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-12 15:57 (~5)",
|
||||
"hash": "064083e",
|
||||
"files_changed": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,131 +1,292 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@xyflow/react/dist/style.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Updated color tokens for dark gallery aesthetic */
|
||||
/* ============================================================
|
||||
双主题 · 玻璃拟物(Rivet/Flowise 风)
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--background: oklch(0.08 0 0);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.12 0 0);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.1 0 0);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
--primary: oklch(0.95 0 0);
|
||||
--primary-foreground: oklch(0.1 0 0);
|
||||
--secondary: oklch(0.2 0 0);
|
||||
--secondary-foreground: oklch(0.95 0 0);
|
||||
--muted: oklch(0.2 0 0);
|
||||
--muted-foreground: oklch(0.6 0 0);
|
||||
--accent: oklch(0.25 0 0);
|
||||
--accent-foreground: oklch(0.95 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.25 0 0);
|
||||
--input: oklch(0.2 0 0);
|
||||
--ring: oklch(0.5 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.1 0 0);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.95 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.2 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(0.25 0 0);
|
||||
--sidebar-ring: oklch(0.5 0 0);
|
||||
/* ---- Light · 暖白底 ---- */
|
||||
--bg-canvas-1: #f6f4ed;
|
||||
--bg-canvas-2: #ece6d8;
|
||||
--bg-grid: rgba(0, 0, 0, 0.06);
|
||||
--bg-aurora-1: rgba(120, 100, 220, 0.15);
|
||||
--bg-aurora-2: rgba(220, 120, 180, 0.12);
|
||||
|
||||
--node-bg: rgba(255, 255, 255, 0.7);
|
||||
--node-bg-hover: rgba(255, 255, 255, 0.82);
|
||||
--node-border: rgba(0, 0, 0, 0.08);
|
||||
--node-border-hover: rgba(0, 0, 0, 0.18);
|
||||
--node-ring: rgba(255, 255, 255, 0.9);
|
||||
--node-shadow:
|
||||
0 1px 1px rgba(0, 0, 0, 0.04),
|
||||
0 8px 24px -8px rgba(0, 0, 0, 0.12),
|
||||
0 30px 60px -30px rgba(40, 30, 80, 0.18);
|
||||
--node-shadow-hover:
|
||||
0 1px 1px rgba(0, 0, 0, 0.06),
|
||||
0 12px 32px -10px rgba(40, 30, 80, 0.22),
|
||||
0 40px 80px -30px rgba(40, 30, 80, 0.3);
|
||||
|
||||
--text-strong: oklch(0.18 0.02 280);
|
||||
--text-soft: oklch(0.45 0.02 280);
|
||||
--text-faint: oklch(0.6 0.02 280);
|
||||
|
||||
--edge-stroke: rgba(60, 50, 120, 0.35);
|
||||
--edge-glow: rgba(120, 100, 220, 0.4);
|
||||
|
||||
--divider: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--background: oklch(0.97 0.005 80);
|
||||
--foreground: oklch(0.18 0.02 280);
|
||||
--border: var(--node-border);
|
||||
--ring: rgba(120, 100, 220, 0.5);
|
||||
--radius: 1rem;
|
||||
|
||||
/* 节点头渐变(4 类型)*/
|
||||
--grad-input: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
--grad-process: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
--grad-ai: linear-gradient(135deg, #d946ef, #ec4899);
|
||||
--grad-output: linear-gradient(135deg, #10b981, #06b6d4);
|
||||
|
||||
/* 状态色 */
|
||||
--status-pending: oklch(0.7 0.02 280);
|
||||
--status-running: oklch(0.7 0.18 260);
|
||||
--status-done: oklch(0.7 0.18 160);
|
||||
--status-failed: oklch(0.65 0.22 25);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* ---- Dark · 深蓝紫 ---- */
|
||||
--bg-canvas-1: #0a0d1c;
|
||||
--bg-canvas-2: #14172e;
|
||||
--bg-grid: rgba(255, 255, 255, 0.04);
|
||||
--bg-aurora-1: rgba(120, 100, 220, 0.22);
|
||||
--bg-aurora-2: rgba(220, 80, 180, 0.16);
|
||||
|
||||
--node-bg: rgba(22, 26, 48, 0.62);
|
||||
--node-bg-hover: rgba(30, 35, 60, 0.76);
|
||||
--node-border: rgba(255, 255, 255, 0.08);
|
||||
--node-border-hover: rgba(255, 255, 255, 0.18);
|
||||
--node-ring: rgba(255, 255, 255, 0.05);
|
||||
--node-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 1px 2px rgba(0, 0, 0, 0.4),
|
||||
0 16px 40px -12px rgba(0, 0, 0, 0.6),
|
||||
0 30px 80px -20px rgba(80, 50, 200, 0.3);
|
||||
--node-shadow-hover:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 1px 2px rgba(0, 0, 0, 0.5),
|
||||
0 20px 50px -10px rgba(0, 0, 0, 0.7),
|
||||
0 40px 100px -20px rgba(120, 80, 240, 0.4);
|
||||
|
||||
--text-strong: oklch(0.96 0.005 280);
|
||||
--text-soft: oklch(0.7 0.015 280);
|
||||
--text-faint: oklch(0.5 0.015 280);
|
||||
|
||||
--edge-stroke: rgba(180, 170, 240, 0.45);
|
||||
--edge-glow: rgba(120, 100, 220, 0.7);
|
||||
|
||||
--divider: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--background: oklch(0.1 0.02 280);
|
||||
--foreground: oklch(0.96 0.005 280);
|
||||
--border: var(--node-border);
|
||||
--ring: rgba(180, 160, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Added Playfair Display font for serif headings */
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-serif: "Playfair Display", Georgia, serif;
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-md: calc(var(--radius) - 0.25rem);
|
||||
--radius-sm: calc(var(--radius) - 0.5rem);
|
||||
--radius-xl: calc(var(--radius) + 0.25rem);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
border-color: var(--border);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workbench: keep dark gallery aesthetic but allow scroll */
|
||||
html, body {
|
||||
background: #0a0a0a;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.25 0 0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Ambient glow utility — 04 风格核心 */
|
||||
.ambient-glow {
|
||||
/* ============================================================
|
||||
画布背景:渐变 + 极光 + 颗粒
|
||||
============================================================ */
|
||||
.canvas-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 10%, var(--bg-aurora-1), transparent 60%),
|
||||
radial-gradient(ellipse 70% 50% at 80% 90%, var(--bg-aurora-2), transparent 60%),
|
||||
linear-gradient(180deg, var(--bg-canvas-1), var(--bg-canvas-2));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
节点:玻璃拟物 + 头部 4 色渐变
|
||||
============================================================ */
|
||||
.glass-node {
|
||||
position: relative;
|
||||
background: var(--node-bg);
|
||||
backdrop-filter: blur(20px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||
border: 1px solid var(--node-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--node-shadow);
|
||||
transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
box-shadow 0.25s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
color: var(--text-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.glass-node:hover {
|
||||
background: var(--node-bg-hover);
|
||||
border-color: var(--node-border-hover);
|
||||
box-shadow: var(--node-shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.glass-node--selected {
|
||||
border-color: var(--ring);
|
||||
box-shadow: var(--node-shadow-hover), 0 0 0 3px var(--ring);
|
||||
}
|
||||
.glass-node--running::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: conic-gradient(from 0deg, transparent 70%, var(--edge-glow), transparent);
|
||||
animation: spin 3s linear infinite;
|
||||
mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
mask-composite: exclude;
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
padding: 1.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.glass-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
}
|
||||
.glass-node__header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 30% 30%, oklch(0.35 0.1 250 / 0.25), transparent 60%),
|
||||
radial-gradient(circle at 70% 70%, oklch(0.35 0.08 300 / 0.18), transparent 55%);
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.glass-node__header > * { position: relative; z-index: 1; }
|
||||
|
||||
.glass-node[data-type="input"] .glass-node__header::before { background: var(--grad-input); }
|
||||
.glass-node[data-type="process"] .glass-node__header::before { background: var(--grad-process); }
|
||||
.glass-node[data-type="ai"] .glass-node__header::before { background: var(--grad-ai); }
|
||||
.glass-node[data-type="output"] .glass-node__header::before { background: var(--grad-output); }
|
||||
|
||||
.glass-node__body { padding: 0.85rem 1rem 1rem; }
|
||||
|
||||
.glass-node__row {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 12px; color: var(--text-soft);
|
||||
}
|
||||
.glass-node__kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Glass card — 04 风格 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius);
|
||||
/* 状态点 */
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--status-pending);
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
.status-dot--running {
|
||||
background: var(--status-running);
|
||||
animation: pulse-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
.status-dot--done { background: var(--status-done); }
|
||||
.status-dot--failed { background: var(--status-failed); }
|
||||
@keyframes pulse-dot {
|
||||
0%,100% { box-shadow: 0 0 0 0 var(--status-running); opacity: 1; }
|
||||
50% { box-shadow: 0 0 0 6px transparent; opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* 节点 handle(ReactFlow) */
|
||||
.react-flow__handle {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
background: var(--node-bg) !important;
|
||||
border: 2px solid var(--edge-stroke) !important;
|
||||
box-shadow: 0 0 0 3px var(--node-ring) !important;
|
||||
}
|
||||
.react-flow__handle:hover {
|
||||
background: var(--edge-glow) !important;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.react-flow__edge .react-flow__edge-path {
|
||||
stroke: var(--edge-stroke);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.react-flow__edge.animated .react-flow__edge-path {
|
||||
stroke: var(--edge-glow);
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 6px var(--edge-glow));
|
||||
}
|
||||
.react-flow__controls,
|
||||
.react-flow__panel {
|
||||
background: var(--node-bg) !important;
|
||||
border: 1px solid var(--node-border) !important;
|
||||
border-radius: 12px !important;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: var(--node-shadow) !important;
|
||||
}
|
||||
.react-flow__controls button {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--divider) !important;
|
||||
color: var(--text-strong) !important;
|
||||
}
|
||||
.react-flow__controls button:hover {
|
||||
background: var(--node-bg-hover) !important;
|
||||
}
|
||||
.react-flow__minimap {
|
||||
background: var(--node-bg) !important;
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid var(--node-border) !important;
|
||||
box-shadow: var(--node-shadow) !important;
|
||||
}
|
||||
.react-flow__background pattern circle {
|
||||
fill: var(--bg-grid) !important;
|
||||
}
|
||||
.react-flow__attribution { display: none !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--divider); border-radius: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono, Playfair_Display } from "next/font/google"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import "./globals.css"
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] })
|
||||
@@ -12,7 +13,7 @@ const _playfairDisplay = Playfair_Display({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SKG TK 二创工作台",
|
||||
description: "SKG AI 素材生产管线 · TK 链接 → 关键帧 + 双语转录 → 改写 / 生图 / 生视频",
|
||||
description: "SKG AI 素材生产管线 · 节点工作流",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -21,8 +22,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="font-sans antialiased">{children}</body>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
265
web/app/page.tsx
265
web/app/page.tsx
@@ -1,23 +1,71 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ReactFlow, Background, BackgroundVariant, Controls, MiniMap,
|
||||
type Node, type Edge,
|
||||
} from "@xyflow/react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { UrlInput } from "@/components/url-input"
|
||||
import { JobStatusBar } from "@/components/job-status"
|
||||
import { KeyframeGallery } from "@/components/keyframe-gallery"
|
||||
import { TranscriptPanel } from "@/components/transcript-panel"
|
||||
import { createJob, getJob, triggerTranscribe, uploadJob, videoUrl, type Job } from "@/lib/api"
|
||||
import {
|
||||
InputNode, DownloadNode, SplitNode, KeyframeNode, ASRNode,
|
||||
TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode,
|
||||
type NodeData,
|
||||
} from "@/components/nodes"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { createJob, getJob, triggerTranscribe, uploadJob, type Job } from "@/lib/api"
|
||||
|
||||
const NODE_TYPES = {
|
||||
input: InputNode,
|
||||
download: DownloadNode,
|
||||
split: SplitNode,
|
||||
keyframe: KeyframeNode,
|
||||
asr: ASRNode,
|
||||
translate: TranslateNode,
|
||||
rewrite: RewriteNode,
|
||||
imagegen: ImageGenNode,
|
||||
videogen: VideoGenNode,
|
||||
compose: ComposeNode,
|
||||
}
|
||||
|
||||
// 手布局:DAG,从左到右
|
||||
// 拆分后两路:上路 video → keyframe → imagegen → videogen ↘
|
||||
// 下路 audio → asr → translate → rewrite ────→ compose
|
||||
const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [
|
||||
{ id: "input", type: "input", x: 40, y: 240 },
|
||||
{ id: "download", type: "download", x: 400, y: 240 },
|
||||
{ id: "split", type: "split", x: 720, y: 240 },
|
||||
{ id: "keyframe", type: "keyframe", x: 1060, y: 60 },
|
||||
{ id: "asr", type: "asr", x: 1060, y: 440 },
|
||||
{ id: "translate", type: "translate", x: 1440, y: 440 },
|
||||
{ id: "imagegen", type: "imagegen", x: 1480, y: 60 },
|
||||
{ id: "rewrite", type: "rewrite", x: 1820, y: 440 },
|
||||
{ id: "videogen", type: "videogen", x: 1860, y: 60 },
|
||||
{ id: "compose", type: "compose", x: 2240, y: 240 },
|
||||
]
|
||||
|
||||
const EDGES_RAW: Array<[string, string]> = [
|
||||
["input", "download"],
|
||||
["download", "split"],
|
||||
["split", "keyframe"],
|
||||
["split", "asr"],
|
||||
["asr", "translate"],
|
||||
["translate", "rewrite"],
|
||||
["keyframe", "imagegen"],
|
||||
["rewrite", "imagegen"],
|
||||
["imagegen", "videogen"],
|
||||
["videogen", "compose"],
|
||||
["rewrite", "compose"],
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
const [job, setJob] = useState<Job | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const transcribeTriggeredRef = useRef<string | null>(null)
|
||||
|
||||
const handleSubmit = useCallback(async (url: string) => {
|
||||
setSubmitting(true)
|
||||
setSelected(new Set())
|
||||
setSelectedFrames(new Set())
|
||||
transcribeTriggeredRef.current = null
|
||||
try {
|
||||
const created = await createJob(url)
|
||||
@@ -32,13 +80,13 @@ export default function Home() {
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setSubmitting(true)
|
||||
setSelected(new Set())
|
||||
setSelectedFrames(new Set())
|
||||
transcribeTriggeredRef.current = null
|
||||
try {
|
||||
toast.info(`正在上传 ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||
toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`)
|
||||
const created = await uploadJob(file)
|
||||
setJob(created)
|
||||
toast.success(`已上传,任务 ${created.id.slice(0, 8)}`)
|
||||
toast.success(`已上传 ${created.id.slice(0, 8)}`)
|
||||
} catch (e) {
|
||||
toast.error("上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
@@ -46,27 +94,32 @@ export default function Home() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 轮询 job 状态
|
||||
const handleToggleFrame = useCallback((idx: number) => {
|
||||
setSelectedFrames((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx)
|
||||
else if (next.size < 10) next.add(idx)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 轮询 Job
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
if (job.status === "transcribed" || job.status === "failed") {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
||||
return
|
||||
}
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const latest = await getJob(job.id)
|
||||
setJob(latest)
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}, 1500)
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [job?.id, job?.status])
|
||||
|
||||
// 抽帧完成后自动触发 ASR
|
||||
// 抽帧完后自动触发 transcribe
|
||||
useEffect(() => {
|
||||
if (!job) return
|
||||
if (job.status !== "frames_extracted") return
|
||||
@@ -75,108 +128,94 @@ export default function Home() {
|
||||
triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message))
|
||||
}, [job?.id, job?.status])
|
||||
|
||||
const toggleFrame = (idx: number) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx)
|
||||
else if (next.size < 10) next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const nodeData: NodeData = useMemo(() => ({
|
||||
job,
|
||||
submitting,
|
||||
selectedFrames,
|
||||
onSubmitUrl: handleSubmit,
|
||||
onUploadFile: handleUpload,
|
||||
onToggleFrame: handleToggleFrame,
|
||||
}), [job, submitting, selectedFrames, handleSubmit, handleUpload, handleToggleFrame])
|
||||
|
||||
const handleSeek = (sec: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = sec
|
||||
videoRef.current.play().catch(() => {})
|
||||
}
|
||||
const nodes: Node[] = useMemo(
|
||||
() => LAYOUT.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: { x: n.x, y: n.y },
|
||||
data: nodeData,
|
||||
draggable: true,
|
||||
})),
|
||||
[nodeData],
|
||||
)
|
||||
|
||||
// 边状态:source 节点 done 时 animated
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
const doneOf: Record<string, boolean> = {
|
||||
input: !!job,
|
||||
download: !!job?.video_url,
|
||||
split: !!job && ["frames_extracted", "transcribing", "transcribed"].includes(job.status),
|
||||
keyframe: !!job && job.frames.length > 0,
|
||||
asr: !!job && job.transcript.length > 0,
|
||||
translate: !!job && (job.transcript.some((s) => s.zh) ?? false),
|
||||
}
|
||||
return EDGES_RAW.map(([from, to], i) => ({
|
||||
id: `e${i}`,
|
||||
source: from,
|
||||
target: to,
|
||||
animated: !!doneOf[from],
|
||||
type: "default",
|
||||
}))
|
||||
}, [job])
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen text-white overflow-x-hidden">
|
||||
<div className="ambient-glow" />
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-10 space-y-8">
|
||||
{/* Header */}
|
||||
<header className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.3em] text-white/40">SKG · AI Material Pipeline</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl leading-tight">
|
||||
<>
|
||||
<div className="canvas-bg" />
|
||||
<main className="relative h-screen w-screen overflow-hidden">
|
||||
{/* 顶部栏 */}
|
||||
<header className="absolute top-4 left-6 right-6 z-20 flex items-center justify-between pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<div className="text-[10px] uppercase tracking-[0.3em] text-[var(--text-faint)]">SKG · AI Material Pipeline</div>
|
||||
<h1 className="font-serif text-[26px] leading-none mt-1 text-[var(--text-strong)]">
|
||||
TK 二创工作台
|
||||
<span className="text-white/40 ml-3 text-xl font-sans tracking-tight">/ Verification Prototype</span>
|
||||
<span className="text-[var(--text-faint)] ml-3 text-[14px] font-sans tracking-tight">/ Node Workflow</span>
|
||||
</h1>
|
||||
<p className="text-white/50 text-sm max-w-2xl">
|
||||
粘贴 TikTok 链接 → 自动抽取关键帧 + Gemini 双语转录 → 后续接入文案改写 / 生图 / 生视频。
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
{job && (
|
||||
<div className="glass-node flex items-center gap-2 px-3 h-9" style={{ borderRadius: 12 }}>
|
||||
<span className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">JOB</span>
|
||||
<span className="text-[11.5px] font-mono text-[var(--text-strong)]">{job.id.slice(0, 8)}</span>
|
||||
<span className="text-[10.5px] text-[var(--text-faint)]">·</span>
|
||||
<span className="text-[11.5px] text-[var(--text-soft)]">{job.message || job.status}</span>
|
||||
</div>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* URL 输入 */}
|
||||
<section>
|
||||
<UrlInput
|
||||
loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")}
|
||||
onSubmitUrl={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
/>
|
||||
</section>
|
||||
{/* 画布 */}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.12 }}
|
||||
minZoom={0.4}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={28} size={1.4} />
|
||||
<Controls position="bottom-left" />
|
||||
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
||||
</ReactFlow>
|
||||
|
||||
{job && (
|
||||
<>
|
||||
{/* 状态条 */}
|
||||
<section>
|
||||
<JobStatusBar job={job} />
|
||||
</section>
|
||||
|
||||
{/* 视频预览 + 关键帧 */}
|
||||
{job.video_url && (
|
||||
<section className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-6">
|
||||
<div className="glass-card overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl(job.id)}
|
||||
controls
|
||||
className="w-full aspect-video bg-black"
|
||||
/>
|
||||
<div className="px-4 py-2.5 text-[11px] font-mono text-white/40 border-t border-white/10">
|
||||
{job.width}×{job.height} · {job.duration?.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<div className="font-serif text-lg mb-1">关键帧 · Keyframes</div>
|
||||
<div className="text-xs text-white/40">自动抽取,点击勾选最多 10 张作为生图参考</div>
|
||||
</div>
|
||||
<KeyframeGallery frames={job.frames} selected={selected} onToggle={toggleFrame} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 双语转录 */}
|
||||
{(job.frames.length > 0 || job.transcript.length > 0) && (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<div className="font-serif text-lg mb-1">双语转录 · Transcript</div>
|
||||
<div className="text-xs text-white/40">点击段落跳转视频时间点</div>
|
||||
</div>
|
||||
<TranscriptPanel
|
||||
segments={job.transcript}
|
||||
loading={job.status === "transcribing"}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!job && (
|
||||
<section className="text-center py-16">
|
||||
<div className="font-serif text-2xl text-white/30">↑ 粘贴 TikTok 链接,或拖入 / 上传本地视频</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="pt-8 pb-4 text-[11px] text-white/30 font-mono flex items-center justify-between border-t border-white/5">
|
||||
<div>SKG TK 二创验证 · MVP 第一冲刺(步骤 1-4)</div>
|
||||
<div>4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}</div>
|
||||
{/* 底部说明 */}
|
||||
<footer className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-[10.5px] font-mono text-[var(--text-faint)] pointer-events-none">
|
||||
MVP 第一冲刺:步骤 1-6 已通 · 7-10 占位 · 拖拽节点 · 滚轮缩放
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster theme="dark" position="top-right" />
|
||||
|
||||
<Toaster theme="system" position="top-right" />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
377
web/components/nodes/index.tsx
Normal file
377
web/components/nodes/index.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client"
|
||||
import { useRef, useState } from "react"
|
||||
import { type NodeProps } from "@xyflow/react"
|
||||
import {
|
||||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2,
|
||||
} from "lucide-react"
|
||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import { type Job } from "@/lib/api"
|
||||
|
||||
export interface NodeData {
|
||||
job: Job | null
|
||||
submitting: boolean
|
||||
selectedFrames: Set<number>
|
||||
onSubmitUrl: (url: string) => void
|
||||
onUploadFile: (file: File) => void
|
||||
onToggleFrame: (idx: number) => void
|
||||
}
|
||||
|
||||
/* ---- 状态映射工具 ---- */
|
||||
function inputStatus(job: Job | null): NodeStatus {
|
||||
if (!job) return "pending"
|
||||
return "done"
|
||||
}
|
||||
function downloadStatus(job: Job | null): NodeStatus {
|
||||
if (!job) return "pending"
|
||||
if (job.status === "failed" && job.progress < 20) return "failed"
|
||||
if (job.status === "downloading") return "running"
|
||||
if (job.video_url) return "done"
|
||||
return "pending"
|
||||
}
|
||||
function splitStatus(job: Job | null): NodeStatus {
|
||||
if (!job || !job.video_url) return "pending"
|
||||
if (job.status === "failed" && job.progress >= 20 && job.progress < 50) return "failed"
|
||||
if (job.status === "splitting") return "running"
|
||||
if (["frames_extracted", "transcribing", "transcribed"].includes(job.status)) return "done"
|
||||
return "pending"
|
||||
}
|
||||
function keyframeStatus(job: Job | null): NodeStatus {
|
||||
if (!job) return "pending"
|
||||
if (job.status === "failed" && job.progress >= 50 && job.progress < 70) return "failed"
|
||||
if (job.frames.length === 0 && job.status === "splitting") return "running"
|
||||
if (job.frames.length > 0) return "done"
|
||||
return "pending"
|
||||
}
|
||||
function asrStatus(job: Job | null): NodeStatus {
|
||||
if (!job) return "pending"
|
||||
if (job.status === "transcribing") return "running"
|
||||
if (job.transcript.length > 0) return "done"
|
||||
if (job.status === "failed" && job.progress >= 70) return "failed"
|
||||
return "pending"
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
1. InputNode — TK 链接 / 上传
|
||||
============================================================ */
|
||||
export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | any) {
|
||||
const d: NodeData = data
|
||||
const [url, setUrl] = useState("")
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const isLocked = !!d.job && d.job.status !== "failed" && d.job.status !== "transcribed"
|
||||
return (
|
||||
<NodeShell
|
||||
type="input" status={inputStatus(d.job)}
|
||||
icon={<Link2 className="h-4 w-4" />}
|
||||
title="输入 · Input"
|
||||
subtitle="STEP 1"
|
||||
width={300}
|
||||
selected={selected}
|
||||
hasTarget={false}
|
||||
>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="粘贴 TikTok 链接"
|
||||
disabled={isLocked}
|
||||
className="w-full text-[12px] px-2.5 py-2 rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/10 dark:border-white/10 outline-none 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={isLocked || !url.trim()}
|
||||
onClick={() => d.onSubmitUrl(url.trim())}
|
||||
className="flex-1 text-[11.5px] py-1.5 rounded-md bg-black text-white dark:bg-white dark:text-black hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||
>
|
||||
{d.submitting ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
提交链接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLocked}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/60 dark:bg-white/[0.06] border border-black/10 dark:border-white/15 hover:bg-white/80 dark: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,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) d.onUploadFile(f)
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{d.job && (
|
||||
<div className="mt-2 text-[10.5px] font-mono text-[var(--text-faint)] truncate">
|
||||
{d.job.url.startsWith("upload://") ? `📎 ${d.job.url.slice(9)}` : d.job.url}
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
2. DownloadNode
|
||||
============================================================ */
|
||||
export function DownloadNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const st = downloadStatus(d.job)
|
||||
return (
|
||||
<NodeShell
|
||||
type="process" status={st}
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
title="下载 · Download"
|
||||
subtitle="STEP 2 · yt-dlp"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
|
||||
{d.job?.url.startsWith("upload://") ? "本地上传 · 跳过下载" : "TikTok / yt-dlp 兼容站点"}
|
||||
</div>
|
||||
{d.job && st === "done" && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[10.5px] font-mono text-[var(--text-faint)]">
|
||||
<div>分辨率<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.width}×{d.job.height}</span></div>
|
||||
<div>时长<br /><span className="text-[var(--text-strong)] text-[12px]">{d.job.duration.toFixed(1)}s</span></div>
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
3. SplitNode
|
||||
============================================================ */
|
||||
export function SplitNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
return (
|
||||
<NodeShell
|
||||
type="process" status={splitStatus(d.job)}
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
title="拆分 · Split"
|
||||
subtitle="STEP 3 · ffmpeg"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">视频流</div>
|
||||
<div className="text-[var(--text-strong)] mt-0.5">→ 关键帧</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/40 dark:bg-white/[0.04] border border-black/5 dark:border-white/5 px-2 py-1.5">
|
||||
<div className="text-[10px] uppercase tracking-widest text-[var(--text-faint)]">音频流</div>
|
||||
<div className="text-[var(--text-strong)] mt-0.5">→ ASR</div>
|
||||
</div>
|
||||
</div>
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. KeyframeNode — 缩略图网格 + 多选
|
||||
============================================================ */
|
||||
export function KeyframeNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const st = keyframeStatus(d.job)
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status={st}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
title="关键帧 · Keyframes"
|
||||
subtitle={`STEP 4 · ${d.selectedFrames.size}/10`}
|
||||
width={360}
|
||||
selected={selected}
|
||||
>
|
||||
{d.job?.frames.length ? (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{d.job.frames.map((f) => {
|
||||
const isSel = d.selectedFrames.has(f.index)
|
||||
return (
|
||||
<button
|
||||
key={f.index}
|
||||
onClick={(e) => { e.stopPropagation(); d.onToggleFrame(f.index) }}
|
||||
className={`relative aspect-video overflow-hidden rounded-md border ${
|
||||
isSel ? "border-[var(--ring)] ring-2 ring-[var(--ring)]" : "border-black/10 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"}${f.url}`}
|
||||
alt={`frame ${f.index}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{isSel && (
|
||||
<div className="absolute inset-0 bg-[var(--ring)]/15" />
|
||||
)}
|
||||
<div className="absolute bottom-0.5 right-0.5 bg-black/60 text-white text-[8px] font-mono px-1 rounded">
|
||||
{f.timestamp.toFixed(1)}s
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11.5px] text-[var(--text-faint)] py-2">等待视频流,自动 + 手动抽取 ≤10 张</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
5. ASRNode — Gemini 转录
|
||||
============================================================ */
|
||||
export function ASRNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status={asrStatus(d.job)}
|
||||
icon={<Mic className="h-4 w-4" />}
|
||||
title="转录 · ASR"
|
||||
subtitle="STEP 5 · Gemini"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||||
Gemini 2.5 · 英文带时间戳分段
|
||||
</div>
|
||||
{d.job && d.job.transcript.length > 0 && (
|
||||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||||
{d.job.transcript.slice(0, 3).map((s) => (
|
||||
<div key={s.index} className="leading-snug">
|
||||
<span className="text-[var(--text-faint)] font-mono text-[10px] mr-1">
|
||||
{s.start.toFixed(1)}s
|
||||
</span>
|
||||
{s.en.slice(0, 60)}
|
||||
{s.en.length > 60 && "…"}
|
||||
</div>
|
||||
))}
|
||||
{d.job.transcript.length > 3 && (
|
||||
<div className="text-[var(--text-faint)] text-[10px]">还有 {d.job.transcript.length - 3} 段…</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
6. TranslateNode
|
||||
============================================================ */
|
||||
export function TranslateNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const hasZh = d.job?.transcript.some((s) => s.zh) ?? false
|
||||
const st: NodeStatus = !d.job ? "pending" :
|
||||
d.job.status === "transcribing" ? "running" :
|
||||
hasZh ? "done" :
|
||||
d.job.status === "failed" ? "failed" : "pending"
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status={st}
|
||||
icon={<Languages className="h-4 w-4" />}
|
||||
title="翻译 · Translate"
|
||||
subtitle="STEP 6 · EN → ZH"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)]">
|
||||
中文翻译 · 段落级 · 实时输出
|
||||
</div>
|
||||
{hasZh && d.job && (
|
||||
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">
|
||||
{d.job.transcript.slice(0, 3).map((s) => (
|
||||
<div key={s.index} className="leading-snug">{s.zh.slice(0, 30)}{s.zh.length > 30 && "…"}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
7. RewriteNode (placeholder)
|
||||
============================================================ */
|
||||
export function RewriteNode({ selected }: any) {
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status="pending"
|
||||
icon={<FileEdit className="h-4 w-4" />}
|
||||
title="文案改写 · Rewrite"
|
||||
subtitle="STEP 7 · 接产品信息"
|
||||
selected={selected}
|
||||
>
|
||||
<textarea
|
||||
placeholder="粘贴 SKG 产品信息 / 关键卖点(占位,未接通)"
|
||||
rows={3}
|
||||
disabled
|
||||
className="w-full text-[11.5px] px-2.5 py-2 rounded-md bg-white/30 dark:bg-white/[0.03] border border-dashed border-black/15 dark:border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
|
||||
/>
|
||||
<div className="mt-1.5 text-[10px] text-[var(--text-faint)]">下一冲刺接入</div>
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
8. ImageGenNode (placeholder)
|
||||
============================================================ */
|
||||
export function ImageGenNode({ selected }: any) {
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status="pending"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
title="生图 · Image Gen"
|
||||
subtitle="STEP 8 · nano-banana / GPT"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
nano-banana-pro<br /><span className="text-[var(--text-strong)] text-[11px]">Gemini 3 Image</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
GPT Image<br /><span className="text-[var(--text-strong)] text-[11px]">OpenAI</span>
|
||||
</div>
|
||||
</div>
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
9. VideoGenNode (placeholder)
|
||||
============================================================ */
|
||||
export function VideoGenNode({ selected }: any) {
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status="pending"
|
||||
icon={<Film className="h-4 w-4" />}
|
||||
title="生视频 · Video Gen"
|
||||
subtitle="STEP 9 · 多家可切"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
|
||||
{["Seedance", "Kling", "Veo 3"].map((m) => (
|
||||
<div key={m} className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-center text-[var(--text-faint)]">
|
||||
<span className="text-[var(--text-strong)] text-[11px]">{m}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
10. ComposeNode (placeholder)
|
||||
============================================================ */
|
||||
export function ComposeNode({ selected }: any) {
|
||||
return (
|
||||
<NodeShell
|
||||
type="output" status="pending"
|
||||
icon={<FileVideo className="h-4 w-4" />}
|
||||
title="合成成品 · Compose"
|
||||
subtitle="STEP 10 · ffmpeg + TTS"
|
||||
selected={selected}
|
||||
hasSource={false}
|
||||
>
|
||||
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
|
||||
视频片段 + 字幕 / TTS<br />→ 最终 mp4 输出
|
||||
</div>
|
||||
</NodeShell>
|
||||
)
|
||||
}
|
||||
79
web/components/nodes/node-shell.tsx
Normal file
79
web/components/nodes/node-shell.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
import { type ReactNode } from "react"
|
||||
import { Handle, Position } from "@xyflow/react"
|
||||
import { CheckCircle2, Loader2, AlertCircle } from "lucide-react"
|
||||
|
||||
export type NodeKind = "input" | "process" | "ai" | "output"
|
||||
export type NodeStatus = "pending" | "running" | "done" | "failed"
|
||||
|
||||
interface Props {
|
||||
type: NodeKind
|
||||
status: NodeStatus
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
subtitle?: string
|
||||
width?: number
|
||||
selected?: boolean
|
||||
hasTarget?: boolean
|
||||
hasSource?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||
pending: "status-dot",
|
||||
running: "status-dot status-dot--running",
|
||||
done: "status-dot status-dot--done",
|
||||
failed: "status-dot status-dot--failed",
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<NodeStatus, string> = {
|
||||
pending: "待运行",
|
||||
running: "运行中",
|
||||
done: "完成",
|
||||
failed: "失败",
|
||||
}
|
||||
|
||||
export function NodeShell({
|
||||
type,
|
||||
status,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
width = 280,
|
||||
selected,
|
||||
hasTarget = true,
|
||||
hasSource = true,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className={`glass-node ${selected ? "glass-node--selected" : ""} ${status === "running" ? "glass-node--running" : ""}`}
|
||||
data-type={type}
|
||||
style={{ width }}
|
||||
>
|
||||
{hasTarget && <Handle type="target" position={Position.Left} />}
|
||||
|
||||
<div className="glass-node__header">
|
||||
{icon ? <span className="inline-flex h-5 w-5 items-center justify-center">{icon}</span> : null}
|
||||
<span className="text-[13px]">{title}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{status === "running" ? <Loader2 className="h-3 w-3 animate-spin" /> :
|
||||
status === "done" ? <CheckCircle2 className="h-3 w-3" /> :
|
||||
status === "failed" ? <AlertCircle className="h-3 w-3" /> : null}
|
||||
<span className={STATUS_DOT[status]} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="glass-node__body">
|
||||
{subtitle && (
|
||||
<div className="text-[11px] mb-2 glass-node__kbd uppercase tracking-widest">
|
||||
{subtitle} · {STATUS_LABEL[status]}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{hasSource && <Handle type="source" position={Position.Right} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
web/components/theme-toggle.tsx
Normal file
22
web/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
if (!mounted) return null
|
||||
const isDark = theme === "dark"
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="glass-node h-9 w-9 inline-flex items-center justify-center rounded-xl"
|
||||
style={{ width: 36, height: 36, padding: 0, borderRadius: 12 }}
|
||||
title={isDark ? "切到明亮主题" : "切到暗色主题"}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
175
web/pnpm-lock.yaml
generated
175
web/pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.1.6
|
||||
version: 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.2
|
||||
version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.0(postcss@8.5.14)
|
||||
@@ -1185,6 +1188,24 @@ packages:
|
||||
'@tailwindcss/postcss@4.3.0':
|
||||
resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-selection@3.0.11':
|
||||
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
|
||||
'@types/node@22.19.19':
|
||||
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
|
||||
|
||||
@@ -1196,6 +1217,15 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@xyflow/react@12.10.2':
|
||||
resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1223,6 +1253,9 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
@@ -1239,6 +1272,44 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
@@ -1595,6 +1666,21 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
@@ -2534,6 +2620,27 @@ snapshots:
|
||||
postcss: 8.5.14
|
||||
tailwindcss: 4.3.0
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-selection@3.0.11': {}
|
||||
|
||||
'@types/d3-transition@3.0.9':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/node@22.19.19':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
@@ -2546,6 +2653,29 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@xyflow/react@12.10.2(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.76
|
||||
classcat: 5.0.5
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
zustand: 4.5.7(@types/react@19.2.14)(react@19.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.76':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2575,6 +2705,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
@@ -2593,6 +2725,42 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
@@ -2895,3 +3063,10 @@ snapshots:
|
||||
- '@types/react-dom'
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.2.14)(react@19.2.0):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.0
|
||||
|
||||
Reference in New Issue
Block a user