'use client'; import { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import PromptPanel from '@/components/PromptPanel'; import Sidebar from '@/components/Sidebar'; import PackPanel from '@/components/PackPanel'; import ProjectGalleryDrawer from '@/components/ProjectGalleryDrawer'; import { OasisCanvas } from '@/components/login/OasisCanvas'; import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES } from '@/lib/templates'; import type { GenImage, GenSession, GeneratePackResponse, GenerateResponse, LockCharacterResponse, PackKind, ProjectFromUploadResponse, RegenerateAssetResponse, UploadImageResponse, UploadedImageRole, VideoGenerationResponse, } from '@/lib/types'; import type { VIDEO_TEMPLATES } from '@/lib/templates'; function modeLabel(mode?: GenSession['inputMode']) { if (mode === 'remix') return '二创项目'; if (mode === 'replicate') return '复刻项目'; if (mode === 'extend') return '补全项目'; return '想法项目'; } function projectTitle(session: GenSession) { return session.characterSpec?.name || session.prompt || '未命名项目'; } function defaultPrimaryAspectRatio(session: GenSession) { return session.inputMode === 'replicate' || session.inputMode === 'extend' ? '2 / 3' : '1 / 1'; } function imageSourcesForSession(session: GenSession) { const uploaded = session.uploadedImages?.map(image => ({ url: image.url, label: image.role === 'subject' ? '主体图' : image.role === 'reference' ? '参考图' : image.accessoryName || image.role, })) ?? []; if (uploaded.length) return uploaded; return session.refImages.map((url, index) => ({ url, label: `参考 ${index + 1}` })); } function packSlotTotal() { return PACK_ORDER.reduce((sum, kind) => sum + PACK_TEMPLATES[kind].length, 0); } const PACK_BRIEF_DESCRIPTIONS: Record = { patent: '六面视图 · 45° 立体图 · 局部放大', accessories: '配件六视图 · 连接结构 · 尺寸 · 组合图', production: '尺寸 · 材料 · 颜色 · 拆件 · 包装', marketing: '白底商品图 · 场景图 · 细节图 · 社媒图', }; function ProjectStat({ label, value, tone }: { label: string; value: string | number; tone?: 'accent' | 'soft' }) { return (
{label}
{value}
); } function ProjectSpecField({ label, value }: { label: string; value?: string | string[] }) { const text = Array.isArray(value) ? value.filter(Boolean).join('、') : value; if (!text) return null; return (
{label} {text}
); } function ReferenceStrip({ session }: { session: GenSession }) { const refs = imageSourcesForSession(session); if (!refs.length) return null; return (
{refs.slice(0, 6).map((ref, index) => (
{ref.label} {ref.label}
))}
); } function packForKind(session: GenSession, kind: PackKind, sourceImageId?: string) { return session.packs?.find(pack => pack.kind === kind && (!sourceImageId || pack.sourceImageId === sourceImageId)); } function isPackComplete(session: GenSession, kind: PackKind, sourceImageId?: string) { const pack = packForKind(session, kind, sourceImageId); return Boolean(pack && pack.assets.length >= PACK_TEMPLATES[kind].length); } function ProjectPackOverview({ session, primaryImage, loadingKind, onGeneratePack, onOpenPack, }: { session: GenSession; primaryImage: GenImage | null; loadingKind: PackKind | null; onGeneratePack: (image: GenImage, kind: PackKind) => void; onOpenPack: (kind: PackKind) => void; }) { if (!primaryImage) return null; const sourceImage = primaryImage; return (
生产进度 串行生成
{PACK_ORDER.map((kind, index) => { const pack = packForKind(session, kind, sourceImage.id); const total = PACK_TEMPLATES[kind].length; const count = pack?.assets.length ?? 0; const complete = count >= total; const previousKind = index > 0 ? PACK_ORDER[index - 1] : null; const locked = !session.characterSpec || Boolean(previousKind && !isPackComplete(session, previousKind, sourceImage.id)); const running = loadingKind === kind; const progress = Math.round((count / total) * 100); function handleGenerate() { if (locked || running) return; if (pack) { const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${count} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`); if (!ok) return; } onGeneratePack(sourceImage, kind); } return (
{index + 1}
{PACK_LABELS[kind]} {PACK_BRIEF_DESCRIPTIONS[kind]}
{count}/{total}
{complete ? '完成' : locked ? '锁定' : '可生成'}
); })}
); } function ProjectBrief({ session, loadingKind, characterLoading, onGeneratePack, onLockCharacter, onOpenPack, }: { session: GenSession; loadingKind: PackKind | null; characterLoading: boolean; onGeneratePack: (image: GenImage, kind: PackKind) => void; onLockCharacter: (image: GenImage) => void; onOpenPack: (kind: PackKind) => void; }) { const selectedImages = session.images.filter(image => image.status === 'selected'); const primaryImage = selectedImages[0] ?? session.images[0] ?? null; const generatedAssets = (session.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0); const totalSlots = packSlotTotal(); const [previewOpen, setPreviewOpen] = useState(false); const [primaryAspectRatio, setPrimaryAspectRatio] = useState(defaultPrimaryAspectRatio(session)); useEffect(() => { setPrimaryAspectRatio(defaultPrimaryAspectRatio(session)); }, [primaryImage?.url, session.inputMode]); return (
Project Core

{projectTitle(session)}

{session.characterSpec?.oneLiner || session.prompt || '当前项目暂无描述'}

{modeLabel(session.inputMode)}
{primaryImage && (
setPreviewOpen(true)} onMouseLeave={() => setPreviewOpen(false)} onFocus={() => setPreviewOpen(true)} onBlur={() => setPreviewOpen(false)} >
当前主方案 { const image = event.currentTarget; if (image.naturalWidth && image.naturalHeight) { setPrimaryAspectRatio(`${image.naturalWidth} / ${image.naturalHeight}`); } }} />
Primary {primaryImage.status === 'selected' ? '已选中' : '待筛选'}
{previewOpen && typeof document !== 'undefined' && createPortal( , document.body, )}
)}
{session.characterSpec ? (
角色设定
已锁定
) : (
角色设定

从右侧图库选中主方案后,在生产矩阵里锁定角色设定。

)}
); } export default function Home() { const [sessions, setSessions] = useState([]); const [current, setCurrent] = useState(null); const [loading, setLoading] = useState(false); const [loadingKind, setLoadingKind] = useState(null); const [characterLoading, setCharacterLoading] = useState(false); const [videoLoading, setVideoLoading] = useState(false); const [uploadLoading, setUploadLoading] = useState(false); const [provider, setProvider] = useState('?'); const [sidebarOpen, setSidebarOpen] = useState(true); const [activeAssetPanel, setActiveAssetPanel] = useState('pack-patent'); const refreshSessions = useCallback(async () => { const r = await fetch('/api/sessions'); const d = await r.json(); setSessions(d.sessions); return d.sessions as GenSession[]; }, []); useEffect(() => { refreshSessions(); }, [refreshSessions]); useEffect(() => { setActiveAssetPanel('pack-patent'); }, [current?.id]); async function handleGenerate(opts: { prompt: string; refImages: string[]; count: number; style?: string }) { setLoading(true); try { const r = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts), }); if (!r.ok) { alert('生成失败:' + (await r.text())); return; } const d: GenerateResponse = await r.json(); setProvider(d.provider); const all = await refreshSessions(); const s = all.find(x => x.id === d.sessionId) ?? null; setCurrent(s); } finally { setLoading(false); } } async function uploadImage(file: File, role: UploadedImageRole) { const form = new FormData(); form.set('image', file); form.set('role', role); const r = await fetch('/api/uploads', { method: 'POST', body: form }); if (!r.ok) throw new Error(await r.text()); const d: UploadImageResponse = await r.json(); return d.uploadedImage; } async function handleUploadProject(opts: { mode: 'remix' | 'replicate'; files: Array<{ file: File; role: 'reference' | 'subject' }>; prompt?: string; styleId?: string; count?: number; }) { if (uploadLoading) return; setUploadLoading(true); try { const uploadedImages = await Promise.all(opts.files.map(item => uploadImage(item.file, item.role))); const r = await fetch('/api/projects/from-upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadedImages, mode: opts.mode, remixPrompt: opts.mode === 'remix' ? opts.prompt : undefined, userHint: opts.mode === 'replicate' ? opts.prompt : undefined, styleId: opts.styleId, count: opts.count, }), }); if (!r.ok) { alert('上传项目创建失败:' + (await r.text())); return; } const d: ProjectFromUploadResponse = await r.json(); setProvider(d.provider); const all = await refreshSessions(); const s = all.find(x => x.id === d.sessionId) ?? null; setCurrent(s); } catch (error) { alert('上传失败:' + String(error)); } finally { setUploadLoading(false); } } async function handleAction(imageId: string, action: 'select' | 'reject' | 'reset') { if (!current) return; const r = await fetch('/api/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: current.id, imageId, action }), }); if (!r.ok) return; const d: { image: GenImage } = await r.json(); setCurrent(prev => prev ? { ...prev, images: prev.images.map(i => i.id === imageId ? d.image : i), } : prev); refreshSessions(); } async function handleGeneratePack(image: GenImage, kind: PackKind) { if (!current || loadingKind) return; setLoadingKind(kind); try { const r = await fetch('/api/packs/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: current.id, imageId: image.id, kind, background: true }), }); if (!r.ok) { alert('素材包生成失败:' + (await r.text())); return; } const d: GeneratePackResponse = await r.json(); setProvider(d.provider); const all = await refreshSessions(); const updated = all.find(x => x.id === current.id) ?? null; setCurrent(updated); scheduleSessionRefresh(current.id); } finally { setLoadingKind(null); } } async function reloadCurrent(sessionId: string) { const all = await refreshSessions(); const updated = all.find(x => x.id === sessionId) ?? null; setCurrent(updated); return updated; } function scheduleSessionRefresh(sessionId: string, remaining = 90) { if (remaining <= 0) return; window.setTimeout(async () => { const updated = await reloadCurrent(sessionId); const hasDraftPack = updated?.packs?.some(pack => pack.status === 'draft') ?? false; if (hasDraftPack || remaining > 84) scheduleSessionRefresh(sessionId, remaining - 1); }, 5000); } async function handleLockCharacter(image: GenImage) { if (!current || characterLoading) return; setCharacterLoading(true); try { const r = await fetch('/api/character/lock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: current.id, imageId: image.id, force: true }), }); if (!r.ok) { alert('角色锁定失败:' + (await r.text())); return; } const d: LockCharacterResponse = await r.json(); setProvider(d.provider); await reloadCurrent(current.id); } finally { setCharacterLoading(false); } } async function handleRegenerateAsset(assetId: string, userRefinement?: string) { if (!current) return; const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }), }); if (!r.ok) { alert('单张重做失败:' + (await r.text())); return; } const d: RegenerateAssetResponse = await r.json(); setProvider(d.provider); await reloadCurrent(current.id); } function resolveVideoAnchor(image: GenImage) { const packs = current?.packs ?? []; const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front'); const patentFront = packs.find(pack => pack.kind === 'patent')?.assets.find(asset => asset.templateId === 'patent_front'); const cleanReference = current?.characterSpec?.cleanReferenceImageUrl; if (mktFront) return { url: mktFront.url, label: '宣发白底图' }; if (patentFront) return { url: patentFront.url, label: '专利主图' }; if (cleanReference) return { url: cleanReference, label: 'L1 白底锚图' }; return { url: image.url, label: '意向图' }; } async function handleGenerateVideo(image: GenImage, template: typeof VIDEO_TEMPLATES[number]) { if (!current || videoLoading) return; setVideoLoading(true); try { const character = current.characterSpec ? `${current.characterSpec.name},${current.characterSpec.oneLiner}` : current.prompt; const prompt = template.promptTemplate.replace('{character}', character); const anchor = resolveVideoAnchor(image); const r = await fetch('/api/video/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, imageUrl: anchor.url, duration: template.duration, ratio: template.ratio, generateAudio: true, watermark: false, }), }); if (!r.ok) { alert('Seedance 视频提交失败:' + (await r.text())); return; } const d: VideoGenerationResponse = await r.json(); alert(`Seedance 任务已提交:${d.taskId ?? d.status};参考:${anchor.label}`); } finally { setVideoLoading(false); } } async function handleLogout() { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); window.location.href = '/login'; } return (
setSidebarOpen(v => !v)} sessions={sessions} currentId={current?.id ?? null} onPick={id => setCurrent(sessions.find(s => s.id === id) ?? null)} onNew={() => setCurrent(null)} />
AI Toy Patent

项目生产工作台

{current && ( 记录 )} {provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
{!current && (
)} {current && (
Current Project

{projectTitle(current)}

{new Date(current.createdAt).toLocaleString('zh-CN')} · {current.id}

setActiveAssetPanel(`pack-${kind}`)} />
)}
); }