feat: 完善图像工作区与创作工具体验
Some checks failed
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
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

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>
);
}

View File

@@ -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}
/>

View File

@@ -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',
)}
>

View File

@@ -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}

View File

@@ -216,10 +216,6 @@ export function UpdateSettings() {
</div>
)}
{/* Help Text */}
<p className="text-xs text-muted-foreground">
{t('updates.help')}
</p>
</div>
);
}