364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
FolderKanban,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
|
import { cn } from '@/lib/utils';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import type { DesignWorkspaceSummary } from '../../../shared/image-workspace';
|
|
|
|
type ImageWorkspaceSidebarProps = {
|
|
sidebarCollapsed: boolean;
|
|
};
|
|
|
|
type ProjectDialogState =
|
|
| { mode: 'create'; project: null }
|
|
| { mode: 'rename'; project: DesignWorkspaceSummary };
|
|
|
|
function formatUpdatedAt(value: string): string {
|
|
const timestamp = Date.parse(value);
|
|
if (!Number.isFinite(timestamp)) return '';
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
if (date.toDateString() === now.toDateString()) {
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
|
}
|
|
|
|
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
|
|
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
|
const navigate = useNavigate();
|
|
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 deletingWorkspaceId = useImageWorkspaceStore((state) => state.deletingWorkspaceId);
|
|
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
|
const load = useImageWorkspaceStore((state) => state.load);
|
|
const createProject = useImageWorkspaceStore((state) => state.createProject);
|
|
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
|
|
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
|
|
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
|
|
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
|
|
const [projectName, setProjectName] = useState('');
|
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<DesignWorkspaceSummary | null>(null);
|
|
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (status === 'idle' || (status === 'auth-required' && authenticated)) void load();
|
|
}, [authenticated, load, status]);
|
|
|
|
useEffect(() => {
|
|
const openCreateDialog = () => {
|
|
setProjectName('');
|
|
setDialogError(null);
|
|
setDialog({ mode: 'create', project: null });
|
|
};
|
|
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
|
|
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
|
|
}, []);
|
|
|
|
const sortedProjects = useMemo(
|
|
() => [...(bootstrap?.workspaces ?? [])].sort(
|
|
(left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt),
|
|
),
|
|
[bootstrap?.workspaces],
|
|
);
|
|
|
|
const openCreateDialog = () => {
|
|
setProjectName('');
|
|
setDialogError(null);
|
|
setDialog({ mode: 'create', project: null });
|
|
};
|
|
|
|
const openRenameDialog = (project: DesignWorkspaceSummary) => {
|
|
setProjectName(project.title);
|
|
setDialogError(null);
|
|
setDialog({ mode: 'rename', project });
|
|
};
|
|
|
|
const saveProject = async () => {
|
|
const title = projectName.trim();
|
|
if (!title) {
|
|
setDialogError('请输入项目名称');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setDialogError(null);
|
|
try {
|
|
if (dialog?.mode === 'rename') {
|
|
await renameProject(dialog.project.workspaceId, title);
|
|
toast.success('项目名称已更新');
|
|
} else {
|
|
await createProject(title);
|
|
navigate('/image-canvas');
|
|
}
|
|
setDialog(null);
|
|
} catch (error) {
|
|
setDialogError(error instanceof Error ? error.message : '保存项目失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
if (deleteConfirmation !== deleteTarget.title) {
|
|
setDeleteError('请输入完整项目名称');
|
|
return;
|
|
}
|
|
setDeleteError(null);
|
|
try {
|
|
await deleteProject(deleteTarget.workspaceId);
|
|
setDeleteTarget(null);
|
|
setDeleteConfirmation('');
|
|
toast.success('设计项目已删除');
|
|
} catch (error) {
|
|
setDeleteError(error instanceof Error ? error.message : '删除项目失败');
|
|
}
|
|
};
|
|
|
|
const chooseProject = (project: DesignWorkspaceSummary) => {
|
|
void selectProject(project.workspaceId);
|
|
navigate('/image-canvas');
|
|
};
|
|
|
|
if (sidebarCollapsed) {
|
|
return (
|
|
<div data-testid="sidebar-image-workspace" className="flex flex-col items-center gap-2 px-2 py-3">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="新建设计项目"
|
|
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',
|
|
)}
|
|
>
|
|
<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)}
|
|
>
|
|
{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>
|
|
</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)}
|
|
>
|
|
<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>
|
|
|
|
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'}</DialogTitle>
|
|
<DialogDescription>
|
|
创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<label htmlFor="design-project-name" className="text-sm font-medium text-foreground">
|
|
项目名称
|
|
</label>
|
|
<Input
|
|
id="design-project-name"
|
|
autoFocus
|
|
value={projectName}
|
|
placeholder="例如:秋季新品主视觉"
|
|
onChange={(event) => setProjectName(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !saving) void saveProject();
|
|
}}
|
|
/>
|
|
{dialogError && <p className="text-sm text-destructive">{dialogError}</p>}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="ghost" disabled={saving} onClick={() => setDialog(null)}>
|
|
取消
|
|
</Button>
|
|
<Button type="button" disabled={saving} onClick={() => void saveProject()}>
|
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
{dialog?.mode === 'rename' ? '保存名称' : '创建项目'}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={deleteTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && !deletingWorkspaceId) setDeleteTarget(null);
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>删除设计项目</DialogTitle>
|
|
<DialogDescription>
|
|
这会隐藏项目、设计表单、任务与资产。已另存到电脑的文件不受影响。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<label htmlFor="delete-design-project" className="text-sm font-medium text-foreground">
|
|
输入“{deleteTarget?.title ?? ''}”确认
|
|
</label>
|
|
<Input
|
|
id="delete-design-project"
|
|
value={deleteConfirmation}
|
|
onChange={(event) => setDeleteConfirmation(event.target.value)}
|
|
/>
|
|
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
disabled={Boolean(deletingWorkspaceId)}
|
|
onClick={() => setDeleteTarget(null)}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={Boolean(deletingWorkspaceId)}
|
|
onClick={() => void confirmDelete()}
|
|
>
|
|
{deletingWorkspaceId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
删除项目
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|