@@ -0,0 +1,574 @@
"use client" ;
import { BookOpen , CheckSquare , ClipboardPaste , Download , FolderPlus , History , LoaderCircle , Plus , SlidersHorizontal , Sparkles , Trash2 , Upload , VideoIcon } from "lucide-react" ;
import { useEffect , useRef , useState } from "react" ;
import { App , AutoComplete , Button , Checkbox , Drawer , Empty , Input , Modal , Tag , Typography } from "antd" ;
import localforage from "localforage" ;
import { nanoid } from "nanoid" ;
import { AssetPickerModal , type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal" ;
import { ModelPicker } from "@/components/model-picker" ;
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog" ;
import { formatBytes , formatDuration } from "@/lib/image-utils" ;
import { resolveMediaUrl , uploadMediaFile } from "@/services/file-storage" ;
import { uploadImage } from "@/services/image-storage" ;
import { requestVideoGeneration } from "@/services/api/video" ;
import { useAssetStore } from "@/stores/use-asset-store" ;
import { useConfigStore , useEffectiveConfig , type AiConfig } from "@/stores/use-config-store" ;
import type { ReferenceImage } from "@/types/image" ;
type GeneratedVideo = {
id : string ;
url : string ;
storageKey : string ;
durationMs : number ;
width : number ;
height : number ;
bytes : number ;
mimeType : string ;
} ;
type GenerationResult = {
id : string ;
status : "pending" | "success" | "failed" ;
video? : GeneratedVideo ;
error? : string ;
} ;
type GenerationLog = {
id : string ;
createdAt : number ;
title : string ;
time : string ;
model : string ;
durationMs : number ;
size : string ;
resolution : string ;
seconds : string ;
status : "成功" | "失败" ;
video? : GeneratedVideo ;
error? : string ;
} ;
type UpdateAiConfig = < K extends keyof AiConfig > ( key : K , value : AiConfig [ K ] ) = > void ;
const sizeOptions = [ "1280x720" , "720x1280" , "1024x1024" , "1792x1024" , "1024x1792" ] . map ( ( value ) = > ( { label : value , value } ) ) ;
const resolutionOptions = [ "720" , "480" ] . map ( ( value ) = > ( { label : ` ${ value } p ` , value } ) ) ;
const secondOptions = [ "6" , "10" , "12" , "16" , "20" ] . map ( ( value ) = > ( { label : ` ${ value } s ` , value } ) ) ;
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs" ;
const logStore = localforage . createInstance ( { name : "infinite-canvas" , storeName : "video_generation_logs" } ) ;
export default function VideoPage() {
const { message } = App . useApp ( ) ;
const fileInputRef = useRef < HTMLInputElement > ( null ) ;
const config = useConfigStore ( ( state ) = > state . config ) ;
const effectiveConfig = useEffectiveConfig ( ) ;
const updateConfig = useConfigStore ( ( state ) = > state . updateConfig ) ;
const isAiConfigReady = useConfigStore ( ( state ) = > state . isAiConfigReady ) ;
const openConfigDialog = useConfigStore ( ( state ) = > state . openConfigDialog ) ;
const addAsset = useAssetStore ( ( state ) = > state . addAsset ) ;
const [ prompt , setPrompt ] = useState ( "" ) ;
const [ references , setReferences ] = useState < ReferenceImage [ ] > ( [ ] ) ;
const [ results , setResults ] = useState < GenerationResult [ ] > ( [ ] ) ;
const [ logs , setLogs ] = useState < GenerationLog [ ] > ( [ ] ) ;
const [ running , setRunning ] = useState ( false ) ;
const [ logsOpen , setLogsOpen ] = useState ( false ) ;
const [ settingsOpen , setSettingsOpen ] = useState ( false ) ;
const [ promptDialogOpen , setPromptDialogOpen ] = useState ( false ) ;
const [ assetPickerOpen , setAssetPickerOpen ] = useState ( false ) ;
const [ startedAt , setStartedAt ] = useState ( 0 ) ;
const [ elapsedMs , setElapsedMs ] = useState ( 0 ) ;
const [ selectedLogIds , setSelectedLogIds ] = useState < string [ ] > ( [ ] ) ;
const [ previewLog , setPreviewLog ] = useState < GenerationLog | null > ( null ) ;
const [ deleteConfirmOpen , setDeleteConfirmOpen ] = useState ( false ) ;
const model = effectiveConfig . videoModel || effectiveConfig . model ;
const canGenerate = Boolean ( prompt . trim ( ) ) ;
useEffect ( ( ) = > {
if ( ! running || ! startedAt ) return ;
const timer = window . setInterval ( ( ) = > setElapsedMs ( performance . now ( ) - startedAt ) , 1000 ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ running , startedAt ] ) ;
useEffect ( ( ) = > {
void refreshLogs ( ) ;
} , [ ] ) ;
const addReferences = async ( files? : FileList | null ) = > {
const imageFiles = Array . from ( files || [ ] ) . filter ( ( file ) = > file . type . startsWith ( "image/" ) ) . slice ( 0 , 7 - references . length ) ;
const nextReferences = await Promise . all (
imageFiles . map ( async ( file ) = > {
const image = await uploadImage ( file ) ;
return { id : nanoid ( ) , name : file.name , type : image . mimeType , dataUrl : image.url , storageKey : image.storageKey } ;
} ) ,
) ;
setReferences ( ( value ) = > [ . . . value , . . . nextReferences ] . slice ( 0 , 7 ) ) ;
} ;
const addReferencesFromClipboard = async ( ) = > {
try {
const items = await navigator . clipboard . read ( ) ;
const blobs = await Promise . all ( items . flatMap ( ( item ) = > item . types . filter ( ( type ) = > type . startsWith ( "image/" ) ) . map ( ( type ) = > item . getType ( type ) ) ) ) ;
if ( ! blobs . length ) {
message . error ( "剪切板里没有可读取的图片" ) ;
return ;
}
const nextReferences = await Promise . all (
blobs . slice ( 0 , 7 - references . length ) . map ( async ( blob , index ) = > {
const image = await uploadImage ( blob ) ;
return { id : nanoid ( ) , name : ` clipboard- ${ index + 1 } .png ` , type : image . mimeType , dataUrl : image.url , storageKey : image.storageKey } ;
} ) ,
) ;
setReferences ( ( value ) = > [ . . . value , . . . nextReferences ] . slice ( 0 , 7 ) ) ;
message . success ( ` 已读取 ${ nextReferences . length } 张参考图 ` ) ;
} catch {
message . error ( "剪切板里没有可读取的图片" ) ;
}
} ;
const generate = async ( ) = > {
const snapshot = buildRequestSnapshot ( ) ;
if ( ! snapshot ) return ;
setElapsedMs ( 0 ) ;
setRunning ( true ) ;
setPreviewLog ( null ) ;
setResults ( [ { id : nanoid ( ) , status : "pending" } ] ) ;
const batchStartedAt = performance . now ( ) ;
setStartedAt ( batchStartedAt ) ;
try {
const blob = await requestVideoGeneration ( snapshot . config , snapshot . text , snapshot . references ) ;
const stored = await uploadMediaFile ( blob , "video" ) ;
const nextVideo : GeneratedVideo = {
id : nanoid ( ) ,
url : stored.url ,
storageKey : stored.storageKey ,
durationMs : performance.now ( ) - batchStartedAt ,
width : stored.width || 1280 ,
height : stored.height || 720 ,
bytes : stored.bytes ,
mimeType : stored.mimeType ,
} ;
setResults ( [ { id : nextVideo.id , status : "success" , video : nextVideo } ] ) ;
saveLog ( buildLog ( { prompt : snapshot.text , model , config : snapshot.config , durationMs : nextVideo.durationMs , status : "成功" , video : nextVideo } ) ) ;
message . success ( "视频已生成" ) ;
} catch ( error ) {
const errorMessage = error instanceof Error ? error . message : "生成失败" ;
setResults ( [ { id : nanoid ( ) , status : "failed" , error : errorMessage } ] ) ;
saveLog ( buildLog ( { prompt : snapshot.text , model , config : snapshot.config , durationMs : performance.now ( ) - batchStartedAt , status : "失败" , error : errorMessage } ) ) ;
message . error ( errorMessage ) ;
} finally {
setRunning ( false ) ;
}
} ;
const buildRequestSnapshot = ( ) = > {
const text = prompt . trim ( ) ;
if ( ! text ) {
message . error ( "请输入视频提示词" ) ;
return null ;
}
if ( ! isAiConfigReady ( effectiveConfig , model ) ) {
message . warning ( "请先完成配置" ) ;
openConfigDialog ( true ) ;
return null ;
}
return { text , config : { . . . effectiveConfig , model } , references : [ . . . references ] } ;
} ;
const retryResult = ( ) = > {
void generate ( ) ;
} ;
const downloadVideo = ( video : GeneratedVideo ) = > {
const link = document . createElement ( "a" ) ;
link . href = video . url ;
link . download = "video.mp4" ;
link . click ( ) ;
} ;
const saveResultToAssets = ( video : GeneratedVideo ) = > {
addAsset ( {
kind : "video" ,
title : "生成视频" ,
coverUrl : "" ,
tags : [ ] ,
source : "视频创作台" ,
data : { url : video.url , storageKey : video.storageKey , width : video.width , height : video.height , bytes : video.bytes , mimeType : video.mimeType } ,
metadata : { source : "video-page" , prompt } ,
} ) ;
message . success ( "已加入我的素材" ) ;
} ;
const insertPickedAsset = async ( payload : InsertAssetPayload ) = > {
if ( payload . kind === "text" ) {
setPrompt ( payload . content ) ;
} else if ( payload . kind === "image" ) {
const stored = await uploadImage ( payload . dataUrl ) ;
setReferences ( ( value ) = > [ . . . value , { id : nanoid ( ) , name : payload.title , type : stored . mimeType , dataUrl : stored.url , storageKey : stored.storageKey } ] . slice ( 0 , 7 ) ) ;
}
setAssetPickerOpen ( false ) ;
} ;
const createSession = ( ) = > {
setPrompt ( "" ) ;
setReferences ( [ ] ) ;
setResults ( [ ] ) ;
setElapsedMs ( 0 ) ;
setStartedAt ( 0 ) ;
setSelectedLogIds ( [ ] ) ;
setPreviewLog ( null ) ;
} ;
const deleteSelectedLogs = ( ) = > {
void Promise . all ( selectedLogIds . map ( ( id ) = > logStore . removeItem ( id ) ) ) . then ( refreshLogs ) ;
if ( previewLog && selectedLogIds . includes ( previewLog . id ) ) {
setPreviewLog ( null ) ;
setResults ( [ ] ) ;
}
setSelectedLogIds ( [ ] ) ;
setDeleteConfirmOpen ( false ) ;
} ;
const saveLog = ( log : GenerationLog ) = > {
void logStore . setItem ( log . id , log ) . then ( refreshLogs ) ;
} ;
const refreshLogs = async ( ) = > setLogs ( await readStoredLogs ( ) ) ;
const previewGenerationLog = ( log : GenerationLog ) = > {
setPreviewLog ( log ) ;
setLogsOpen ( false ) ;
setResults ( log . video ? [ { id : log.video.id , status : "success" , video : log.video } ] : [ { id : log.id , status : "failed" , error : log.error || "生成失败" } ] ) ;
} ;
return (
< div className = "flex h-full flex-col overflow-hidden bg-stone-50 text-stone-900 dark:bg-stone-950 dark:text-stone-100" >
< main className = "grid min-h-0 flex-1 grid-cols-1 gap-3 overflow-y-auto p-3 lg:grid-cols-[300px_minmax(0,1fr)] lg:overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)]" >
< aside className = "thin-scrollbar hidden min-h-0 overflow-y-auto rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:block" >
< LogPanel logs = { logs } selectedLogIds = { selectedLogIds } activeLogId = { previewLog ? . id } onSelectedLogIdsChange = { setSelectedLogIds } onCreateSession = { createSession } onDeleteSelected = { ( ) = > setDeleteConfirmOpen ( true ) } onPreviewLog = { previewGenerationLog } / >
< / aside >
< section className = "grid gap-3 lg:min-h-0 lg:overflow-hidden xl:grid-cols-[420px_minmax(0,1fr)]" >
< div className = "thin-scrollbar flex flex-col rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto" >
< div className = "flex items-start justify-between gap-3" >
< h1 className = "text-2xl font-semibold text-stone-950 dark:text-stone-100" > 视 频 创 作 台 < / h1 >
< div className = "flex shrink-0 gap-2 lg:hidden" >
< Button icon = { < History className = "size-4" / > } onClick = { ( ) = > setLogsOpen ( true ) } >
记 录
< / Button >
< Button icon = { < SlidersHorizontal className = "size-4" / > } onClick = { ( ) = > setSettingsOpen ( true ) } >
参 数
< / Button >
< / div >
< / div >
< div className = "mt-6 space-y-5" >
< div >
< div className = "mb-2 flex items-center justify-between gap-3" >
< span className = "text-base font-semibold" > 提 示 词 < / span >
< div className = "flex gap-2" >
< Button size = "small" icon = { < BookOpen className = "size-3.5" / > } onClick = { ( ) = > setPromptDialogOpen ( true ) } >
查 看 提 示 词 库
< / Button >
< Button size = "small" icon = { < FolderPlus className = "size-3.5" / > } onClick = { ( ) = > setAssetPickerOpen ( true ) } >
查 看 我 的 素 材
< / Button >
< / div >
< / div >
< Input.TextArea value = { prompt } onChange = { ( event ) = > setPrompt ( event . target . value ) } rows = { 7 } placeholder = "描述镜头运动、主体动作、场景氛围和画面风格" / >
< / div >
< div className = "min-w-0" >
< div className = "mb-2 flex items-center justify-between gap-3" >
< span className = "text-base font-semibold" > 参 考 图 < / span >
< div className = "flex gap-2" >
< Button size = "small" icon = { < ClipboardPaste className = "size-3.5" / > } onClick = { ( ) = > void addReferencesFromClipboard ( ) } >
剪 切 板
< / Button >
< Button size = "small" icon = { < Upload className = "size-3.5" / > } onClick = { ( ) = > fileInputRef . current ? . click ( ) } >
上 传
< / Button >
< / div >
< / div >
< div className = "hover-scrollbar hover-scrollbar-hint flex min-h-24 w-full min-w-0 max-w-full gap-2 overflow-x-scroll overflow-y-hidden rounded-lg border border-dashed border-stone-300 p-2 pb-3 overscroll-x-contain dark:border-stone-700" >
{ references . map ( ( item ) = > (
< div key = { item . id } className = "group relative size-20 shrink-0 overflow-hidden rounded-md border border-stone-200 dark:border-stone-800" >
< img src = { item . dataUrl } alt = { item . name } className = "size-full object-cover" / >
< button type = "button" className = "absolute right-1 top-1 hidden size-6 items-center justify-center rounded bg-black/60 text-white group-hover:flex" onClick = { ( ) = > setReferences ( ( value ) = > value . filter ( ( ref ) = > ref . id !== item . id ) ) } aria-label = "移除参考图" >
< Trash2 className = "size-3.5" / >
< / button >
< / div >
) ) }
{ ! references . length ? < div className = "flex min-w-full items-center justify-center text-sm text-stone-500" > 暂 无 参 考 图 , 最 多 7 张 < / div > : null }
< / div >
< / div >
< div className = "flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden" >
< span className = "truncate text-stone-500 dark:text-stone-400" >
{ model } · { effectiveConfig . vquality } p · { effectiveConfig . size } · { effectiveConfig . videoSeconds } s
< / span >
< Button size = "small" type = "text" icon = { < SlidersHorizontal className = "size-4" / > } onClick = { ( ) = > setSettingsOpen ( true ) } >
调 整
< / Button >
< / div >
< div className = "hidden gap-4 sm:grid sm:grid-cols-2" >
< GenerationSettings config = { effectiveConfig } model = { model } updateConfig = { updateConfig } openConfigDialog = { openConfigDialog } / >
< / div >
< / div >
< div className = "mt-auto pt-6" >
< Button type = "primary" size = "large" block icon = { < Sparkles className = "size-4" / > } loading = { running } disabled = { ! canGenerate || running } onClick = { ( ) = > void generate ( ) } >
开 始 生 成
< / Button >
< / div >
< / div >
< div className = "thin-scrollbar rounded-lg border border-stone-200 bg-card p-4 shadow-sm dark:border-stone-800 lg:min-h-0 lg:overflow-y-auto lg:p-5" >
< div className = "mb-4 flex items-center justify-between gap-3" >
< h2 className = "text-xl font-semibold" > 生 成 结 果 < / h2 >
{ running ? < Tag className = "m-0 px-2 py-1" > 等 待 { formatDuration ( elapsedMs ) } < / Tag > : null }
< / div >
{ results . length ? (
< div className = "grid gap-4" >
{ results . map ( ( result ) = > ( result . status === "success" && result . video ? < ResultVideoCard key = { result . id } video = { result . video } onDownload = { downloadVideo } onSaveAsset = { saveResultToAssets } / > : result . status === "failed" ? < FailedVideoCard key = { result . id } error = { result . error || "生成失败" } onRetry = { retryResult } / > : < PendingVideoCard key = { result . id } / > ) ) }
< / div >
) : (
< div className = "flex min-h-[320px] flex-col items-center justify-center rounded-lg border border-dashed border-stone-300 text-center dark:border-stone-700 lg:min-h-[560px]" >
< VideoIcon className = "mb-4 size-11 text-stone-400" / >
< Empty image = { Empty . PRESENTED_IMAGE_SIMPLE } description = "还没有生成视频" / >
< / div >
) }
< / div >
< / section >
< / main >
< input
ref = { fileInputRef }
type = "file"
accept = "image/*"
multiple
className = "hidden"
onChange = { ( event ) = > {
void addReferences ( event . target . files ) ;
event . target . value = "" ;
} }
/ >
< Drawer title = "生成记录" placement = "bottom" size = "large" open = { logsOpen } onClose = { ( ) = > setLogsOpen ( false ) } >
< LogPanel logs = { logs } selectedLogIds = { selectedLogIds } activeLogId = { previewLog ? . id } onSelectedLogIdsChange = { setSelectedLogIds } onCreateSession = { createSession } onDeleteSelected = { ( ) = > setDeleteConfirmOpen ( true ) } onPreviewLog = { previewGenerationLog } / >
< / Drawer >
< Drawer title = "参数" placement = "bottom" size = "default" open = { settingsOpen } onClose = { ( ) = > setSettingsOpen ( false ) } >
< div className = "grid grid-cols-2 gap-3" >
< GenerationSettings config = { effectiveConfig } model = { model } updateConfig = { updateConfig } openConfigDialog = { openConfigDialog } / >
< / div >
< / Drawer >
< PromptSelectDialog open = { promptDialogOpen } onOpenChange = { setPromptDialogOpen } onSelect = { setPrompt } / >
< AssetPickerModal open = { assetPickerOpen } defaultTab = "my-assets" onInsert = { ( payload ) = > void insertPickedAsset ( payload ) } onClose = { ( ) = > setAssetPickerOpen ( false ) } / >
< Modal title = "删除生成记录" open = { deleteConfirmOpen } onCancel = { ( ) = > setDeleteConfirmOpen ( false ) } onOk = { deleteSelectedLogs } okText = "删除" okButtonProps = { { danger : true } } cancelText = "取消" >
确 定 删 除 选 中 的 { selectedLogIds . length } 条 生 成 记 录 吗 ?
< / Modal >
< / div >
) ;
}
function GenerationSettings ( { config , model , updateConfig , openConfigDialog } : { config : AiConfig ; model : string ; updateConfig : UpdateAiConfig ; openConfigDialog : ( shouldPromptContinue? : boolean ) = > void } ) {
return (
< >
< label className = "col-span-2 block min-w-0 sm:col-span-1" >
< span className = "mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base" > 模 型 < / span >
< ModelPicker config = { config } value = { model } onChange = { ( value ) = > updateConfig ( "videoModel" , value ) } fullWidth onMissingConfig = { ( ) = > openConfigDialog ( false ) } / >
< / label >
< label className = "block" >
< span className = "mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base" > 秒 数 < / span >
< AutoComplete className = "canvas-control-select w-full" value = { config . videoSeconds } options = { secondOptions } onChange = { ( value ) = > updateConfig ( "videoSeconds" , value ) } / >
< / label >
< label className = "block" >
< span className = "mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base" > 尺 寸 < / span >
< AutoComplete className = "canvas-control-select w-full" value = { config . size } options = { sizeOptions } placeholder = "例如 1280x720" onChange = { ( value ) = > updateConfig ( "size" , value ) } / >
< / label >
< label className = "block" >
< span className = "mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base" > 清 晰 度 < / span >
< AutoComplete className = "canvas-control-select w-full" value = { normalizeResolution ( config . vquality ) } options = { resolutionOptions } placeholder = "例如 720" onChange = { ( value ) = > updateConfig ( "vquality" , value ) } / >
< / label >
< / >
) ;
}
function ResultVideoCard ( { video , onDownload , onSaveAsset } : { video : GeneratedVideo ; onDownload : ( video : GeneratedVideo ) = > void ; onSaveAsset : ( video : GeneratedVideo ) = > void } ) {
return (
< div className = "overflow-hidden rounded-lg border border-stone-200 bg-background dark:border-stone-800" >
< video src = { video . url } controls className = "aspect-video w-full bg-black object-contain" / >
< div className = "flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-t border-stone-200 px-3 py-2.5 dark:border-stone-800" >
< div className = "flex min-w-0 flex-wrap gap-x-2 gap-y-1 text-xs text-stone-500 dark:text-stone-400" >
< span >
{ video . width } x { video . height }
< / span >
< span > { formatBytes ( video . bytes ) } < / span >
< span > { formatDuration ( video . durationMs ) } < / span >
< / div >
< div className = "flex shrink-0 gap-1" >
< Button size = "small" icon = { < FolderPlus className = "size-3.5" / > } onClick = { ( ) = > onSaveAsset ( video ) } >
添 加 到 素 材
< / Button >
< Button size = "small" icon = { < Download className = "size-3.5" / > } onClick = { ( ) = > onDownload ( video ) } >
下 载
< / Button >
< / div >
< / div >
< / div >
) ;
}
function PendingVideoCard() {
return (
< div className = "relative aspect-video overflow-hidden rounded-lg border border-dashed border-stone-300 bg-stone-50 dark:border-stone-700 dark:bg-stone-900" >
< div className = "absolute inset-0 flex flex-col items-center justify-center gap-2 text-sm text-stone-500 dark:text-stone-400" >
< LoaderCircle className = "size-6 animate-spin" / >
< span > 生 成 中 < / span >
< / div >
< / div >
) ;
}
function FailedVideoCard ( { error , onRetry } : { error : string ; onRetry : ( ) = > void } ) {
return (
< div className = "overflow-hidden rounded-lg border border-red-200 bg-red-50 dark:border-red-950 dark:bg-red-950/20" >
< div className = "flex aspect-video flex-col items-center justify-center gap-3 p-5 text-center" >
< div className = "text-sm font-medium text-red-600 dark:text-red-300" > 生 成 失 败 < / div >
< Typography.Paragraph ellipsis = { { rows : 4 } } className = "!mb-0 !text-xs !text-red-500 dark:!text-red-300" >
{ error }
< / Typography.Paragraph >
< / div >
< div className = "flex justify-end border-t border-red-200 p-3 dark:border-red-950" >
< Button size = "small" danger onClick = { onRetry } >
重 试
< / Button >
< / div >
< / div >
) ;
}
function LogPanel ( {
logs ,
selectedLogIds ,
activeLogId ,
onSelectedLogIdsChange ,
onCreateSession ,
onDeleteSelected ,
onPreviewLog ,
} : {
logs : GenerationLog [ ] ;
selectedLogIds : string [ ] ;
activeLogId? : string ;
onSelectedLogIdsChange : ( ids : string [ ] ) = > void ;
onCreateSession : ( ) = > void ;
onDeleteSelected : ( ) = > void ;
onPreviewLog : ( log : GenerationLog ) = > void ;
} ) {
const allSelected = Boolean ( logs . length ) && selectedLogIds . length === logs . length ;
const toggleAll = ( ) = > onSelectedLogIdsChange ( allSelected ? [ ] : logs . map ( ( log ) = > log . id ) ) ;
return (
< >
< div className = "mb-3 flex items-center justify-between gap-3" >
< h2 className = "text-base font-semibold" > 生 成 记 录 < / h2 >
< Tag className = "m-0" > { logs . length } < / Tag >
< / div >
< div className = "mb-4 flex flex-wrap gap-2" >
< Button size = "small" icon = { < Plus className = "size-3.5" / > } onClick = { onCreateSession } >
新 建
< / Button >
< Button size = "small" icon = { < CheckSquare className = "size-3.5" / > } disabled = { ! logs . length } onClick = { toggleAll } >
{ allSelected ? "取消" : "全选" }
< / Button >
< Button size = "small" danger icon = { < Trash2 className = "size-3.5" / > } disabled = { ! selectedLogIds . length } onClick = { onDeleteSelected } >
删 除
< / Button >
< / div >
< div className = "space-y-3" >
{ logs . map ( ( log ) = > (
< LogCard key = { log . id } log = { log } selected = { selectedLogIds . includes ( log . id ) } active = { activeLogId === log . id } onSelectedChange = { ( checked ) = > onSelectedLogIdsChange ( checked ? [ . . . selectedLogIds , log . id ] : selectedLogIds . filter ( ( id ) = > id !== log . id ) ) } onClick = { ( ) = > onPreviewLog ( log ) } / >
) ) }
{ ! logs . length ? < div className = "flex min-h-48 items-center justify-center rounded-lg border border-dashed border-stone-300 text-center text-sm text-stone-500 dark:border-stone-700" > 暂 无 生 成 记 录 < / div > : null }
< / div >
< / >
) ;
}
function LogCard ( { log , selected , active , onSelectedChange , onClick } : { log : GenerationLog ; selected : boolean ; active : boolean ; onSelectedChange : ( checked : boolean ) = > void ; onClick : ( ) = > void } ) {
return (
< button type = "button" className = { ` block w-full rounded-lg border p-2 text-left transition ${ active ? "border-stone-900 bg-blue-50 dark:border-stone-100 dark:bg-blue-950/20" : "border-stone-200 bg-background hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900" } ` } onClick = { onClick } >
< div className = "grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-2" >
< Checkbox className = "mt-0.5" checked = { selected } onClick = { ( event ) = > event . stopPropagation ( ) } onChange = { ( event ) = > onSelectedChange ( event . target . checked ) } / >
< div className = "min-w-0" >
< div className = "truncate text-sm font-semibold leading-5" > { log . title } < / div >
< div className = "mt-2 flex flex-wrap gap-1" >
< Tag className = "m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" > { log . size } < / Tag >
< Tag className = "m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" > { log . resolution } p < / Tag >
< Tag className = "m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" > { log . seconds } s < / Tag >
< / div >
< / div >
< div className = "grid justify-items-end gap-2" >
< Tag className = "m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color = { log . status === "成功" ? "blue" : "red" } >
{ log . status }
< / Tag >
< Tag className = "m-0 flex h-6 items-center rounded-md px-1.5 text-xs leading-none" color = "green" >
{ formatDuration ( log . durationMs ) }
< / Tag >
< / div >
< / div >
< / button >
) ;
}
async function readStoredLogs() {
if ( typeof window === "undefined" ) return [ ] ;
try {
const logs : GenerationLog [ ] = [ ] ;
await logStore . iterate < GenerationLog , void > ( ( value ) = > {
logs . push ( value ) ;
} ) ;
return ( await Promise . all ( logs . map ( normalizeLog ) ) ) . sort ( ( a , b ) = > ( b . createdAt || 0 ) - ( a . createdAt || 0 ) ) ;
} catch {
return [ ] ;
}
}
async function normalizeLog ( log : Partial < GenerationLog > ) : Promise < GenerationLog > {
const video = log . video ? . storageKey ? { . . . log . video , url : await resolveMediaUrl ( log . video . storageKey , log . video . url ) } : log . video ;
return {
id : log.id || nanoid ( ) ,
createdAt : log.createdAt || Date . now ( ) ,
title : log.title || log . model || "未命名" ,
time : log.time || new Date ( ) . toLocaleString ( "zh-CN" , { hour12 : false } ) ,
model : log.model || "" ,
durationMs : log.durationMs || 0 ,
size : log.size || "" ,
resolution : normalizeResolution ( log . resolution || "" ) ,
seconds : log.seconds || "" ,
status : log.status || "成功" ,
video ,
error : log.error ,
} ;
}
function buildLog ( { prompt , model , config , durationMs , status , video , error } : { prompt : string ; model : string ; config : AiConfig ; durationMs : number ; status : GenerationLog [ "status" ] ; video? : GeneratedVideo ; error? : string } ) : GenerationLog {
return {
id : nanoid ( ) ,
createdAt : Date.now ( ) ,
title : prompt.slice ( 0 , 12 ) || "未命名" ,
time : new Date ( ) . toLocaleString ( "zh-CN" , { hour12 : false } ) ,
model ,
durationMs ,
size : config.size ,
resolution : normalizeResolution ( config . vquality ) ,
seconds : config.videoSeconds ,
status ,
video ,
error ,
} ;
}
function normalizeResolution ( value : string ) {
return value . replace ( /p$/i , "" ) || "720" ;
}