495 lines
19 KiB
TypeScript
495 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
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_ORDER, PACK_TEMPLATES } from '@/lib/templates';
|
||
import type {
|
||
GenImage,
|
||
GenSession,
|
||
GenerateAllPacksResponse,
|
||
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 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);
|
||
}
|
||
|
||
function ProjectStat({ label, value, tone }: { label: string; value: string | number; tone?: 'accent' | 'soft' }) {
|
||
return (
|
||
<div className={`project-stat ${tone === 'accent' ? 'project-stat--accent' : ''}`}>
|
||
<div className="text-[10px] text-white/40">{label}</div>
|
||
<div className="mt-1 text-[18px] font-semibold tracking-tight">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReferenceStrip({ session }: { session: GenSession }) {
|
||
const refs = imageSourcesForSession(session);
|
||
if (!refs.length) return null;
|
||
|
||
return (
|
||
<div className="project-reference-strip">
|
||
{refs.slice(0, 6).map((ref, index) => (
|
||
<div key={`${ref.url}-${index}`} className="project-reference-tile">
|
||
<img src={ref.url} alt={ref.label} className="h-full w-full object-contain" />
|
||
<span>{ref.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProjectBrief({ session }: { session: GenSession }) {
|
||
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();
|
||
|
||
return (
|
||
<section className="project-brief-panel">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<span className="section-eyebrow">Project Core</span>
|
||
<h2 className="mt-2 text-[20px] font-semibold leading-tight text-white">{projectTitle(session)}</h2>
|
||
<p className="mt-2 line-clamp-3 text-[12px] leading-relaxed text-white/50">
|
||
{session.characterSpec?.oneLiner || session.prompt || '当前项目暂无描述'}
|
||
</p>
|
||
</div>
|
||
<span className="rounded-full bg-[#e6f578]/15 px-3 py-1 text-[10px] font-semibold text-[#e6f578] ring-1 ring-[#e6f578]/25">
|
||
{modeLabel(session.inputMode)}
|
||
</span>
|
||
</div>
|
||
|
||
{primaryImage && (
|
||
<div className="project-primary-preview mt-5">
|
||
<img src={primaryImage.url} alt="当前主方案" className="h-full w-full object-contain" />
|
||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-gradient-to-t from-black/72 to-transparent p-3">
|
||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-white/70">Primary</span>
|
||
<span className="text-[10px] text-white/50">{primaryImage.status === 'selected' ? '已选中' : '待筛选'}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||
<ProjectStat label="图库" value={session.images.length} />
|
||
<ProjectStat label="选中" value={selectedImages.length} tone="accent" />
|
||
<ProjectStat label="产出" value={`${generatedAssets}/${totalSlots}`} />
|
||
</div>
|
||
|
||
{session.characterSpec ? (
|
||
<div className="project-spec-card mt-5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[11px] font-semibold text-white/80">角色设定</span>
|
||
<span className="text-[10px] text-[#e6f578]/70">已锁定</span>
|
||
</div>
|
||
<div className="mt-3 space-y-2 text-[11px]">
|
||
{[
|
||
['形态', session.characterSpec.speciesShape],
|
||
['比例', session.characterSpec.bodyRatio],
|
||
['配色', session.characterSpec.colorPalette.join('、')],
|
||
['材料', session.characterSpec.materials.join('、')],
|
||
].map(([label, value]) => (
|
||
<div key={label} className="grid grid-cols-[42px_minmax(0,1fr)] gap-2">
|
||
<span className="text-white/35">{label}</span>
|
||
<span className="truncate text-white/75">{value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="project-spec-card mt-5">
|
||
<div className="text-[11px] font-semibold text-white/80">角色设定</div>
|
||
<p className="mt-2 text-[11px] leading-relaxed text-white/45">
|
||
从右侧图库选中主方案后,在生产矩阵里锁定角色设定。
|
||
</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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="project-shell 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="project-stage min-w-0 flex-1">
|
||
<div className="project-stage-inner">
|
||
<header className="project-topbar">
|
||
<div className="min-w-0">
|
||
<span className="section-eyebrow">AI Toy Patent</span>
|
||
<h1 className="mt-1 text-[18px] font-semibold tracking-tight text-[#f8f7ef]">项目生产工作台</h1>
|
||
</div>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
{current && (
|
||
<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={`h-1.5 w-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>
|
||
|
||
{!current && (
|
||
<section className="project-empty-canvas">
|
||
<PromptPanel
|
||
session={null}
|
||
onGenerate={handleGenerate}
|
||
onUploadProject={handleUploadProject}
|
||
loading={loading}
|
||
uploadLoading={uploadLoading}
|
||
/>
|
||
</section>
|
||
)}
|
||
|
||
{current && (
|
||
<section className="project-board">
|
||
<div className="project-board-head">
|
||
<div className="min-w-0">
|
||
<span className="section-eyebrow">Current Project</span>
|
||
<h2 className="mt-2 line-clamp-1 text-[24px] font-semibold leading-tight text-white">{projectTitle(current)}</h2>
|
||
<p className="mt-1 text-[11px] text-white/40">
|
||
{new Date(current.createdAt).toLocaleString('zh-CN')} · {current.id}
|
||
</p>
|
||
</div>
|
||
<ReferenceStrip session={current} />
|
||
</div>
|
||
|
||
<div className="project-board-grid">
|
||
<ProjectBrief session={current} />
|
||
<section className="project-production-panel">
|
||
<PackPanel
|
||
session={current}
|
||
loadingKind={loadingKind}
|
||
allLoading={allLoading}
|
||
characterLoading={characterLoading}
|
||
videoLoading={videoLoading}
|
||
onGenerate={handleGeneratePack}
|
||
onGenerateAll={handleGenerateAll}
|
||
onLockCharacter={handleLockCharacter}
|
||
onRegenerateAsset={handleRegenerateAsset}
|
||
onGenerateVideo={handleGenerateVideo}
|
||
/>
|
||
</section>
|
||
<ProjectGalleryDrawer session={current} onAction={handleAction} />
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|