Files
makelore/src/pages/Home/index.tsx
inman 80e8386fa6
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
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

396 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
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="surface-card motion-press rounded-2xl border border-border/70 bg-card p-4 shadow-none hover:border-brand/25 hover:shadow-soft">
<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 openCreateDialog = () => {
resetCreateForm();
setCreateOpen(true);
window.setTimeout(() => nameInputRef.current?.focus(), 50);
};
const cancelCreateDialog = () => {
resetCreateForm();
setCreateOpen(false);
};
useEffect(() => {
const handleOpenCreateProject = () => openCreateDialog();
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="glass-surface shrink-0 border-b border-border/70 bg-background/80 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={openCreateDialog}
>
<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="surface-card mt-6 flex min-h-[280px] items-center justify-center rounded-2xl border border-dashed border-border 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={openCreateDialog}
>
<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>
<Dialog
open={createOpen}
onOpenChange={(open) => {
if (open) {
openCreateDialog();
} else {
cancelCreateDialog();
}
}}
>
<DialogContent className="flex max-h-[calc(100vh-2rem)] w-[min(460px,92vw)] max-w-none flex-col overflow-hidden p-0 sm:max-w-lg">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle 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>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<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={cancelCreateDialog}>
</Button>
<Button type="submit">
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</main>
);
}
export default Home;