feat(desktop): add all-projects workbench home
This commit is contained in:
@@ -1,29 +1,30 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Message, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconDown, IconEmail, IconFile,
|
||||
IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
|
||||
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRight, IconRobot,
|
||||
IconSun, IconUser,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
|
||||
listAIExperts, login, setApiBaseUrl, updateCurrentUser, updateTask,
|
||||
uploadDocument,
|
||||
type ApiSession, type CurrentUser, type Expert, type Project, type Workspace, type WorkspaceTask,
|
||||
} from './api'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
const SESSION_KEY = 'senlin-app-session'
|
||||
type View = 'overview' | 'tasks' | 'inbox' | 'documents' | 'ai' | 'cron'
|
||||
type View = 'overview' | 'tasks' | 'documents' | 'ai' | 'cron'
|
||||
type WorkspaceMode = 'workbench' | 'explore'
|
||||
type SavedSession = ApiSession & { user?: CurrentUser }
|
||||
type TaskWithProject = WorkspaceTask & { projectName: string }
|
||||
|
||||
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'overview', label: '概况', icon: <IconApps /> },
|
||||
{ id: 'tasks', label: '计划', icon: <IconList /> },
|
||||
{ id: 'inbox', label: '收集箱', icon: <IconEmail /> },
|
||||
{ id: 'documents', label: '资料', icon: <IconFile /> },
|
||||
{ id: 'ai', label: 'AI', icon: <IconRobot /> },
|
||||
{ id: 'cron', label: '周期计划', icon: <IconClockCircle /> },
|
||||
{ id: 'cron', label: '计划任务', icon: <IconClockCircle /> },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
@@ -33,6 +34,7 @@ export function App() {
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [workspaces, setWorkspaces] = useState<Record<string, Workspace>>({})
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('workbench')
|
||||
const [view, setView] = useState<View>('overview')
|
||||
const [dark, setDark] = useState(false)
|
||||
const [loading, setLoading] = useState(Boolean(stored))
|
||||
@@ -90,7 +92,7 @@ export function App() {
|
||||
if (!session) return <Login onLogin={(next) => { setSession(next); setUser(next.user); persistSession(next) }} />
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${dark ? 'theme-dark' : ''}`}>
|
||||
<div className={`app-shell ${dark ? 'theme-dark' : ''} ${!projectId || workspaceMode === 'explore' ? 'no-rail' : ''}`}>
|
||||
<header className="app-header">
|
||||
<div className="app-brand" data-tauri-drag-region><img src="/senlinai-icon.svg" alt="" /><span>森林AI</span></div>
|
||||
<div className="header-actions">
|
||||
@@ -100,21 +102,23 @@ export function App() {
|
||||
</header>
|
||||
|
||||
<section className="project-strip" aria-label="项目列表">
|
||||
<button className={!projectId ? 'project-pill active' : 'project-pill'} onClick={() => setProjectId('')}><IconApps /><span>全部</span></button>
|
||||
<div className="project-scroller">
|
||||
{projects.map((project) => <button key={project.id} className={projectId === project.id ? 'project-pill active' : 'project-pill'} onClick={() => setProjectId(project.id)}><i style={{ background: project.background || '#165DFF' }}>{projectLabel(project)}</i><span>{project.name}</span></button>)}
|
||||
</div>
|
||||
<button className={workspaceMode === 'workbench' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('workbench')}><IconApps /><span>工作台</span></button>
|
||||
<button className={workspaceMode === 'explore' ? 'project-pill active' : 'project-pill'} onClick={() => setWorkspaceMode('explore')}><IconCompass /><span>探索</span></button>
|
||||
<Select className="project-select" value={projectId || 'all'} onChange={(value) => { setProjectId(value === 'all' ? '' : value); setWorkspaceMode('workbench') }} triggerElement={() => <button className="project-pill project-current"><i style={{ background: activeProject?.background || '#165DFF' }}>{activeProject ? projectLabel(activeProject) : <IconFolder />}</i><span>{title}</span><IconDown /></button>}>
|
||||
<Select.Option value="all">全部项目</Select.Option>
|
||||
{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Button type="text" size="mini" shape="circle" aria-label="上一个项目" icon={<IconLeft />} onClick={() => cycleProject(-1)} />
|
||||
<Button type="text" size="mini" shape="circle" aria-label="下一个项目" icon={<IconRight />} onClick={() => cycleProject(1)} />
|
||||
<Button type="primary" size="mini" shape="circle" aria-label="新建项目" icon={<IconPlus />} onClick={() => setProjectModalOpen(true)} />
|
||||
</section>
|
||||
|
||||
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
|
||||
<main className="app-main"><Spin loading={loading} block>{visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} /> : <Empty description="还没有项目,点击右上方加号创建一个项目" />}</Spin></main>
|
||||
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel projects={projects} onOpen={(id) => { setProjectId(id); setWorkspaceMode('workbench') }} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} onUpload={async (project, file) => { await uploadDocument(session, project, file); await loadWorkspaces() }} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
|
||||
|
||||
<aside className="channel-rail" aria-label="项目子菜单">
|
||||
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
|
||||
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
|
||||
</aside>
|
||||
</aside> : null}
|
||||
|
||||
<TaskEditor task={taskEditor} tags={visibleWorkspaces.flatMap((workspace) => workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => {
|
||||
if (!taskEditor) return
|
||||
@@ -140,7 +144,6 @@ export function App() {
|
||||
|
||||
function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[] }) {
|
||||
if (view === 'tasks' || view === 'overview') return <TaskBoard title={view === 'overview' ? '待办任务' : '工作计划'} subtitle={title} tasks={tasks} onEditTask={onEditTask} />
|
||||
if (view === 'inbox') return <InboxPanel title={title} workspaces={workspaces} />
|
||||
if (view === 'documents') return <DocumentPanel title={title} workspaces={workspaces} />
|
||||
if (view === 'ai') return <AIPanel title={title} workspaces={workspaces} experts={experts} onLoad={onLoadAI} />
|
||||
return <CronPanel title={title} workspaces={workspaces} />
|
||||
@@ -164,9 +167,23 @@ function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void
|
||||
return <button className={`task-row ${task.completed ? 'done' : ''}`} onClick={onClick}><span className="task-check">{task.completed ? <IconCheckCircleFill /> : <IconCheckCircle />}</span><span className="task-copy"><strong>{task.title}</strong><small>{task.summary || '暂无任务说明'}</small></span><span className="task-meta"><Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || task.projectName}</Tag><small>{formatDate(task.completedAt || task.createdAt)}</small></span></button>
|
||||
}
|
||||
|
||||
function InboxPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||||
const items = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
return <Panel title="收集箱" subtitle={title}>{items.map((item) => <article className="detail-row" key={item.id}><IconEmail /><div><strong>{item.title}</strong><p>{item.summary || item.source}</p></div><Tag>{item.projectName}</Tag></article>)}{!items.length && <Empty description="收集箱为空" />}</Panel>
|
||||
|
||||
function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject, onUpload }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void; onUpload: (projectId: string, file: File) => Promise<void> }) {
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [uploadProject, setUploadProject] = useState(projects[0]?.id ?? '')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
useEffect(() => { if (!projects.some((project) => project.id === uploadProject)) setUploadProject(projects[0]?.id ?? '') }, [projects, uploadProject])
|
||||
const pending = tasks.filter((task) => !task.completed)
|
||||
const latestFiles = workspaces.flatMap((workspace) => workspace.documents.map((document) => ({ ...document, projectName: workspace.project.name }))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
|
||||
const statItems = [
|
||||
{ label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' },
|
||||
{ label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' },
|
||||
]
|
||||
return <section className="content-page all-projects-home"><div className="content-heading"><div><Text type="secondary">工作台</Text><Title heading={4}>全部项目</Title></div></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><div className="quick-workspace-actions"><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}>创建项目</Button><Button icon={<IconFile />} disabled={!uploadProject} loading={uploading} onClick={() => fileInput.current?.click()}>上传文件</Button><Select value={uploadProject || undefined} placeholder="选择上传项目" onChange={setUploadProject}>{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}</Select><input ref={fileInput} className="hidden-file" type="file" onChange={async (event) => { const file = event.target.files?.[0]; if (!file || !uploadProject) return; setUploading(true); try { await onUpload(uploadProject, file); Message.success('文件已上传') } catch (error) { Message.error(errorMessage(error)) } finally { event.target.value = ''; setUploading(false) } }} /></div><TaskBoard title="待办任务" subtitle="全部项目" tasks={tasks} onEditTask={onEditTask} /><section className="latest-files"><div className="section-label"><strong>最新文件</strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件,上传一份项目资料开始吧" />}</div></section></section>
|
||||
}
|
||||
|
||||
function ExplorePanel({ projects, onOpen }: { projects: Project[]; onOpen: (id: string) => void }) {
|
||||
return <section className="content-page explore-page"><div className="content-heading"><div><Text type="secondary">探索</Text><Title heading={4}>项目空间</Title></div><Tag color="arcoblue">{projects.length} 个项目</Tag></div><div className="project-card-grid">{projects.map((project) => <button key={project.id} className="project-explore-card" onClick={() => onOpen(project.id)}><i style={{ background: project.background || '#165DFF' }}>{projectLabel(project)}</i><span><strong>{project.name}</strong><small>{project.description || project.identifier}</small></span><IconRight /></button>)}{!projects.length && <Empty description="还没有项目" />}</div></section>
|
||||
}
|
||||
|
||||
function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||||
@@ -182,7 +199,7 @@ function AIPanel({ title, workspaces, experts, onLoad }: { title: string; worksp
|
||||
|
||||
function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {
|
||||
const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name })))
|
||||
return <Panel title="周期计划" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无周期计划" />}</Panel>
|
||||
return <Panel title="计划任务" subtitle={title}>{plans.map((item) => <article className="detail-row" key={item.id}><IconClockCircle /><div><strong>{item.title}</strong><p>{item.schedule} · {item.enabled ? '已启用' : '已停用'}</p></div><Tag>{item.projectName}</Tag></article>)}{!plans.length && <Empty description="暂无计划任务" />}</Panel>
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return <section className="content-page"><div className="content-heading"><div><Text type="secondary">{subtitle}</Text><Title heading={4}>{title}</Title></div></div><div className="detail-list">{children}</div></section> }
|
||||
|
||||
Reference in New Issue
Block a user