386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import PromptPanel from '@/components/PromptPanel';
|
||
import ResultGrid from '@/components/ResultGrid';
|
||
import Sidebar from '@/components/Sidebar';
|
||
import PackPanel from '@/components/PackPanel';
|
||
import { OasisCanvas } from '@/components/login/OasisCanvas';
|
||
import type {
|
||
GenImage,
|
||
GenSession,
|
||
GenerateAllPacksResponse,
|
||
GeneratePackResponse,
|
||
GenerateResponse,
|
||
LockCharacterResponse,
|
||
PackKind,
|
||
ProjectFromUploadResponse,
|
||
RegenerateAssetResponse,
|
||
UploadImageResponse,
|
||
UploadedImageRole,
|
||
VideoGenerationResponse,
|
||
} from '@/lib/types';
|
||
import type { VIDEO_TEMPLATES } from '@/lib/templates';
|
||
|
||
export default function Home() {
|
||
const [sessions, setSessions] = useState<GenSession[]>([]);
|
||
const [current, setCurrent] = useState<GenSession | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingKind, setLoadingKind] = useState<PackKind | null>(null);
|
||
const [allLoading, setAllLoading] = useState(false);
|
||
const [characterLoading, setCharacterLoading] = useState(false);
|
||
const [videoLoading, setVideoLoading] = useState(false);
|
||
const [uploadLoading, setUploadLoading] = useState(false);
|
||
const [provider, setProvider] = useState<string>('?');
|
||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||
|
||
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]);
|
||
|
||
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 handleGenerateAll(image: GenImage) {
|
||
if (!current || allLoading) return;
|
||
setAllLoading(true);
|
||
try {
|
||
const r = await fetch('/api/packs/generate-all', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sessionId: current.id, imageId: image.id, background: true }),
|
||
});
|
||
if (!r.ok) {
|
||
alert('完整三包生成失败:' + (await r.text()));
|
||
return;
|
||
}
|
||
const d: GenerateAllPacksResponse = await r.json();
|
||
setProvider(d.provider);
|
||
await reloadCurrent(current.id);
|
||
scheduleSessionRefresh(current.id);
|
||
} finally {
|
||
setAllLoading(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 (
|
||
<div className="app-oasis relative h-screen overflow-hidden text-white">
|
||
<OasisCanvas />
|
||
<div className="app-oasis-shade" />
|
||
<div className="app-grass-floor" />
|
||
<div className="relative z-10 flex h-screen text-white">
|
||
<Sidebar
|
||
open={sidebarOpen}
|
||
onToggle={() => setSidebarOpen(v => !v)}
|
||
sessions={sessions}
|
||
currentId={current?.id ?? null}
|
||
onPick={id => setCurrent(sessions.find(s => s.id === id) ?? null)}
|
||
onNew={() => setCurrent(null)}
|
||
/>
|
||
<main className="flex-1 overflow-y-auto">
|
||
<div className="mx-auto max-w-[1240px] px-10 py-8">
|
||
<header className="flex items-center justify-between mb-8 gap-4">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<div className="w-9 h-9 rounded-[8px] bg-gradient-to-br from-[#e6f578] to-[#d6b36a] flex items-center justify-center shrink-0 shadow-glow-violet">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#081006" strokeWidth="2.5">
|
||
<path d="M12 2l2.5 6.5L21 11l-6.5 2.5L12 20l-2.5-6.5L3 11l6.5-2.5z" strokeLinejoin="round" />
|
||
</svg>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h1 className="text-base font-semibold tracking-tight leading-tight text-[#f8f7ef]">
|
||
AI Toy Patent
|
||
<span className="ml-2 text-white/48 font-normal text-sm">把一句想法变成专利 / 生产 / 宣发素材包</span>
|
||
</h1>
|
||
<p className="text-[11px] text-[#e6f578]/55 mt-0.5">批量出意向 · 快筛 · 锁定角色 · 一键四包 · Seedance 视频</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
{current && (
|
||
<>
|
||
<a
|
||
href={`/api/gallery/${encodeURIComponent(current.id)}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
|
||
>
|
||
图库
|
||
</a>
|
||
<a
|
||
href={`/api/audit/${encodeURIComponent(current.id)}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
|
||
>
|
||
记录
|
||
</a>
|
||
</>
|
||
)}
|
||
<button
|
||
onClick={handleLogout}
|
||
className="chip chip-neutral hover:border-[#e6f578]/40 hover:text-white transition-colors"
|
||
>
|
||
退出
|
||
</button>
|
||
<span className={provider === 'gpt' ? 'chip chip-live' : provider === '?' ? 'chip chip-neutral' : 'chip chip-mock'}>
|
||
<span className={`w-1.5 h-1.5 rounded-full ${provider === 'gpt' ? 'bg-emerald-400' : provider === '?' ? 'bg-white/40' : 'bg-amber-400'}`} />
|
||
{provider === 'gpt' ? 'GPT · gpt-image-2' : provider === 'mock' ? 'Mock · 占位图' : provider === '?' ? '待连接' : provider}
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="space-y-8">
|
||
<PromptPanel
|
||
onGenerate={handleGenerate}
|
||
onUploadProject={handleUploadProject}
|
||
loading={loading}
|
||
uploadLoading={uploadLoading}
|
||
/>
|
||
{current && (
|
||
<section className="space-y-5">
|
||
<div className="flex items-end justify-between">
|
||
<div>
|
||
<span className="section-eyebrow">Step · 02 · Quick Screen</span>
|
||
<h2 className="mt-2 text-lg font-semibold text-white">本次生成</h2>
|
||
<p className="text-xs text-white/40 mt-1">
|
||
{new Date(current.createdAt).toLocaleString('zh-CN')}
|
||
</p>
|
||
</div>
|
||
<code className="text-[11px] text-white/30 font-mono">{current.id}</code>
|
||
</div>
|
||
<ResultGrid images={current.images} onAction={handleAction} />
|
||
<PackPanel
|
||
session={current}
|
||
loadingKind={loadingKind}
|
||
allLoading={allLoading}
|
||
characterLoading={characterLoading}
|
||
videoLoading={videoLoading}
|
||
onGenerate={handleGeneratePack}
|
||
onGenerateAll={handleGenerateAll}
|
||
onLockCharacter={handleLockCharacter}
|
||
onRegenerateAsset={handleRegenerateAsset}
|
||
onGenerateVideo={handleGenerateVideo}
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|