Files
ai-toy-patent-workflow/src/app/page.tsx
2026-05-20 17:11:41 +08:00

505 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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_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 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 ProjectSpecField({ label, value }: { label: string; value?: string | string[] }) {
const text = Array.isArray(value) ? value.filter(Boolean).join('、') : value;
if (!text) return null;
return (
<div className="project-spec-row">
<span className="project-spec-label">{label}</span>
<span className="project-spec-value">{text}</span>
</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();
const [previewOpen, setPreviewOpen] = useState(false);
const [primaryAspectRatio, setPrimaryAspectRatio] = useState('1 / 1');
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-card mt-5"
onMouseEnter={() => setPreviewOpen(true)}
onMouseLeave={() => setPreviewOpen(false)}
onFocus={() => setPreviewOpen(true)}
onBlur={() => setPreviewOpen(false)}
>
<div className="project-primary-preview" style={{ aspectRatio: primaryAspectRatio }}>
<img
src={primaryImage.url}
alt="当前主方案"
className="project-primary-image"
onLoad={event => {
const image = event.currentTarget;
if (image.naturalWidth && image.naturalHeight) {
setPrimaryAspectRatio(`${image.naturalWidth} / ${image.naturalHeight}`);
}
}}
/>
</div>
<div className="project-primary-meta">
<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>
{previewOpen && typeof document !== 'undefined' && createPortal(
<div className="project-image-popover project-image-popover--open" aria-hidden="true">
<img src={primaryImage.url} alt="" />
</div>,
document.body,
)}
</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]">
<ProjectSpecField label="形态" value={session.characterSpec.speciesShape} />
<ProjectSpecField label="比例" value={session.characterSpec.bodyRatio} />
<ProjectSpecField label="五官" value={session.characterSpec.faceFeatures} />
<ProjectSpecField label="配色" value={session.characterSpec.colorPalette} />
<ProjectSpecField label="材料" value={session.characterSpec.materials} />
<ProjectSpecField label="配件" value={session.characterSpec.accessories} />
<ProjectSpecField label="识别" value={session.characterSpec.signatureElements} />
<ProjectSpecField label="打样" value={session.characterSpec.manufacturingNotes} />
</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 [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 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}
characterLoading={characterLoading}
videoLoading={videoLoading}
onGenerate={handleGeneratePack}
onLockCharacter={handleLockCharacter}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}
/>
</section>
<ProjectGalleryDrawer session={current} onAction={handleAction} />
</div>
</section>
)}
</div>
</main>
</div>
</div>
);
}