merge: integrate marketplace and design v2 client
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user