Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

395
src/pages/Home/index.tsx Normal file
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;