feat: simplify code project creation

This commit is contained in:
inman
2026-09-07 10:06:37 +08:00
parent f8eee430f4
commit af13aca003
15 changed files with 351 additions and 486 deletions

View File

@@ -2,15 +2,11 @@
* Main Layout Component
* TitleBar at top, then sidebar + content below.
*/
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Outlet, useLocation } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LockKeyhole, Settings2 } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { TitleBar } from './TitleBar';
import { Button } from '@/components/ui/button';
import { getAiModuleForPath } from '@/lib/ai-modules';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { cn } from '@/lib/utils';
import type { SidebarPeekSource } from './sidebar-peek';
@@ -18,12 +14,8 @@ import type { SidebarPeekSource } from './sidebar-peek';
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
export function MainLayout() {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
const load = useProjectConfigStore((state) => state.load);
const location = useLocation();
const navigate = useNavigate();
const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false);
const sidebarPeekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sidebarPeekSourcesRef = useRef<Record<SidebarPeekSource, boolean>>({
@@ -33,13 +25,9 @@ export function MainLayout() {
titlebar: false,
});
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 === '/chat';
const isInitializationSafeRoute = location.pathname === '/project-config'
|| location.pathname === '/plugins'
|| !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
@@ -84,11 +72,6 @@ export function MainLayout() {
if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current);
}, []);
useEffect(() => {
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
}, [activeProject, isProgrammingModule, load]);
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
return (
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
@@ -117,16 +100,6 @@ export function MainLayout() {
)}
>
<Outlet />
{initializationBlocked ? (
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
<LockKeyhole className="mx-auto h-10 w-10" />
<h1 className="mt-4 text-2xl font-semibold">项目尚未初始化</h1>
<p className="mt-3 text-sm font-medium text-muted-foreground">请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。</p>
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" />打开项目配置</Button>
</div>
</div>
) : null}
</main>
</div>
</div>

View File

@@ -48,11 +48,8 @@ import { useSettingsStore } from '@/stores/settings';
import { useAuthStore, type AuthUser } from '@/stores/auth';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProviderStore } from '@/stores/providers';
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
import type { CodingProjectSummary } from '@/types/coding-project';
import { useProjectConfigStore } from '@/stores/project-config';
import type { ProjectType } from '../../../shared/project-config';
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
import { toast } from 'sonner';
type OpenDialogResult = {
@@ -72,7 +69,6 @@ type ProjectEntryError = {
};
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
function getFolderName(pathValue: string): string {
const trimmed = pathValue.trim();
@@ -166,9 +162,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('interactive_ai_app');
const [newProjectIdentityKind, setNewProjectIdentityKind] = useState<ProjectIdentityKind>('create');
const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
@@ -186,7 +179,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const isPaintingModule = activeModule === 'painting';
const isRobotModule = activeModule === 'robot';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const projectConfigPath = '/project-config';
const visibleProjects = projects;
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
@@ -397,9 +389,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
@@ -407,9 +396,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('interactive_ai_app');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
};
@@ -436,9 +422,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
return;
}
await setActiveProject(project.id);
navigate(result.config.initialized
? '/chat'
: projectConfigPath);
navigate('/chat');
};
const readAndEnterProject = async (project: CodingProjectSummary) => {
@@ -462,20 +446,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setCreateProjectError('请选择项目路径');
return;
}
const boundProjectId = newProjectIdentityProjectId.trim();
if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
setCreateProjectError('请输入有效的项目 ID(小写 UUID)。');
return;
}
setCreatingProject(true);
setCreateProjectError(null);
try {
const project = await createProject({
projectType: newProjectType,
identity: newProjectIdentityKind === 'create'
? { kind: 'create' }
: { kind: 'bind', projectId: boundProjectId },
projectType: 'interactive_ai_app',
identity: { kind: 'create' },
...(newProjectDirectoryMode === 'create-child-directory'
? { parentPath: selectedPath, projectName }
: { projectPath: selectedPath }),
@@ -756,7 +732,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
<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">默认直接使用所选文件夹,也可以在所选位置新建下级文件夹。</DialogDescription>
<DialogDescription className="mt-1 text-xs">只需选择项目文件夹,其他设置会自动准备好。</DialogDescription>
</div>
<button
type="button"
@@ -775,120 +751,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
void confirmCreateProject();
}}
>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold">项目身份</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="create"
aria-label="创建新的项目 ID"
checked={newProjectIdentityKind === 'create'}
onChange={() => {
setNewProjectIdentityKind('create');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2">创建新的项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
由主进程生成一个新的小写 UUID,不会发起云端数据创建。
</span>
</label>
<label
className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
newProjectIdentityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}
>
<input
type="radio"
name="project-identity"
value="bind"
aria-label="绑定已有项目 ID"
checked={newProjectIdentityKind === 'bind'}
onChange={() => {
setNewProjectIdentityKind('bind');
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
<span className="ml-2">绑定已有项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
仅接受规范的小写 UUID;绑定不会把项目 ID 当作登录凭据。
</span>
</label>
</div>
{newProjectIdentityKind === 'bind' ? (
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
<label className="block text-xs font-semibold" htmlFor="new-project-identity-id">
已有项目 ID
</label>
<input
id="new-project-identity-id"
value={newProjectIdentityProjectId}
onChange={(event) => {
setNewProjectIdentityProjectId(event.target.value);
setCreateProjectError(null);
}}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
disabled={creatingProject}
/>
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
</p>
</div>
) : null}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold">项目类型</legend>
<div className="grid gap-2 sm:grid-cols-2">
{([
['interactive_ai_app', '交互式 AI 应用', '创建可交互、可发布的 AI 应用项目;进入对话后可用项目初始化 Skill 生成工程骨架。'],
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
] as const).map(([value, title, description]) => (
<label
key={value}
className={cn(
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft',
newProjectType === value
? 'ring-2 ring-brand/35 shadow-float'
: 'ring-1 ring-foreground/10 hover:shadow-float',
)}
>
<span className="flex items-center gap-2 text-sm font-semibold">
<input
type="radio"
name="project-type"
value={value}
aria-label={title}
checked={newProjectType === value}
onChange={() => {
setNewProjectType(value);
setCreateProjectError(null);
}}
disabled={creatingProject}
/>
{title}
</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
{description}
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold">项目目录方式</legend>
<div className="grid gap-2 sm:grid-cols-2">