Files
makelore/src/pages/ImageCanvas/index.tsx
T

227 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
CloudOff,
PanelLeft,
Plus,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { DesignConversationPane } from './DesignConversationPane';
import { CanvasWorkspaceSplit } from './CanvasWorkspaceSplit';
import { YouthCreationCard } from './YouthCreationCard';
import { DesignHistoryRail } from './DesignHistoryRail';
import { DesignWorksRail } from './DesignWorksRail';
function useDesktopLayout(): boolean {
const [desktop, setDesktop] = useState(() => (
typeof window.matchMedia === 'function'
? window.matchMedia('(min-width: 1024px)').matches
: false
));
useEffect(() => {
if (typeof window.matchMedia !== 'function') return;
const query = window.matchMedia('(min-width: 1024px)');
const update = () => setDesktop(query.matches);
update();
query.addEventListener?.('change', update);
return () => query.removeEventListener?.('change', update);
}, []);
return desktop;
}
function LoadingWorkspace() {
return (
<div className="flex h-full min-h-0 bg-background" aria-label="正在加载 AI 设计">
<div className="flex min-w-0 flex-1 flex-col">
<div className="h-10 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<div className="min-h-0 flex-1 space-y-4 bg-surface-subtle/35 p-6">
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
<div className="h-48 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
</div>
</div>
<div className="hidden h-full w-64 shrink-0 flex-col border-l border-border/70 bg-background lg:flex">
<div className="h-10 animate-pulse border-b border-border/60 bg-surface-subtle/45" />
<div className="space-y-3 p-3">
{[0, 1, 2].map((item) => (
<div key={item} className="h-16 animate-pulse rounded-xl bg-surface-subtle" />
))}
</div>
</div>
</div>
);
}
function EmptyWorkspace({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
<section data-testid="image-workspace-empty-state" className="w-full max-w-lg rounded-2xl border border-border/70 bg-background p-8 shadow-soft">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand">
<Sparkles className="h-5 w-5" />
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-foreground">你想创作什么?</h1>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
先建一个作品,再像聊天一样描述脑中的画面。AI 会帮你整理成可直接修改的制作方案。
</p>
<Button type="button" className="mt-6 h-10 rounded-xl" onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
开始第一个作品
</Button>
</section>
</div>
);
}
function ErrorWorkspace({ message, retry }: { message: string; retry: () => void }) {
return (
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
<section data-testid="image-workspace-unavailable" className="w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-soft">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-destructive/[0.06] text-destructive">
<CloudOff className="h-5 w-5" />
</div>
<h1 className="mt-4 text-lg font-semibold text-foreground">AI 设计暂时无法连接</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
<Button type="button" variant="outline" className="mt-5 h-10 rounded-xl" onClick={retry}>
<RefreshCw className="mr-2 h-4 w-4" />
重新连接
</Button>
</section>
</div>
);
}
export function ImageCanvas() {
const authenticated = useAuthStore((state) => state.isAuthenticated());
const status = useImageWorkspaceStore((state) => state.status);
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const workspace = useImageWorkspaceStore((state) => state.workspace);
const error = useImageWorkspaceStore((state) => state.error);
const quoteBlockers = useImageWorkspaceStore((state) => state.quoteBlockers);
const load = useImageWorkspaceStore((state) => state.load);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
const connectEvents = useImageWorkspaceStore((state) => state.connectEvents);
const disconnectEvents = useImageWorkspaceStore((state) => state.disconnectEvents);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
const consumePendingPrompt = useImagePromptMuseumStore((state) => state.consumePendingPrompt);
const workspaceId = workspace?.workspace.workspaceId ?? null;
const desktopLayout = useDesktopLayout();
const [worksOpen, setWorksOpen] = useState(false);
useEffect(() => {
if (authenticated && (status === 'idle' || status === 'auth-required')) void load();
}, [authenticated, load, status]);
useEffect(() => {
if (!workspaceId) return;
connectEvents();
return () => disconnectEvents();
}, [connectEvents, disconnectEvents, workspaceId]);
useEffect(() => {
if (!workspaceId) return;
const pendingPrompt = consumePendingPrompt();
if (pendingPrompt) setChatDraft(pendingPrompt.prompt);
}, [consumePendingPrompt, setChatDraft, workspaceId]);
if (status === 'loading') {
return <LoadingWorkspace />;
}
if (status === 'unavailable' || status === 'error') {
return <ErrorWorkspace message={error ?? '请检查网络后重试'} retry={() => void load()} />;
}
if (!workspace && error) {
return <ErrorWorkspace message={error} retry={() => {
if (bootstrap && activeWorkspaceId) void selectProject(activeWorkspaceId);
else void load();
}} />;
}
if (status === 'ready' && bootstrap?.workspaces.length && !workspace) return <LoadingWorkspace />;
if (!workspace && status !== 'ready') return <LoadingWorkspace />;
const generationAvailable = bootstrap?.capabilities.generation !== false;
const platform = window.electron?.platform;
const openCreate = () => {
if (!desktopLayout) setWorksOpen(true);
globalThis.setTimeout(() => {
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
}, 0);
};
const latestTask = [...(workspace?.tasks ?? [])].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))[0];
const taskStatus = latestTask ? { queued: '等待制作', running: '制作中', succeeded: '已完成', failed: '制作未完成', cancelled: '已取消' }[latestTask.status] : null;
return (
<div data-testid="image-workspace" className="canvas-workspace bg-background font-sans">
<header data-testid="image-workspace-header" className={cn(
'canvas-workspace-header',
platform === 'darwin' ? 'pl-4 pr-[116px] sm:pl-5'
: platform === 'win32' ? 'pl-5 pr-[260px]' : 'px-4 sm:px-5',
)}>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand">
<Sparkles className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-foreground">{workspace?.workspace.title ?? 'Makelore Canvas'}</h1>
<p className="canvas-header-version mt-0.5 text-xs text-muted-foreground">
{workspace ? `设计版本 ${workspace.form.specificationRevision}` : '把想法变成图片和视频'}
</p>
</div>
{taskStatus && <span role="status" className={cn('hidden text-xs sm:inline',
latestTask.status === 'failed' ? 'text-destructive' : 'text-muted-foreground')}>{taskStatus}</span>}
{!desktopLayout && <Button type="button" variant="outline" size="sm" className="h-10 shrink-0" onClick={() => setWorksOpen(true)}>
<PanelLeft className="mr-1.5 h-4 w-4" />项目
</Button>}
</header>
{error && workspace && <div className="flex shrink-0 items-center justify-between gap-3 border-b border-amber-200 bg-amber-50/70 px-4 py-2 text-xs text-amber-950">
<span role="alert">{error}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => void refreshWorkspace()}>刷新</Button>
</div>}
<CanvasWorkspaceSplit conversation={workspace ? <DesignConversationPane
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
generationOptions={bootstrap?.capabilities.generationOptions}
showPlanHistory={false}
showCreationCard={false}
/> : <EmptyWorkspace onCreate={openCreate} />}
works={<DesignHistoryRail key={workspaceId ?? 'empty'} workspace={workspace} creation={workspace && <YouthCreationCard
key={workspaceId}
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
generationOptions={bootstrap?.capabilities.generationOptions}
/>} />} />
{!desktopLayout && <Sheet open={worksOpen} onOpenChange={setWorksOpen}>
<SheetContent side="left" className="flex w-[min(92vw,360px)] flex-col overflow-hidden p-0 sm:max-w-[360px]">
<SheetHeader className="sr-only">
<SheetTitle>设计项目</SheetTitle>
<SheetDescription>新建、切换、重命名或删除 AI 绘画项目。</SheetDescription>
</SheetHeader>
<DesignWorksRail workspace={workspace} onProjectSelected={() => setWorksOpen(false)} />
</SheetContent>
</Sheet>}
</div>
);
}