feat: 完善图像工作区与创作工具体验
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman committed 2026-08-16 14:08:27 +08:00
1 parent bfcb88cfef
commit 26b52d76e3
92 files changed
+16678 -2975

No files matched your search

+159 -2
View File
@@ -3,14 +3,16 @@ import {
ChevronDown,
ChevronRight,
FolderKanban,
Lightbulb,
Loader2,
MessageSquareText,
Pencil,
Plus,
RefreshCw,
Trash2,
X,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DisclosureContent } from '@/components/ui/disclosure';
@@ -41,6 +43,7 @@ type ProjectDialogState =
const VISIBLE_CONVERSATION_LIMIT = 5;
const CONVERSATION_PREVIEW_LIMIT = 28;
const PROJECT_ACTION_BUTTON_CLASS = 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground pointer-events-none opacity-0 transition-[opacity,background-color,color,transform] 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-[background-color,color,transform] 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 active:scale-[0.985]';
function conversationTimestamp(conversation: DesignConversationSummary): number {
const timestamp = Date.parse(conversation.updatedAt);
@@ -73,6 +76,7 @@ function conversationPreview(conversation: DesignConversationSummary): string {
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);
@@ -80,10 +84,12 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
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);
@@ -94,6 +100,9 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
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();
@@ -130,12 +139,46 @@ 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) {
@@ -197,17 +240,39 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
);
}, [activeWorkspaceId, workspace]);
const promptMuseumActive = location.pathname === '/image-prompts'
|| location.pathname.startsWith('/image-prompts/');
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(
'motion-press flex min-h-9 w-full items-center 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 hover:bg-surface-subtle active:scale-[0.985]',
IMAGE_SIDEBAR_ACTION_CLASS,
sidebarCollapsed && 'justify-center px-0',
)}
aria-label="新建设计项目"
title="新建设计项目"
onClick={openCreateDialog}
disabled={status !== 'ready'}
>
@@ -377,6 +442,16 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
? <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 active:scale-[0.96]')}
onClick={() => openDeleteDialog(project)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<DisclosureContent
@@ -506,6 +581,88 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
</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}
>
<X className="h-4 w-4" />
</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
type="button"
data-testid="confirm-delete-image-project"
variant="destructive"
aria-busy={deletingProject}
disabled={deletingProject || !deleteNameMatches}
onClick={() => void confirmDeleteProject()}
>
{deletingProject ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{deletingProject ? '正在删除' : '确认删除项目'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
) : null}
</div>
);
}