feat: 完善图像工作区与创作工具体验
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function MainLayout() {
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isChatWorkspace = location.pathname === '/opencode-chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
|
||||
|
||||
@@ -92,7 +93,8 @@ export function MainLayout() {
|
||||
<TitleBar
|
||||
integrated
|
||||
workspaceLayout={isChatWorkspace}
|
||||
overlay={isPaintingModule}
|
||||
overlay={isPaintingModule && !isPromptMuseum}
|
||||
pageTitle={isPromptMuseum ? '获取灵感' : undefined}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
onSidebarPeekChange={handleSidebarPeekChange}
|
||||
/>
|
||||
|
||||
@@ -212,6 +212,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const projectConfigPath = '/project-config';
|
||||
const visibleProjects = opencodeProjects;
|
||||
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
|
||||
@@ -639,7 +640,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
<div
|
||||
className={cn(
|
||||
'bg-transparent px-3 text-foreground',
|
||||
isPaintingModule && !sidebarCollapsed ? 'pb-2 pt-10' : 'py-2',
|
||||
isPaintingModule && !isPromptMuseum && !sidebarCollapsed ? 'pb-2 pt-10' : 'py-2',
|
||||
sidebarCollapsed && 'px-2',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,15 +21,16 @@ type TitleBarProps = {
|
||||
integrated?: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
overlay?: boolean;
|
||||
pageTitle?: string;
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
};
|
||||
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
const platform = window.electron?.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
// Linux keeps the native frame/title bar for better IME compatibility.
|
||||
@@ -37,13 +38,14 @@ export function TitleBar({ integrated = false, workspaceLayout = false, overlay
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
function ProductTitleBar({
|
||||
integrated,
|
||||
workspaceLayout = false,
|
||||
overlay = false,
|
||||
pageTitle,
|
||||
children,
|
||||
nativeTrafficLights = false,
|
||||
sidebarPeekOpen = false,
|
||||
@@ -52,15 +54,16 @@ function ProductTitleBar({
|
||||
integrated: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
overlay?: boolean;
|
||||
pageTitle?: string;
|
||||
children?: React.ReactNode;
|
||||
nativeTrafficLights?: boolean;
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
}) {
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
const activeProject = useOpencodeStore((state) => state.activeProject);
|
||||
const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground';
|
||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
const toggleSidebar = () => setSidebarCollapsed(!sidebarCollapsed);
|
||||
const handleSidebarPeekEnter = (source: SidebarPeekSource) => {
|
||||
if (sidebarCollapsed) onSidebarPeekChange?.(true, source);
|
||||
@@ -134,8 +137,9 @@ function ProductTitleBar({
|
||||
<div
|
||||
data-testid="product-titlebar"
|
||||
className={cn(
|
||||
'relative z-30 flex h-10 shrink-0 text-foreground',
|
||||
overlay && 'pointer-events-none absolute inset-x-0 top-0 z-[300] bg-transparent',
|
||||
overlay ? 'absolute' : 'relative',
|
||||
'z-30 flex h-10 shrink-0 text-foreground',
|
||||
overlay && 'pointer-events-none inset-x-0 top-0 z-[300] bg-transparent',
|
||||
(!nativeTrafficLights || !integrated) && 'drag-region',
|
||||
!workspaceLayout && !overlay && 'glass-surface',
|
||||
integrated && !overlay && (workspaceLayout ? 'bg-transparent' : sidebarTitlebarHidden ? 'bg-background' : 'bg-background/70'),
|
||||
@@ -155,13 +159,14 @@ function ProductTitleBar({
|
||||
: workspaceLayout
|
||||
? 'w-[256px] min-w-[256px] basis-[256px]'
|
||||
: 'w-64',
|
||||
sidebarTitlebarHidden
|
||||
? 'border-r-0 bg-background'
|
||||
: cn(
|
||||
sidebarPreviewTitlebarVisible ? 'sidebar-peek-surface' : 'sidebar-glass-surface',
|
||||
'border-r border-border/80',
|
||||
),
|
||||
overlay && 'pointer-events-none border-r-0 bg-transparent',
|
||||
overlay
|
||||
? 'pointer-events-none border-r-0 bg-transparent'
|
||||
: sidebarTitlebarHidden
|
||||
? 'border-r-0 bg-background'
|
||||
: cn(
|
||||
sidebarPreviewTitlebarVisible ? 'sidebar-peek-surface' : 'sidebar-glass-surface',
|
||||
'border-r border-border/80',
|
||||
),
|
||||
nativeTrafficLights && 'pl-[88px]',
|
||||
)}
|
||||
onPointerEnter={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekEnter('titlebar') : undefined}
|
||||
@@ -191,7 +196,14 @@ function ProductTitleBar({
|
||||
style={{ left: conversationSurfaceOffset }}
|
||||
/>
|
||||
) : null}
|
||||
{!workspaceLayout && !overlay ? (
|
||||
{pageTitle ? (
|
||||
<h1
|
||||
data-testid="titlebar-page-title"
|
||||
className="flex min-w-0 flex-1 items-center truncate px-3 text-sm font-semibold tracking-[-0.02em]"
|
||||
>
|
||||
{pageTitle}
|
||||
</h1>
|
||||
) : !workspaceLayout && !overlay ? (
|
||||
<div
|
||||
data-testid="titlebar-project-context"
|
||||
className={cn(
|
||||
@@ -220,7 +232,7 @@ function ProductTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -247,7 +259,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, sidebarPeekOpen
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange}>
|
||||
<ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange}>
|
||||
<div className="no-drag pointer-events-auto flex h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
|
||||
@@ -216,10 +216,6 @@ export function UpdateSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('updates.help')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user