Makelore 2.0 initial clean snapshot
This commit is contained in:
395
src/pages/Home/index.tsx
Normal file
395
src/pages/Home/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user