227 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|