merge: integrate marketplace and design v2 client

This commit is contained in:
2026-08-30 22:45:44 +08:00
43 changed files with 5662 additions and 14134 deletions

View File

@@ -1,21 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import {
ChevronDown,
ChevronRight,
FolderKanban,
Lightbulb,
Loader2,
MessageSquareText,
Pencil,
Plus,
RefreshCw,
Trash2,
X,
} from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DisclosureContent } from '@/components/ui/disclosure';
import {
Dialog,
DialogContent,
@@ -23,14 +18,12 @@ import {
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 {
DesignConversationSummary,
DesignWorkspaceSummary,
} from '../../../shared/image-workspace';
import type { DesignWorkspaceSummary } from '../../../shared/image-workspace';
type ImageWorkspaceSidebarProps = {
sidebarCollapsed: boolean;
@@ -40,18 +33,9 @@ type ProjectDialogState =
| { mode: 'create'; project: null }
| { mode: 'rename'; project: DesignWorkspaceSummary };
const VISIBLE_CONVERSATION_LIMIT = 5;
const CONVERSATION_PREVIEW_LIMIT = 28;
const PROJECT_ACTION_BUTTON_CLASS = 'relative z-10 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground pointer-events-none opacity-0 transition-colors duration-100 group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100';
const IMAGE_SIDEBAR_ACTION_CLASS = 'motion-press flex min-h-9 w-full items-center justify-start gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors duration-100 hover:bg-surface-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1';
function conversationTimestamp(conversation: DesignConversationSummary): number {
const timestamp = Date.parse(conversation.updatedAt);
return Number.isFinite(timestamp) ? timestamp : 0;
}
function formatConversationTime(timestamp: number): string {
if (!timestamp) return '';
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()) {
@@ -60,42 +44,20 @@ function formatConversationTime(timestamp: number): string {
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
}
function compactConversationPreview(preview: string): string {
const characters = Array.from(preview);
return characters.length > CONVERSATION_PREVIEW_LIMIT
? `${characters.slice(0, CONVERSATION_PREVIEW_LIMIT - 1).join('')}`
: preview;
}
function conversationPreview(conversation: DesignConversationSummary): string {
return conversation.latestMessagePreview?.trim()
|| conversation.brief.summary.trim()
|| conversation.title.trim()
|| '新会话';
}
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
const authenticated = useAuthStore((state) => state.isAuthenticated());
const location = useLocation();
const navigate = useNavigate();
const status = useImageWorkspaceStore((state) => state.status);
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId);
const workspace = useImageWorkspaceStore((state) => state.workspace);
const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation);
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 createConversation = useImageWorkspaceStore((state) => state.createConversation);
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const selectConversation = useImageWorkspaceStore((state) => state.selectConversation);
const [projectsOpen, setProjectsOpen] = useState(true);
const [expandedProjectIds, setExpandedProjectIds] = useState<Record<string, boolean>>({});
const [expandedConversationLists, setExpandedConversationLists] = useState<Record<string, boolean>>({});
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
const [dialogError, setDialogError] = useState<string | null>(null);
@@ -108,15 +70,6 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
if (status === 'idle' || (status === 'auth-required' && authenticated)) void load();
}, [authenticated, load, status]);
useEffect(() => {
if (!activeWorkspaceId) return;
setExpandedProjectIds((current) => (
current[activeWorkspaceId] === true
? current
: { ...current, [activeWorkspaceId]: true }
));
}, [activeWorkspaceId]);
useEffect(() => {
const openCreateDialog = () => {
setProjectName('');
@@ -127,6 +80,13 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
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);
@@ -139,46 +99,6 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
setDialog({ mode: 'rename', project });
};
const openDeleteDialog = (project: DesignWorkspaceSummary) => {
setDeleteConfirmation('');
setDeleteError(null);
setDeleteTarget(project);
};
const closeDialog = () => {
if (saving) return;
setDialog(null);
setDialogError(null);
};
const deletingProject = Boolean(
deleteTarget && deletingWorkspaceId === deleteTarget.workspaceId,
);
const deleteNameMatches = Boolean(
deleteTarget && deleteConfirmation.trim() === deleteTarget.title,
);
const closeDeleteDialog = () => {
if (deletingProject) return;
setDeleteTarget(null);
setDeleteConfirmation('');
setDeleteError(null);
};
const confirmDeleteProject = async () => {
if (!deleteTarget || !deleteNameMatches || deletingProject) return;
const target = deleteTarget;
setDeleteError(null);
try {
await deleteProject(target.workspaceId);
setDeleteTarget(null);
setDeleteConfirmation('');
toast.success(`已删除项目「${target.title}`);
} catch (error) {
setDeleteError(error instanceof Error ? error.message : String(error));
}
};
const saveProject = async () => {
const title = projectName.trim();
if (!title) {
@@ -190,479 +110,272 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
try {
if (dialog?.mode === 'rename') {
await renameProject(dialog.project.workspaceId, title);
toast.success('项目名称已更新');
} else {
await createProject(title);
navigate('/image-canvas');
}
setDialog(null);
navigate('/image-canvas');
} catch (error) {
setDialogError(error instanceof Error ? error.message : String(error));
setDialogError(error instanceof Error ? error.message : '保存项目失败');
} finally {
setSaving(false);
}
};
const handleSelectProject = (workspaceId: string) => {
setExpandedProjectIds((current) => ({ ...current, [workspaceId]: true }));
void selectProject(workspaceId);
navigate('/image-canvas');
};
const handleToggleProject = (workspaceId: string) => {
if (workspaceId !== activeWorkspaceId) {
handleSelectProject(workspaceId);
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteConfirmation !== deleteTarget.title) {
setDeleteError('请输入完整项目名称');
return;
}
setExpandedProjectIds((current) => ({
...current,
[workspaceId]: !(current[workspaceId] ?? true),
}));
};
const handleSelectConversation = (conversationId: string) => {
void selectConversation(conversationId);
navigate('/image-canvas');
};
const handleCreateConversation = async () => {
setDeleteError(null);
try {
await createConversation();
navigate('/image-canvas');
await deleteProject(deleteTarget.workspaceId);
setDeleteTarget(null);
setDeleteConfirmation('');
toast.success('设计项目已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
setDeleteError(error instanceof Error ? error.message : '删除项目失败');
}
};
const activeProjectConversations = useMemo(() => {
if (!workspace || workspace.workspaceId !== activeWorkspaceId) return [];
return [...workspace.conversations].sort(
(left, right) => conversationTimestamp(right) - conversationTimestamp(left),
);
}, [activeWorkspaceId, workspace]);
const chooseProject = (project: DesignWorkspaceSummary) => {
void selectProject(project.workspaceId);
navigate('/image-canvas');
};
const promptMuseumActive = location.pathname === '/image-prompts'
|| location.pathname.startsWith('/image-prompts/');
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>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="获取设计灵感"
onClick={() => navigate('/image-prompts')}
>
<Lightbulb className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div data-testid="sidebar-image-workspace" className="min-h-0 space-y-1 font-sans">
<Button
type="button"
variant="ghost"
data-testid="sidebar-open-image-prompt-museum"
className={cn(
IMAGE_SIDEBAR_ACTION_CLASS,
promptMuseumActive && 'bg-brand-soft',
sidebarCollapsed && 'justify-center px-0',
)}
aria-label="获取灵感"
title="获取灵感"
aria-current={promptMuseumActive ? 'page' : undefined}
onClick={() => navigate('/image-prompts')}
>
<Lightbulb className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '获取灵感'}
</Button>
<Button
type="button"
variant="ghost"
data-testid="sidebar-create-image-project"
className={cn(
IMAGE_SIDEBAR_ACTION_CLASS,
sidebarCollapsed && 'justify-center px-0',
)}
aria-label="新建设计项目"
title="新建设计项目"
onClick={openCreateDialog}
disabled={status !== 'ready'}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建设计项目'}
</Button>
<div className="mt-3">
<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"
onClick={() => setProjectsOpen((open) => !open)}
aria-expanded={projectsOpen}
aria-controls="sidebar-image-projects"
className={cn(
'motion-press flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground transition-colors hover:bg-surface-subtle hover:text-foreground',
sidebarCollapsed && 'justify-center px-0',
)}
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}
>
<FolderKanban className="h-4 w-4 shrink-0" />
{!sidebarCollapsed ? (
<>
<span className="min-w-0 flex-1 truncate"></span>
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
</>
) : null}
<Plus className="h-4 w-4 text-brand" />
</button>
<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 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={() => navigate('/image-prompts')}
>
<Lightbulb className="h-4 w-4" />
</button>
{!sidebarCollapsed ? (
<DisclosureContent
open={projectsOpen}
id="sidebar-image-projects"
data-testid="sidebar-image-projects"
className="mt-1"
innerClassName="space-y-1"
>
{status === 'idle' || status === 'loading' ? (
<div role="status" className="rounded-lg border border-dashed border-border/80 bg-background/60 px-3 py-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-brand" />
</div>
</div>
) : null}
{status === 'unavailable' || status === 'error' || status === 'auth-required' ? (
<div
data-testid="sidebar-image-workspace-unavailable"
className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-3 text-xs font-medium text-muted-foreground"
>
<p className="font-semibold text-foreground">AI </p>
{workspaceError ? <p className="mt-1 break-words">{workspaceError}</p> : null}
<button
type="button"
className="motion-press mt-2 flex items-center gap-1.5 rounded-md bg-transparent px-1.5 py-1.5 font-semibold text-foreground hover:bg-background"
onClick={() => void load()}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
) : null}
{status === 'ready' && bootstrap?.workspaces.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/80 bg-background/60 px-3 py-3 text-xs font-medium leading-5 text-muted-foreground">
<span className="font-semibold text-foreground"></span>
<span className="mt-1 block"> Agent </span>
</div>
) : null}
{status === 'ready' ? bootstrap?.workspaces.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
const projectExpanded = expandedProjectIds[project.workspaceId] ?? active;
const projectConversationsOpen = active && projectExpanded;
const conversations = active ? activeProjectConversations : [];
const visibleConversations = conversations.slice(0, VISIBLE_CONVERSATION_LIMIT);
const hiddenConversations = conversations.slice(VISIBLE_CONVERSATION_LIMIT);
const moreConversationsOpen = expandedConversationLists[project.workspaceId]
?? hiddenConversations.some((conversation) => (
conversation.conversationId === activeConversationId
));
const renderConversation = (conversation: DesignConversationSummary) => {
const fullPreview = conversationPreview(conversation);
const selected = conversation.conversationId === activeConversationId;
return (
<button
key={conversation.conversationId}
type="button"
data-testid={`sidebar-image-conversation-${conversation.conversationId}`}
aria-current={selected ? 'page' : undefined}
title={`${conversation.title}${fullPreview}`}
className={cn(
'group/image-conversation flex w-full min-w-0 items-center gap-1.5 rounded-lg px-1.5 py-1.5 text-left transition-colors duration-100',
selected
? 'bg-background text-foreground shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => handleSelectConversation(conversation.conversationId)}
>
<MessageSquareText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-4">
{compactConversationPreview(fullPreview)}
</span>
<span className="shrink-0 whitespace-nowrap text-[10px] text-muted-foreground">
{formatConversationTime(conversationTimestamp(conversation)) || '刚刚'}
</span>
</button>
);
};
return (
<section
key={project.workspaceId}
data-testid={`sidebar-image-project-${project.workspaceId}`}
className={cn(
'motion-press group/project relative overflow-hidden rounded-lg border px-2 py-1.5 text-left transition-colors duration-100',
active
? 'border-brand/35 bg-brand-soft shadow-soft before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-brand'
: 'border-transparent bg-transparent hover:border-border/70 hover:bg-surface-subtle',
)}
>
<div
className="relative flex min-h-9 min-w-0 items-center"
data-testid={`sidebar-image-project-header-${project.workspaceId}`}
>
<button
type="button"
aria-current={active ? 'page' : undefined}
className="flex min-w-0 flex-1 items-center gap-2 pr-28 text-left"
onClick={() => handleSelectProject(project.workspaceId)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-input text-foreground">
<FolderKanban className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{project.title}</span>
</button>
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 flex items-center gap-1">
<button
type="button"
aria-label={projectExpanded ? `收起 ${project.title} 的会话` : `展开 ${project.title} 的会话`}
title={projectExpanded ? `收起 ${project.title} 的会话` : `展开 ${project.title} 的会话`}
aria-expanded={active ? projectExpanded : undefined}
className={cn(PROJECT_ACTION_BUTTON_CLASS, 'hover:bg-background hover:text-foreground')}
onClick={() => handleToggleProject(project.workspaceId)}
>
<ChevronDown
data-testid={`sidebar-image-project-toggle-icon-${project.workspaceId}`}
className={cn('h-3.5 w-3.5', projectExpanded && 'rotate-180')}
/>
</button>
<button
type="button"
aria-label={`重命名 ${project.title}`}
className={cn(PROJECT_ACTION_BUTTON_CLASS, 'hover:bg-background hover:text-foreground')}
onClick={() => openRenameDialog(project)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label="新建设计会话"
title="新建设计会话"
aria-busy={creatingConversation}
className={cn(PROJECT_ACTION_BUTTON_CLASS, 'hover:bg-background hover:text-brand')}
disabled={creatingConversation || !workspace || workspace.workspaceId !== project.workspaceId}
onClick={() => void handleCreateConversation()}
>
{creatingConversation
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</button>
<button
type="button"
data-testid={`sidebar-delete-image-project-${project.workspaceId}`}
aria-label={`删除 ${project.title}`}
title={`删除 ${project.title}`}
className={cn(PROJECT_ACTION_BUTTON_CLASS, 'hover:bg-destructive/10 hover:text-destructive')}
onClick={() => openDeleteDialog(project)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<DisclosureContent
open={projectConversationsOpen}
data-testid={`sidebar-image-conversations-${project.workspaceId}`}
innerClassName={cn('px-1', projectConversationsOpen ? 'pb-1 pt-1' : 'h-0')}
>
<section aria-label={`${project.title}的会话列表`}>
{workspace?.workspaceId !== project.workspaceId ? (
<div role="status" className="flex items-center gap-2 px-1.5 py-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin text-brand" />
</div>
) : conversations.length === 0 ? (
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 px-2 py-2 text-left text-[10px] text-muted-foreground hover:bg-background"
disabled={creatingConversation}
onClick={() => void handleCreateConversation()}
>
<Plus className="h-3.5 w-3.5" />
</button>
) : (
<div className="mt-0.5 space-y-0.5">
{visibleConversations.map(renderConversation)}
{hiddenConversations.length > 0 ? (
<div className="pt-1">
<button
type="button"
className="flex w-full items-center justify-between px-1.5 py-1 text-left text-[10px] font-semibold text-muted-foreground"
aria-expanded={moreConversationsOpen}
onClick={() => setExpandedConversationLists((current) => ({
...current,
[project.workspaceId]: !moreConversationsOpen,
}))}
>
<span>{hiddenConversations.length}</span>
<ChevronRight className={cn('h-3.5 w-3.5', moreConversationsOpen && 'rotate-90')} />
</button>
<DisclosureContent
open={moreConversationsOpen}
data-testid={`sidebar-image-more-conversations-${project.workspaceId}`}
className="mt-0.5"
innerClassName="space-y-0.5"
>
{hiddenConversations.map(renderConversation)}
</DisclosureContent>
</div>
) : null}
</div>
)}
</section>
</DisclosureContent>
</section>
);
}) : null}
</DisclosureContent>
) : null}
</div>
{dialog ? (
<Dialog
open
onOpenChange={(open) => {
if (!open) closeDialog();
}}
>
<DialogContent
className="max-w-sm p-5"
onInteractOutside={(event) => {
if (saving) event.preventDefault();
}}
<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()}
>
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<DialogTitle className="text-xl">
{dialog.mode === 'create' ? '新建设计项目' : '重命名设计项目'}
</DialogTitle>
<DialogDescription className="mt-1 text-xs">
</DialogDescription>
</div>
<button
type="button"
aria-label="关闭项目对话框"
className="flex h-8 w-8 items-center justify-center rounded-md border border-foreground/15 bg-white"
onClick={closeDialog}
disabled={saving}
>
<X className="h-4 w-4" />
</button>
</DialogHeader>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
void saveProject();
}}
<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',
)}
>
<label className="block text-xs font-semibold" htmlFor="image-project-name"></label>
<input
id="image-project-name"
value={projectName}
maxLength={80}
onChange={(event) => {
setProjectName(event.target.value);
setDialogError(null);
}}
autoFocus
disabled={saving}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
/>
{dialogError ? <p role="alert" className="rounded-md bg-destructive/10 p-2 text-xs font-semibold">{dialogError}</p> : null}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={closeDialog} disabled={saving}></Button>
<Button
type="submit"
disabled={saving || !projectName.trim()}
className="border border-foreground/15 bg-brand-soft font-semibold text-foreground"
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{dialog.mode === 'create' ? '创建项目' : '保存名称'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
) : null}
{deleteTarget ? (
<Dialog
open
onOpenChange={(open) => {
if (!open) closeDeleteDialog();
}}
>
<DialogContent
data-testid="delete-image-project-dialog"
className="max-w-md p-5"
onInteractOutside={(event) => {
if (deletingProject) event.preventDefault();
}}
onEscapeKeyDown={(event) => {
if (deletingProject) event.preventDefault();
}}
>
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<DialogTitle className="text-xl"></DialogTitle>
<DialogDescription className="mt-1 text-xs leading-5">
{deleteTarget.title}访
</DialogDescription>
</div>
<button
type="button"
aria-label="关闭删除项目对话框"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-foreground/15 bg-white"
onClick={closeDeleteDialog}
disabled={deletingProject}
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)}
>
<X className="h-4 w-4" />
{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>
</DialogHeader>
<div className="mt-1 space-y-3">
<label className="block text-xs font-semibold" htmlFor="delete-image-project-name">
{deleteTarget.title}
</label>
<input
id="delete-image-project-name"
data-testid="delete-image-project-name-input"
value={deleteConfirmation}
maxLength={80}
autoComplete="off"
onChange={(event) => {
setDeleteConfirmation(event.target.value);
setDeleteError(null);
}}
autoFocus
disabled={deletingProject}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-60"
/>
{deleteConfirmation && !deleteNameMatches ? (
<p className="text-xs font-medium text-muted-foreground"></p>
) : null}
{deleteError ? (
<p role="alert" className="rounded-md bg-destructive/10 p-2 text-xs font-semibold text-destructive">
{deleteError}
</p>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={closeDeleteDialog} disabled={deletingProject}>
</Button>
<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"
data-testid="confirm-delete-image-project"
variant="destructive"
aria-busy={deletingProject}
disabled={deletingProject || !deleteNameMatches}
onClick={() => void confirmDeleteProject()}
aria-label={`重命名 ${project.title}`}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => openRenameDialog(project)}
>
{deletingProject ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{deletingProject ? '正在删除' : '确认删除项目'}
</Button>
<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>
</DialogContent>
</Dialog>
) : null}
);
})}
</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>
);
}