Makelore 2.0 initial clean snapshot

This commit is contained in:
inman committed 2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 files changed
+139782

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { useState, type ComponentType } from 'react';
import {
AppWindow,
Bot,
Code2,
LayoutTemplate,
MonitorSmartphone,
TerminalSquare,
} from 'lucide-react';
import { cn } from '@/lib/utils';
type TemplateProject = {
id: string;
label: string;
icon: ComponentType<{ className?: string }>;
};
const templateProjects: TemplateProject[] = [
{ id: 'agent-app', label: 'Agent App', icon: Bot },
{ id: 'web-app', label: 'Web App', icon: Code2 },
{ id: 'desktop-app', label: 'Desktop App', icon: AppWindow },
{ id: 'mobile-app', label: 'Mobile App', icon: MonitorSmartphone },
{ id: 'cli-tool', label: 'CLI Tool', icon: TerminalSquare },
];
export function TemplateProjectMenu() {
const [selectedTemplateId, setSelectedTemplateId] = useState(templateProjects[0]?.id ?? '');
return (
<nav
aria-label="Template projects"
data-testid="home-template-project-menu"
className="flex shrink-0 items-center gap-3 border-b bg-background px-4 py-2 sm:px-5"
>
<div className="hidden shrink-0 items-center gap-2 text-sm font-semibold text-muted-foreground sm:flex">
<LayoutTemplate className="h-4 w-4" />
<span>Templates</span>
</div>
<div className="flex min-w-0 flex-1 gap-2 overflow-x-auto">
{templateProjects.map((template) => {
const Icon = template.icon;
const selected = selectedTemplateId === template.id;
return (
<button
key={template.id}
type="button"
aria-label={template.label}
aria-pressed={selected}
onClick={() => setSelectedTemplateId(template.id)}
className={cn(
'inline-flex h-9 shrink-0 items-center gap-2 rounded-md border px-3 text-sm font-medium transition-colors',
selected
? 'border-primary/40 bg-primary text-primary-foreground'
: 'border-border bg-card text-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" />
<span>{template.label}</span>
</button>
);
})}
</div>
</nav>
);
}
+395
View File
@@ -0,0 +1,395 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Archive, Clock3, FolderKanban, FolderOpen, Plus, RotateCcw, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
CollaborationProject,
getStageName,
isProjectInactive,
useCollaborationProjectsStore,
} from '@/stores/collaboration-projects';
import { cn } from '@/lib/utils';
import { invokeIpc } from '@/lib/api-client';
type OpenDialogResult = {
canceled: boolean;
filePaths: string[];
};
function formatRelativeTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Date.now() - date.getTime();
const hours = Math.max(1, Math.floor(diff / (60 * 60 * 1000)));
if (hours < 24) return `${hours} 小时前`;
return `${Math.floor(hours / 24)} 天前`;
}
function ProjectCard({
project,
archived = false,
onOpen,
onArchive,
onRestore,
}: {
project: CollaborationProject;
archived?: boolean;
onOpen: () => void;
onArchive: () => void;
onRestore: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm transition-colors hover:border-primary/40">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold tracking-normal">{project.name}</h2>
{archived ? (
<Badge variant="secondary">归档</Badge>
) : null}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock3 className="h-3.5 w-3.5" />
{formatRelativeTime(project.lastActiveAt)}
</span>
<span>{getStageName(project.currentStage)}</span>
{project.workspacePath ? (
<span className="inline-flex min-w-0 items-center gap-1">
<FolderOpen className="h-3.5 w-3.5" />
<span className="max-w-[260px] truncate">{project.workspacePath}</span>
</span>
) : null}
</div>
</div>
<Button size="sm" onClick={onOpen}>
进入项目
</Button>
</div>
<div className="mt-4 rounded-md bg-surface-input px-3 py-2 text-sm text-foreground/80">
{project.lastResult}
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="text-xs text-muted-foreground">
交付物 {project.deliverables.length} 个 · 文件 {project.files.length} 个
</div>
{archived ? (
<Button variant="ghost" size="sm" onClick={onRestore}>
<RotateCcw className="mr-2 h-4 w-4" />
恢复
</Button>
) : (
<Button variant="ghost" size="sm" onClick={onArchive}>
<Archive className="mr-2 h-4 w-4" />
归档
</Button>
)}
</div>
</div>
);
}
export function Home() {
const navigate = useNavigate();
const projects = useCollaborationProjectsStore((state) => state.projects);
const initialized = useCollaborationProjectsStore((state) => state.initialized);
const init = useCollaborationProjectsStore((state) => state.init);
const createProject = useCollaborationProjectsStore((state) => state.createProject);
const archiveProject = useCollaborationProjectsStore((state) => state.archiveProject);
const restoreProject = useCollaborationProjectsStore((state) => state.restoreProject);
const touchProject = useCollaborationProjectsStore((state) => state.touchProject);
const [name, setName] = useState('');
const [workspacePath, setWorkspacePath] = useState('');
const [nameError, setNameError] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
init();
}, [init]);
const resetCreateForm = () => {
setName('');
setWorkspacePath('');
setNameError('');
};
const openCreateSheet = () => {
resetCreateForm();
setCreateOpen(true);
window.setTimeout(() => nameInputRef.current?.focus(), 50);
};
const cancelCreateSheet = () => {
resetCreateForm();
setCreateOpen(false);
};
useEffect(() => {
const handleOpenCreateProject = () => openCreateSheet();
window.addEventListener('niancode:create-project', handleOpenCreateProject);
return () => window.removeEventListener('niancode:create-project', handleOpenCreateProject);
}, []);
const visibleGroups = useMemo(() => {
const active = projects.filter((project) => !project.archivedAt && !isProjectInactive(project));
const inactive = projects.filter((project) => !project.archivedAt && isProjectInactive(project));
const archived = projects.filter((project) => Boolean(project.archivedAt));
return { active, inactive, archived };
}, [projects]);
const openProject = (project: CollaborationProject) => {
touchProject(project.id);
navigate(`/workbench/${project.id}`);
};
const submit = (event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) {
setNameError('请输入项目名称');
nameInputRef.current?.focus();
return;
}
const project = createProject(trimmed, workspacePath);
resetCreateForm();
setCreateOpen(false);
navigate(`/workbench/${project.id}`);
};
const chooseProjectFolder = async () => {
const electronApi = (window as Partial<Window>).electron;
if (electronApi?.ipcRenderer?.invoke) {
const result = await invokeIpc<OpenDialogResult>('dialog:open', {
properties: ['openDirectory'],
title: '选择项目储存地址',
});
if (!result.canceled && result.filePaths[0]) {
setWorkspacePath(result.filePaths[0]);
}
return;
}
folderInputRef.current?.click();
};
if (!initialized) {
return (
<main className="flex h-full items-center justify-center text-sm text-muted-foreground">
正在读取本地项目
</main>
);
}
return (
<main className="flex h-full min-h-0 -m-6 flex-col bg-background">
<div className="shrink-0 border-b bg-background px-6 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FolderKanban className="h-4 w-4" />
AI 项目协作平台
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-normal">项目导航</h1>
</div>
<Button
className="w-full shrink-0 lg:w-auto"
onClick={openCreateSheet}
>
<Plus className="mr-2 h-4 w-4" />
新建项目
</Button>
</div>
</div>
<Tabs defaultValue="running" className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 px-6 pt-5">
<TabsList>
<TabsTrigger value="running">进行中项目</TabsTrigger>
<TabsTrigger value="archived">归档项目</TabsTrigger>
</TabsList>
</div>
<TabsContent value="running" className="min-h-0 flex-1 overflow-auto px-6 pb-6">
{visibleGroups.active.length === 0 && visibleGroups.inactive.length === 0 ? (
<div className="mt-6 flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-card/40">
<div className="max-w-sm text-center">
<FolderKanban className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">还没有项目</p>
<p className="mt-1 text-sm text-muted-foreground">创建一个项目后,会直接进入阶段一:项目背景。</p>
<Button
className="mt-4"
size="sm"
onClick={openCreateSheet}
>
<Plus className="mr-2 h-4 w-4" />
新建项目
</Button>
</div>
</div>
) : (
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{visibleGroups.active.map((project) => (
<ProjectCard
key={project.id}
project={project}
onOpen={() => openProject(project)}
onArchive={() => archiveProject(project.id)}
onRestore={() => restoreProject(project.id)}
/>
))}
</div>
)}
{visibleGroups.inactive.length > 0 ? (
<div className={cn('mt-5', visibleGroups.active.length === 0 && 'mt-0')}>
<Button variant="outline" size="sm" onClick={() => setShowInactive(!showInactive)}>
{showInactive ? '收起不活跃项目' : `展开不活跃项目 (${visibleGroups.inactive.length})`}
</Button>
{showInactive ? (
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{visibleGroups.inactive.map((project) => (
<ProjectCard
key={project.id}
project={project}
onOpen={() => openProject(project)}
onArchive={() => archiveProject(project.id)}
onRestore={() => restoreProject(project.id)}
/>
))}
</div>
) : null}
</div>
) : null}
</TabsContent>
<TabsContent value="archived" className="min-h-0 flex-1 overflow-auto px-6 pb-6">
{visibleGroups.archived.length === 0 ? (
<div className="mt-6 flex min-h-[240px] items-center justify-center rounded-lg border border-dashed bg-card/40 text-sm text-muted-foreground">
暂无归档项目
</div>
) : (
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{visibleGroups.archived.map((project) => (
<ProjectCard
key={project.id}
project={project}
archived
onOpen={() => openProject(project)}
onArchive={() => archiveProject(project.id)}
onRestore={() => restoreProject(project.id)}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
<Sheet
open={createOpen}
onOpenChange={(open) => {
if (open) {
openCreateSheet();
} else {
cancelCreateSheet();
}
}}
>
<SheetContent className="flex w-[min(460px,92vw)] flex-col overflow-hidden p-0 sm:max-w-none">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</span>
新建项目
</SheetTitle>
<SheetDescription>
只需要先填写项目名称,其他信息会在阶段一继续补充。
</SheetDescription>
</SheetHeader>
<form onSubmit={submit} className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5">
<div>
<Label htmlFor="collaboration-project-name">项目名称</Label>
<Input
ref={nameInputRef}
id="collaboration-project-name"
value={name}
onChange={(event) => {
setName(event.target.value);
if (nameError) setNameError('');
}}
placeholder="例如:AI 项目协作平台 MVP"
className="mt-2"
/>
{nameError ? (
<p className="mt-2 text-sm text-destructive">{nameError}</p>
) : null}
</div>
<div>
<Label htmlFor="collaboration-project-path">项目储存地址</Label>
<div
id="collaboration-project-path"
className="mt-2 flex min-h-10 items-center gap-2 rounded-md border bg-surface-input px-3 py-2"
>
<span className={cn(
'min-w-0 flex-1 truncate text-sm',
workspacePath ? 'text-foreground' : 'text-muted-foreground',
)}>
{workspacePath || '选择本地文件夹作为项目储存地址'}
</span>
<Button type="button" variant="outline" size="sm" onClick={() => void chooseProjectFolder()}>
<FolderOpen className="mr-2 h-4 w-4" />
{workspacePath ? '修改' : '选择'}
</Button>
</div>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
{...({ webkitdirectory: '' } as Record<string, string>)}
onChange={(event) => {
const file = event.target.files?.[0];
const relativePath = file && 'webkitRelativePath' in file
? String((file as File & { webkitRelativePath?: string }).webkitRelativePath)
: '';
const folderName = relativePath.split('/')[0] || file?.name || '';
if (folderName) setWorkspacePath(folderName);
event.target.value = '';
}}
/>
<p className="mt-2 text-xs text-muted-foreground">
浏览器预览会记录文件夹名;桌面版会记录完整路径。
</p>
</div>
</div>
<div className="mt-auto flex shrink-0 justify-end gap-2 border-t bg-background px-6 py-4">
<Button type="button" variant="outline" onClick={cancelCreateSheet}>
取消
</Button>
<Button type="submit">
创建项目
</Button>
</div>
</form>
</SheetContent>
</Sheet>
</main>
);
}
export default Home;