Align Canvas with Agents and give current works a resizable workspace

This commit is contained in:
鲨鱼辣椒 committed 2026-10-14 00:03:22 +08:00
1 parent 70166d22b3
commit 2ca6e3199d
19 files changed
+1548 -511

No files matched your search

+141 -116
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import {
FolderKanban,
Loader2,
MoreHorizontal,
Pencil,
Plus,
RefreshCw,
@@ -71,6 +73,8 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
useEffect(() => {
const openCreateDialog = () => {
// The compact project Sheet owns this event while the desktop sidebar is hidden.
if (typeof window.matchMedia === 'function' && !window.matchMedia('(min-width: 1024px)').matches) return;
setProjectName('');
setDialogError(null);
setDialog({ mode: 'create', project: null });
@@ -144,138 +148,159 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
navigate('/image-canvas');
};
if (sidebarCollapsed) {
return (
<div data-testid="sidebar-image-workspace" className="flex flex-col items-center gap-2 px-2 py-3">
return (
<div data-testid="sidebar-image-workspace" className="flex min-h-0 flex-1 flex-col px-2 pb-3 pt-3 font-sans">
{sidebarCollapsed ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="新建设计项目"
title="新建设计项目"
className="h-10 w-10 self-center rounded-lg text-brand hover:bg-brand-soft"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div data-testid="sidebar-image-workspace" className="flex min-h-0 flex-1 flex-col px-2 pb-3 pt-2 font-sans">
<div className="space-y-1">
<button
type="button"
className="motion-press flex min-h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4 text-brand" />
新建设计项目
</button>
</div>
<div className="mb-2 mt-5 flex items-center justify-between px-2">
<span className="text-xs font-medium text-muted-foreground">设计项目</span>
{(status === 'error' || status === 'unavailable') && (
<button
type="button"
aria-label="重新加载设计项目"
className="rounded-lg p-1 text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
onClick={() => void load()}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">
{status === 'loading' && (
<div className="space-y-2 px-1" aria-label="正在加载设计项目">
{[0, 1, 2].map((item) => (
<div key={item} className="h-12 animate-pulse rounded-xl bg-surface-subtle" />
))}
</div>
)}
{(status === 'error' || status === 'unavailable') && (
<div
data-testid="sidebar-image-workspace-unavailable"
className="rounded-xl bg-surface-subtle px-3 py-3 text-xs leading-5 text-muted-foreground"
>
{workspaceError ?? 'AI 设计暂时不可用'}
</div>
)}
{status === 'ready' && sortedProjects.length === 0 && (
<button
type="button"
onClick={openCreateDialog}
className="w-full rounded-xl border border-dashed border-border px-3 py-5 text-left transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
>
<span className="block text-sm font-medium text-foreground">从一个设计方向开始</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
对话和表单会一起完善同一份设计。
</span>
</button>
)}
{sortedProjects.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
const pending = project.workspaceId === activeWorkspaceId && !workspace;
return (
<div
key={project.workspaceId}
className={cn(
'group/project relative rounded-xl transition-colors',
active ? 'bg-brand/[0.08]' : 'hover:bg-surface-subtle',
)}
) : (
<>
<div>
<button
type="button"
className="motion-press flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold text-foreground transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={openCreateDialog}
>
<span aria-hidden="true" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-brand/15 bg-brand-soft text-brand">
<Plus className="h-4 w-4" />
</span>
新建设计项目
</button>
</div>
<div className="mb-1 mt-5 flex min-h-10 items-center justify-between px-2">
<span className="text-xs font-semibold text-muted-foreground">设计项目</span>
{(status === 'error' || status === 'unavailable') && (
<button
type="button"
className="flex min-h-12 w-full items-center gap-2.5 rounded-xl px-2.5 py-2 pr-16 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => chooseProject(project)}
aria-label="重新加载设计项目"
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => void load()}
>
{pending ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand" />
) : (
<FolderKanban className={cn('h-4 w-4 shrink-0', active ? 'text-brand' : 'text-muted-foreground')} />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{project.title}
</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground">
设计版本 {project.specificationRevision} · {formatUpdatedAt(project.updatedAt)}
</span>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">
{status === 'loading' && (
<div className="space-y-2 px-1" aria-label="正在加载设计项目">
{[0, 1, 2].map((item) => (
<div key={item} className="h-12 animate-pulse rounded-xl bg-surface-subtle" />
))}
</div>
)}
{(status === 'error' || status === 'unavailable') && (
<div
data-testid="sidebar-image-workspace-unavailable"
className="rounded-xl bg-surface-subtle px-3 py-3 text-xs leading-5 text-muted-foreground"
>
{workspaceError ?? 'AI 设计暂时不可用'}
</div>
)}
{status === 'ready' && sortedProjects.length === 0 && (
<button
type="button"
onClick={openCreateDialog}
className="w-full rounded-xl border border-dashed border-border px-3 py-5 text-left transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
>
<span className="block text-sm font-medium text-foreground">从一个设计方向开始</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
对话和表单会一起完善同一份设计。
</span>
</button>
<div className="absolute right-1.5 top-2 flex opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100">
<button
type="button"
aria-label={`重命名 ${project.title}`}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => openRenameDialog(project)}
)}
{sortedProjects.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
const pending = project.workspaceId === activeWorkspaceId && !workspace;
return (
<div
key={project.workspaceId}
className={cn(
'group/project relative rounded-lg transition-colors',
active ? 'bg-brand-soft' : 'hover:bg-surface-tertiary',
)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`删除 ${project.title}`}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-destructive"
onClick={() => {
setDeleteTarget(project);
setDeleteConfirmation('');
setDeleteError(null);
}}
>
{deletingWorkspaceId === project.workspaceId
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
})}
</div>
<button
type="button"
aria-current={active ? 'page' : undefined}
className="flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 pr-12 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => chooseProject(project)}
>
<span aria-hidden="true" className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border',
active ? 'border-brand/20 bg-background/80 text-brand' : 'border-border/70 bg-background text-muted-foreground',
)}>
{pending
? <Loader2 className="h-4 w-4 animate-spin" />
: <FolderKanban className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{project.title}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
设计版本 {project.specificationRevision} · {formatUpdatedAt(project.updatedAt)}
</span>
</span>
</button>
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
aria-label={`项目操作:${project.title}`}
title="项目操作"
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30 data-[state=open]:bg-background/80 data-[state=open]:text-foreground"
>
{deletingWorkspaceId === project.workspaceId
? <Loader2 className="h-4 w-4 animate-spin" />
: <MoreHorizontal className="h-4 w-4" />}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={4} collisionPadding={12} className="z-50 min-w-40 rounded-xl border border-border/80 bg-background p-1 text-sm shadow-float">
<DropdownMenu.Item
className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-foreground outline-none data-[highlighted]:bg-surface-subtle"
onSelect={() => openRenameDialog(project)}
>
<Pencil aria-hidden="true" className="h-4 w-4 text-muted-foreground" />
重命名
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={Boolean(deletingWorkspaceId)}
className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-3 text-destructive outline-none data-[highlighted]:bg-destructive/[0.05] data-[disabled]:opacity-50"
onSelect={() => {
setDeleteTarget(project);
setDeleteConfirmation('');
setDeleteError(null);
}}
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
删除项目
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</div>
);
})}
</div>
</>
)}
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
<DialogContent className="sm:max-w-md">