Files
ai-toy-patent-workflow/src/app/page.tsx

670 lines
26 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_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<PackKind, string> = {
patent: '六面视图 · 45° 立体图 · 局部放大',
accessories: '配件六视图 · 连接结构 · 尺寸 · 组合图',
production: '尺寸 · 材料 · 颜色 · 拆件 · 包装',
marketing: '白底商品图 · 场景图 · 细节图 · 社媒图',
};
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 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 (
<div className="project-spec-card mt-5">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold text-white/80"></span>
<span className="text-[10px] text-white/36"></span>
</div>
<div className="space-y-2">
{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 (
<div key={kind} className={`project-pack-row ${locked ? 'project-pack-row--locked' : ''}`}>
<div className="project-pack-index">{index + 1}</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[12px] font-semibold text-white">{PACK_LABELS[kind]}</span>
<span className="truncate text-[10px] text-white/36">{PACK_BRIEF_DESCRIPTIONS[kind]}</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-gradient-to-r from-[#e6f578] to-[#d6b36a]" style={{ width: `${progress}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-white/36">{count}/{total}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
{complete ? '完成' : locked ? '锁定' : '可生成'}
</span>
<button
onClick={handleGenerate}
disabled={running || locked}
className="project-pack-action"
title={locked ? '先完成前置步骤' : pack ? '重新生成该包' : '生成该包'}
>
{running ? '...' : pack ? '重做' : '生成'}
</button>
<button
type="button"
className="project-pack-jump"
title="查看明细"
onClick={() => onOpenPack(kind)}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 6l6 6-6 6" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
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 (
<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="shrink-0 whitespace-nowrap 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>
<ProjectPackOverview
session={session}
primaryImage={primaryImage}
loadingKind={loadingKind}
onGeneratePack={onGeneratePack}
onOpenPack={onOpenPack}
/>
{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>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[10px] text-[#e6f578]/70"></span>
<button
type="button"
onClick={() => primaryImage && onLockCharacter(primaryImage)}
disabled={!primaryImage || characterLoading || !!loadingKind}
className="project-spec-action"
>
{characterLoading ? '锁定中' : '刷新'}
</button>
</div>
</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="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold text-white/80"></div>
<button
type="button"
onClick={() => primaryImage && onLockCharacter(primaryImage)}
disabled={!primaryImage || characterLoading || !!loadingKind}
className="project-spec-action project-spec-action--primary"
>
{characterLoading ? '锁定中' : '锁定'}
</button>
</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 [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 (
<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}
loadingKind={loadingKind}
characterLoading={characterLoading}
onGeneratePack={handleGeneratePack}
onLockCharacter={handleLockCharacter}
onOpenPack={kind => setActiveAssetPanel(`pack-${kind}`)}
/>
<section className="project-production-panel">
<PackPanel
session={current}
activeNav={activeAssetPanel}
onActiveNavChange={setActiveAssetPanel}
videoLoading={videoLoading}
onRegenerateAsset={handleRegenerateAsset}
onGenerateVideo={handleGenerateVideo}
/>
</section>
<ProjectGalleryDrawer
session={current}
activeNav={activeAssetPanel}
onAction={handleAction}
/>
</div>
</section>
)}
</div>
</main>
</div>
</div>
);
}