feat: 完善图像工作区与创作工具体验
This commit is contained in:
1 parent
bfcb88cfef
commit
26b52d76e3
92 files changed
+16678
-2975
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user