chore: rename web client to web_v1

This commit is contained in:
2026-07-21 11:24:27 +08:00
parent 465e7bea9b
commit ad6dcd6de4
101 changed files with 24 additions and 5537 deletions

View File

@@ -0,0 +1,147 @@
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
import {
IconBook,
IconCheckCircleFill,
IconCloseCircleFill,
IconLaunch,
IconLoading,
IconSafe,
IconUserGroup,
} from '@arco-design/web-react/icon'
const { Title, Text, Paragraph } = Typography
type ConnectionStatus = 'checking' | 'online' | 'offline'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150')
const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123')
const [status, setStatus] = useState<ConnectionStatus>('checking')
useEffect(() => {
const controller = new AbortController()
const timer = window.setTimeout(() => {
void checkServerStatus(server, controller.signal, setStatus)
}, 300)
return () => {
controller.abort()
window.clearTimeout(timer)
}
}, [server])
return (
<section className="login-screen">
<Card className="login-card" bordered>
<div className="login-brand-panel">
<div className="brand-lockup large">
<span className="brand-symbol">S</span>
<Title heading={2}>Agent</Title>
</div>
<Text className="login-slogan"> · </Text>
<Text type="secondary"> · · AI Agent</Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Space>
</div>
<div className="login-form-panel">
<Title heading={4}> SenlinAI</Title>
<Text type="secondary"></Text>
<Form layout="vertical" className="login-form">
<Form.Item label="服务器地址IP 或域名优先)">
<Input value={server} onChange={setServer} placeholder="例如https://10.0.0.15:8080" suffix={<IconLaunch />} />
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
</Form.Item>
<Form.Item label="账号">
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
</Form.Item>
<Form.Item label="密码">
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
</Form.Item>
<div className="login-row">
<Space size={8}>
<span className="check-dot" />
<Text></Text>
</Space>
<Button type="text" size="mini">?</Button>
</div>
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
</Button>
</Form>
</div>
<div className="login-value-panel">
<Title heading={3}> · · </Title>
<Paragraph>
SenlinAI
</Paragraph>
<ValuePoint icon={<IconSafe />} title="私有部署,数据自有" desc="所有数据与模型运行在您的环境中,安全可控。" />
<ValuePoint icon={<IconBook />} title="知识沉淀,结构清晰" desc="从收集到整理,形成可复用的团队知识库。" />
<ValuePoint icon={<IconUserGroup />} title="协同高效,聚焦成果" desc="围绕项目与任务协同,减少沟通成本,专注交付。" />
</div>
</Card>
</section>
)
}
function ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
return (
<div className={`connection-card ${status}`}>
{icon}
<div>
<Text className="connection-title">{title}</Text>
</div>
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
</Button>
</div>
)
}
async function checkServerStatus(
server: string,
signal: AbortSignal | undefined,
setStatus: (status: ConnectionStatus) => void,
) {
const baseUrl = normalizeBaseUrl(server)
setStatus('checking')
try {
const response = await fetch(`${baseUrl}/api/status`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const payload = await response.json() as { timestamp_ms?: number }
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
setStatus('online')
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
setStatus('offline')
}
}
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
return (
<div className="value-point">
<span>{icon}</span>
<div>
<Text className="value-title">{title}</Text>
<Text type="secondary">{desc}</Text>
</div>
</div>
)
}

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = {
name: string
identifier: string
icon: string
background: string
description: string
}
export type TaskDraft = {
title: string
description: string
tag: string
}
export type SourceDraft = {
title: string
file: File | null
}
export type CronDraft = {
title: string
schedule: string
enabled: boolean
nextRunAt: string
}
export function ProjectActionModals({
activeModal,
loading,
onClose,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
tagOptions,
}: {
activeModal: ProjectActionModal
loading: boolean
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => {
if (activeModal === null) {
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', tag: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
}, [activeModal])
return (
<>
<Modal
className="action-modal"
title="新建项目"
visible={activeModal === 'project'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateProject(project)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如A3 / EXP / DATA" value={project.identifier} onChange={(identifier) => setProject((draft) => ({ ...draft, identifier }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如A3 / 🚀 / compass" value={project.icon} onChange={(icon) => setProject((draft) => ({ ...draft, icon }))} />
</label>
<label>
<Text></Text>
<Input placeholder="例如:#165DFF 或背景图片 URL" value={project.background} onChange={(background) => setProject((draft) => ({ ...draft, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="项目目标、背景或协作说明" value={project.description} onChange={(description) => setProject((draft) => ({ ...draft, description }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建任务"
visible={activeModal === 'task'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateTask(task)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="补充背景、验收口径或下一步" value={task.description} onChange={(description) => setTask((draft) => ({ ...draft, description }))} />
</label>
<label>
<Text></Text>
<Select
allowCreate
placeholder="选择或输入标签"
value={task.tag}
onChange={(tag) => setTask((draft) => ({ ...draft, tag }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<input
className="native-file-input"
type="file"
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
/>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建计划任务"
visible={activeModal === 'cron'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateCronPlan(cron)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>Cron </Text>
<Input value={cron.schedule} onChange={(schedule) => setCron((draft) => ({ ...draft, schedule }))} />
</label>
<label>
<Text></Text>
<Input placeholder="2026-07-22T08:00:00Z可留空" value={cron.nextRunAt} onChange={(nextRunAt) => setCron((draft) => ({ ...draft, nextRunAt }))} />
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={cron.enabled} onChange={(enabled) => setCron((draft) => ({ ...draft, enabled }))} />
</label>
</Space>
</Modal>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { Avatar, Button, Card, Input, Space, Tag, Typography } from '@arco-design/web-react'
import { IconPlus, IconRobot, IconSend } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text, Paragraph } = Typography
const messages = [
{ role: 'user', text: '请基于当前项目资料,整理本周风险和推进建议。' },
{ role: 'assistant', text: '我建议优先处理导出问题、权限边界和 Q3 策略评审,并把客户反馈同步到工作计划。' },
{ role: 'user', text: '把建议拆成可执行任务。' },
]
export function ProjectAi({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { aiSessions, project } = activeWorkspace
const selected = aiSessions[0]
return (
<div className="project-channel-page project-ai-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>AI </Title>
<Text type="secondary">{project.name} AI session</Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<div className="ai-workspace">
<Card className="ai-session-list queue-section" bordered>
<div className="section-header">
<Title heading={6}>Session</Title>
<Tag color="purple">{aiSessions.length}</Tag>
</div>
{aiSessions.map((session, index) => (
<button
className={index === 0 ? 'ai-session active' : 'ai-session'}
key={session.title}
onClick={() => onSelectItem(session.title)}
>
<Text>{session.title}</Text>
<Text type="secondary">{session.summary}</Text>
<time>{session.time}</time>
</button>
))}
</Card>
<Card className="ai-chat-panel queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected?.title ?? '暂无会话'}</Title>
<Tag color="green"></Tag>
</div>
<div className="chat-thread">
{messages.map((message, index) => (
<div className={`chat-message ${message.role}`} key={`${message.role}-${index}`}>
<Avatar size={30}>{message.role === 'assistant' ? <IconRobot /> : '张'}</Avatar>
<Paragraph>{message.text}</Paragraph>
</div>
))}
</div>
<div className="chat-input">
<Input.TextArea placeholder="输入你的问题,结合项目上下文继续追问" autoSize={{ minRows: 3, maxRows: 4 }} />
<Space>
<Button></Button>
<Button type="primary" icon={<IconSend />}></Button>
</Space>
</div>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { ProjectAi } from './project-ai'
import { ProjectCron } from './project-cron'
import { ProjectNewChannel } from './project-new-channel'
import { ProjectNotes } from './project-notes'
import { ProjectOverview } from './project-overview'
import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, ProjectWorkspace } from './project-types'
export function ProjectChannelPage({
activeChannel,
activeWorkspace,
activeTaskID,
onSelectItem,
onOpenTask,
onCloseTask,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onCreateProjectTag,
onUpdateTask,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onSelectItem: (title: string) => void
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
switch (activeChannel) {
case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai':
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':
return <ProjectNewChannel activeWorkspace={activeWorkspace} />
case 'overview':
default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUpdateTask={onUpdateTask} />
}
}

View File

@@ -0,0 +1,60 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onCreateCronPlan: () => void }) {
const { cronJobs, project } = activeWorkspace
const enabledCount = cronJobs.filter((job) => job.enabled).length
const pausedCount = cronJobs.length - enabledCount
return (
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Space>
<Tag color="green">{enabledCount} </Tag>
<Tag color="gray">{pausedCount} </Tag>
</Space>
</div>
{cronJobs.map((job) => (
<div
className="data-row cron-row"
key={job.name}
role="button"
tabIndex={0}
onClick={() => onSelectItem(job.name)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') onSelectItem(job.name)
}}
>
<span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span>
<span>{job.lastRun}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div>
))}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
import type { NoteSource } from './project-types'
export type ProjectFileItem = NoteSource | {
id: string
title: string
type: string
updated: string
owner: string
source?: string
}
export function ProjectFileGrid({
items,
onSelectItem,
compact = false,
}: {
items: ProjectFileItem[]
onSelectItem: (title: string) => void
compact?: boolean
}) {
return (
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
{items.map((item) => {
const fileKind = inferFileKind(item)
return (
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
<span className="project-file-copy">
<span className="project-file-name">{item.title}</span>
<span className="project-file-type">{fileKind.label}</span>
<span className="project-file-meta">{fileKind.meta}</span>
</span>
</button>
)
})}
</div>
)
}
function inferFileKind(item: ProjectFileItem) {
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
if (value.includes('png') || value.includes('jpg') || value.includes('jpeg') || value.includes('图片')) return { kind: 'image', label: '图片文件', meta: item.updated }
if (value.includes('excel') || value.includes('xlsx') || value.includes('表')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
if (value.includes('word') || value.includes('doc') || value.includes('文档')) return { kind: 'doc', label: '文档', meta: item.updated }
if (value.includes('markdown') || value.includes('笔记')) return { kind: 'note', label: 'Markdown 笔记', meta: item.updated }
return { kind: 'file', label: item.type || '文件', meta: item.updated }
}
function fileIcon(kind: string) {
if (kind === 'folder') return <IconFolder />
if (kind === 'pdf') return <IconFilePdf />
if (kind === 'image') return <IconFileImage />
return <IconFile />
}

View File

@@ -0,0 +1,62 @@
import { Button, Card, Input, Select, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconLink, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
const { TextArea } = Input
export function ProjectNewChannel({ activeWorkspace }: { activeWorkspace: ProjectWorkspace }) {
return (
<div className="project-channel-page project-new-channel-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{activeWorkspace.project.name} </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
</div>
<Card className="queue-section channel-editor-card" bordered>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:客户研究频道" />
</label>
<label>
<Text></Text>
<Select defaultValue="link">
<Select.Option value="link"></Select.Option>
<Select.Option value="source"></Select.Option>
<Select.Option value="workflow"></Select.Option>
</Select>
</label>
<label>
<Text></Text>
<Input placeholder="例如link / user / file" prefix={<IconApps />} />
</label>
<label>
<Text></Text>
<Input placeholder="https://example.com/channel" prefix={<IconLink />} />
</label>
<label>
<Text></Text>
<TextArea rows={4} placeholder="频道用途、维护规则或采集说明" />
</label>
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="gray"></Tag>
</div>
<Space wrap>
<Tag color="arcoblue"></Tag>
<Tag color="green"></Tag>
<Tag color="orange">RSS/</Tag>
<Tag color="purple">AI </Tag>
</Space>
</Card>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
const { notes, project } = activeWorkspace
return (
<div className="project-channel-page project-notes-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} 线</Text>
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>
</div>
)
}

View File

@@ -0,0 +1,151 @@
import { useMemo, useState } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function ProjectOverview({
activeWorkspace,
onSelectItem,
onUpdateTask,
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
return (
<div className="overview-page project-overview-page">
<section className="project-overview-hero" style={{ background: projectHeroBackground(project.background, project.color) }}>
<div className="project-overview-identity">
<span className="project-overview-icon">{project.short}</span>
<div>
<Title heading={3}>{project.name}</Title>
<Paragraph ellipsis={{ rows: 2 }}>{project.description || '暂无简介'}</Paragraph>
</div>
</div>
<div className="project-overview-share">
<Text> URL</Text>
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
</div>
</section>
<Row gutter={12} className="metric-row">
{metrics.map((metric) => (
<Col span={6} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta"></Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<TaskCardSection title={`进行中的任务(${runningTasks.length}`} tasks={runningTasks} emptyText="当前没有进行中的任务" onEditTask={setEditingTask} />
<TaskCardSection title={`完成的任务(${completedTasks.length}`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} />
<ProjectTaskEditModal
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
/>
</div>
)
}
function TaskCardSection({
title,
tasks,
emptyText,
onSelectItem,
onEditTask,
}: {
title: string
tasks: TaskItem[]
emptyText: string
onSelectItem?: (title: string) => void
onEditTask?: (task: TaskItem) => void
}) {
return (
<Card className="queue-section overview-task-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.length === 0 ? (
<Text type="secondary">{emptyText}</Text>
) : (
<div className="overview-task-grid">
{tasks.map((task) => (
<button key={task.id} className={task.completed ? 'overview-task-card completed' : 'overview-task-card'} onClick={() => onEditTask ? onEditTask(task) : onSelectItem?.(task.title)}>
<span className="overview-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
<div className="overview-task-meta">
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
<span> {task.progress}</span>
<span> {task.createdAt}</span>
</div>
</button>
))}
</div>
)}
</Card>
)
}
function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card>
)
}
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback
if (value.startsWith('http')) {
return `linear-gradient(135deg, color-mix(in srgb, ${fallback} 78%, transparent), color-mix(in srgb, ${fallback} 34%, transparent)), url("${value.replaceAll('"', '%22')}") center / cover`
}
if (value.startsWith('linear-gradient') || value.startsWith('radial-gradient')) {
return value
}
return `linear-gradient(135deg, ${value}, color-mix(in srgb, ${value} 58%, white))`
}

View File

@@ -0,0 +1,58 @@
import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
export function ProjectRail({
activeProject,
projects,
activeView,
onSelectWorkspace,
onSelectWorkspaceExplore,
onSelectProject,
onCreateProject,
}: {
activeProject: Project
projects: Project[]
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectWorkspaceExplore: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
return (
<Sider className="project-rail" width={96}>
<Button
className={activeView === 'workspace' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconDashboard />}
onClick={onSelectWorkspace}
aria-label="工作台"
title="工作台"
/>
<Button
className={activeView === 'workspace-explore' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconCompass />}
onClick={onSelectWorkspaceExplore}
aria-label="探索"
title="探索"
/>
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
onClick={() => onSelectProject(project)}
style={{ '--project-color': project.color } as CSSProperties}
title={project.name}
>
{project.short}
</button>
</Badge>
))}
</Space>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={onCreateProject} />
</Sider>
)
}

View File

@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { Badge, Button, Divider, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
const { Sider } = Layout
const { Title, Text } = Typography
const { TextArea } = Input
export type ProjectSettingsUpdate = {
projectId: string
name: string
identifier: string
icon: string
background: string
description: string
}
export function ProjectSidebar({
activeView,
activeWorkspace,
activeChannel,
onSelectChannel,
onSelectItem,
onUpdateProject,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
}) {
const workspaceMode = activeView === 'workspace'
const channels = activeWorkspace.channels
const [settingsOpen, setSettingsOpen] = useState(false)
const [draft, setDraft] = useState({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
useEffect(() => {
if (!settingsOpen) return
setDraft({
name: activeWorkspace.project.name,
identifier: activeWorkspace.project.identifier,
icon: activeWorkspace.project.icon,
background: activeWorkspace.project.background,
description: activeWorkspace.project.description,
})
}, [activeWorkspace.project, settingsOpen])
const projectIcon = activeWorkspace.project.short || activeWorkspace.project.identifier || activeWorkspace.project.name.slice(0, 2)
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title">
<Space>
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div>
<div className="channel-list">
{workspaceMode ? (
<>
<button className="channel-button active">
<span className="channel-left"><IconDashboard /><Text></Text></span>
<Badge count={channels.length} />
</button>
<button className="channel-button">
<span className="channel-left"><IconApps /><Text></Text></span>
<IconMore />
</button>
</>
) : (
<>
{channels.flatMap((channel) => {
const items = [renderChannelButton(channel, activeChannel, onSelectChannel)]
if (channel.key === 'cron') {
items.push(
<button
key="new-channel"
className={activeChannel === 'new-channel' ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel('new-channel')}
>
<span className="channel-left"><IconPlus /><Text></Text></span>
<IconMore />
</button>,
)
}
return items
})}
</>
)}
</div>
<Divider />
<section className="recent-sessions">
<Text className="section-title"></Text>
{activeWorkspace.recentSessions.slice(0, 4).map((session) => (
<button key={session.id} onClick={() => onSelectItem(session.title)}>
<Text>{session.title}</Text>
<Text type="secondary">{session.time}</Text>
</button>
))}
</section>
<Modal
className="action-modal"
title="编辑项目"
visible={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => {
onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
</label>
<label>
<Text></Text>
<Input value={draft.identifier} onChange={(identifier) => setDraft((value) => ({ ...value, identifier }))} />
</label>
<label>
<Text></Text>
<Input value={draft.icon} onChange={(icon) => setDraft((value) => ({ ...value, icon }))} />
</label>
<label>
<Text></Text>
<Input value={draft.background} onChange={(background) => setDraft((value) => ({ ...value, background }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.description} onChange={(description) => setDraft((value) => ({ ...value, description }))} />
</label>
</Space>
</Modal>
</Sider>
)
}
function renderChannelButton(channel: ProjectChannel, activeChannel: ChannelKey, onSelectChannel: (key: ChannelKey) => void) {
return (
<button
key={channel.id}
className={activeChannel === channel.key ? 'channel-button active' : 'channel-button'}
onClick={() => onSelectChannel(channel.key)}
>
<span className="channel-left">{channel.icon}<Text>{channel.label}</Text></span>
{channel.key === 'overview' ? null : channel.count !== undefined ? <Badge className="channel-badge-muted" count={channel.count} maxCount={999} /> : <IconMore />}
</button>
)
}

View File

@@ -0,0 +1,47 @@
import { Avatar, Button, Layout, Space, Typography } from '@arco-design/web-react'
import {
IconApps,
IconCalendar,
IconCheckCircleFill,
IconMessage,
IconRobot,
IconSettings,
IconStorage,
IconThunderbolt,
IconUserGroup,
} from '@arco-design/web-react/icon'
const { Footer } = Layout
const { Text } = Typography
export function ProjectStatusbar() {
return (
<Footer className="statusbar">
<div className="status-user">
<Avatar size={26} style={{ backgroundColor: '#165DFF' }}></Avatar>
<div className="status-identity">
<Text className="status-name"></Text>
<span className="status-online-dot" aria-label="在线" title="在线" />
</div>
<Button type="text" icon={<IconUserGroup />}></Button>
<Button type="text" icon={<IconMessage />}></Button>
<Button type="text" icon={<IconSettings />}></Button>
</div>
<Space className="status-tasks" size={28}>
<span><IconCheckCircleFill /> </span>
<span><IconInteractionFallback /> 3 </span>
<span><IconRobot /> AI </span>
<span><IconThunderbolt /> 稿</span>
</Space>
<Space className="status-system" size={24}>
<span><IconStorage /> 152GB </span>
<span> <b></b></span>
<IconApps />
</Space>
</Footer>
)
}
function IconInteractionFallback() {
return <IconCalendar />
}

View File

@@ -0,0 +1,93 @@
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Text } = Typography
const { TextArea } = Input
export type ProjectTaskUpdate = {
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type TaskDraft = {
title: string
summary: string
tag: string
completed: boolean
}
export function ProjectTaskEditModal({
task,
workspace,
onClose,
onSubmit,
}: {
task: TaskItem | null
workspace: ProjectWorkspace
onClose: () => void
onSubmit: (update: ProjectTaskUpdate) => void
}) {
const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags])
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false })
useEffect(() => {
if (!task) return
setDraft({
title: task.title,
summary: task.summary,
tag: task.tag,
completed: task.completed,
})
}, [task])
return (
<Modal
className="action-modal"
title="编辑计划"
visible={Boolean(task)}
onCancel={onClose}
onOk={() => {
if (!task) return
onSubmit({
taskId: task.id,
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text></Text>
<Select
allowClear
value={draft.tag || undefined}
placeholder="不设置标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag: tag ?? '' }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
)
}

View File

@@ -0,0 +1,178 @@
import { useState } from 'react'
import { Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Title, Text, Paragraph } = Typography
export function ProjectTasks({
activeWorkspace,
activeTaskID,
onOpenTask,
onCloseTask,
onCreateTask,
onCreateProjectTag,
onUpdateTask,
}: {
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onSelectItem: (title: string) => void
onCreateTask: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
const { tasks, project, tags } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID)
const pendingTasks = tasks.filter((task) => !task.completed)
const completedTasks = tasks.filter((task) => task.completed)
const projectTags = tags.filter((tag) => tag !== 'all' && tag !== '全部')
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
if (activeTask) {
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
}
function submitTag() {
const nextTag = tagName.trim()
if (!nextTag) return
onCreateProjectTag(nextTag)
setTagName('')
setTagModalOpen(false)
}
return (
<div className="project-channel-page project-tasks-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateTask}></Button>
</div>
<Card className="queue-section task-tag-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button className="tag-create-button" size="mini" type="text" icon={<IconPlus />} onClick={() => setTagModalOpen(true)}></Button>
</div>
<Space wrap>
<Tag color="arcoblue"></Tag>
{projectTags.map((tag) => (
<Tag key={tag} color="gray">{tag}</Tag>
))}
</Space>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="arcoblue">{pendingTasks.length}</Tag>
</div>
<div className="task-plan-card-grid">
{pendingTasks.map((task) => (
<button className="task-plan-card" key={task.id} onClick={() => setEditingTask(task)}>
<span className="task-plan-title"><IconCheckCircleFill />{task.title}</span>
<Paragraph ellipsis={{ rows: 2 }} type="secondary">{task.summary || '暂无正文'}</Paragraph>
<div className="task-plan-meta">
<Tag color="arcoblue">{task.tag || '未设置标签'}</Tag>
<span><IconCalendar /> {task.createdAt}</span>
</div>
</button>
))}
{pendingTasks.length === 0 && <Text type="secondary"></Text>}
</div>
</Card>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="green">{completedTasks.length}</Tag>
</div>
{completedTasks.map((task) => (
<button className="completed-plan-row" key={task.id} onClick={() => onOpenTask(task.id)}>
<span className="completed-plan-main">
<Text className="completed-plan-title"><IconCheckCircle />{task.title}</Text>
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无正文'}</Text>
</span>
<span>{task.createdAt}</span>
<span>{task.completedAt || '未知'}</span>
<span><IconClockCircle /> {task.duration || '未知'}</span>
</button>
))}
{completedTasks.length === 0 && <Text type="secondary"></Text>}
</Card>
<Modal
className="action-modal"
title="新建标签"
visible={tagModalOpen}
onCancel={() => {
setTagModalOpen(false)
setTagName('')
}}
onOk={submitTag}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={submitTag} />
</label>
</Space>
</Modal>
<ProjectTaskEditModal
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
/>
</div>
)
}
function TaskDetail({ activeWorkspace, task, onBack }: { activeWorkspace: ProjectWorkspace; task: TaskItem; onBack: () => void }) {
const percent = Number.parseInt(task.progress, 10)
return (
<div className="project-channel-page project-task-detail-page overview-page">
<div className="overview-head">
<div>
<Button type="text" icon={<IconArrowLeft />} onClick={onBack}></Button>
<Title heading={4}>{task.title}</Title>
<Text type="secondary">{activeWorkspace.project.name} </Text>
</div>
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || '未设置标签'}</Tag>
</div>
<Card className="queue-section task-detail-card" bordered>
<Descriptions
colon=""
column={2}
data={[
{ label: '所属项目', value: activeWorkspace.project.name },
{ label: '负责人', value: task.owner },
{ label: '创建时间', value: task.createdAt },
{ label: '完成状态', value: task.completed ? '已完成' : '未完成' },
{ label: '完成时间', value: task.completedAt || '未完成' },
{ label: '耗时', value: task.duration || '未完成' },
]}
/>
<div className="task-detail-progress">
<Text type="secondary"></Text>
<Progress percent={Number.isNaN(percent) ? 0 : percent} />
</div>
<div className="task-detail-summary">
<Title heading={6}></Title>
<Text>{task.summary || '暂无任务说明'}</Text>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { Theme } from './project-types'
const { Header } = Layout
const { Text } = Typography
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
const desktop = isDesktopRuntime()
return (
<Header className="topbar">
<div className="brand-lockup">
<span className="brand-symbol">S</span>
<Text className="brand-name">SenlinAI</Text>
</div>
<div className="global-search" role="search">
<Input size="large" prefix={<IconSearch />} placeholder="搜索项目、频道、文档、任务、联系人..." />
<Button size="large" type="primary"></Button>
</div>
<Space className="topbar-actions">
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
{desktop && (
<>
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
</>
)}
<Button icon={<IconApps />} />
</Space>
</Header>
)
}
function isDesktopRuntime() {
return typeof window !== 'undefined' && '__TAURI__' in window
}
function DockIcon({ side }: { side: 'left' | 'right' }) {
return (
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
<span />
</span>
)
}

View File

@@ -0,0 +1,94 @@
import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
export type Project = {
id: string
name: string
identifier: string
icon: string
background: string
description: string
short: string
badge: number
urgent?: boolean
color: string
}
export type ProjectChannel = {
key: ChannelKey
id: string
label: string
count?: number
icon: ReactElement
url?: string
sortOrder: number
}
export type InboxItem = {
id: string
title: string
meta: string
owner: string
time: string
tag: string
summary: string
status: string
}
export type TaskItem = {
id: string
title: string
owner: string
progress: string
due: string
createdAt: string
completedAt: string
duration: string
tag: string
summary: string
completed: boolean
}
export type AISession = {
id: string
title: string
summary: string
owner: string
time: string
}
export type NoteSource = {
id: string
title: string
type: string
updated: string
owner: string
source: string
}
export type CronJob = {
id: string
name: string
expr: string
status: string
lastRun: string
nextRun: string
owner: string
enabled: boolean
}
export type ProjectWorkspace = {
project: Project
channels: ProjectChannel[]
tags: string[]
recentSessions: AISession[]
inbox: InboxItem[]
tasks: TaskItem[]
aiSessions: AISession[]
notes: NoteSource[]
cronJobs: CronJob[]
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconCalendar,
IconCheckCircleFill,
IconDashboard,
IconFile,
IconRobot,
} from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
export type WorkspaceTaskUpdate = {
originalProjectId: string
nextProjectId: string
taskId: string
title: string
summary: string
tag: string
completed: boolean
}
type WorkspaceTask = TaskItem & {
project: Project
}
type TaskDraft = {
title: string
summary: string
projectId: string
tag: string
completed: boolean
}
export function WorkspacePage({
workspaces,
onUpdateTask,
}: {
workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void
}) {
const projects = workspaces.map((workspace) => workspace.project)
const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces])
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
workspace.tasks
.filter((task) => !task.completed)
.map((task) => ({
...task,
project: workspace.project,
})),
)
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const tagOptions = useMemo(() => {
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
}, [draft.projectId, workspaces])
useEffect(() => {
if (!editingTask) return
setDraft({
title: editingTask.title,
summary: editingTask.summary,
projectId: editingTask.project.id,
tag: editingTask.tag,
completed: editingTask.completed,
})
}, [editingTask])
useEffect(() => {
if (!draft.tag || tagOptions.includes(draft.tag)) return
setDraft((value) => ({ ...value, tag: '' }))
}, [draft.tag, tagOptions])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '未完成计划', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
]
return (
<div className="workspace-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">AI </Text>
</div>
</div>
<Row gutter={12} className="metric-row">
{workspaceMetrics.map((metric) => (
<Col span={24 / workspaceMetrics.length} key={metric.title}>
<Card className="metric-card" bordered>
<Space align="start">
<Tag color={metric.color}>{metric.icon}</Tag>
<div>
<Text type="secondary">{metric.title}</Text>
<Title heading={3}>{metric.value}</Title>
<Text className="metric-delta">{metric.delta}</Text>
</div>
</Space>
</Card>
</Col>
))}
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Tag color="arcoblue">{unfinishedTasks.length}</Tag>
</div>
{unfinishedTasks.length ? (
<div className="workspace-task-list">
{unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => setEditingTask(task)}>
<div className="workspace-task-main">
<span className="workspace-task-title">
<IconCheckCircleFill />
{task.title}
</span>
<Paragraph ellipsis={{ rows: 2 }} type="secondary">
{task.summary || '暂无任务说明'}
</Paragraph>
</div>
<div className="workspace-task-meta">
<Tag color={task.project.urgent ? 'red' : 'arcoblue'}>{task.project.name}</Tag>
<Tag color="green">{task.tag || '-'}</Tag>
<span><IconCalendar /> {task.createdAt}</span>
</div>
</button>
))}
</div>
) : (
<Empty description="暂无未完成计划" />
)}
</Card>
<Modal
className="action-modal"
title="编辑任务"
visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)}
onOk={() => {
if (!editingTask) return
onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
}}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />
</label>
<label>
<Text></Text>
<TextArea rows={4} value={draft.summary} onChange={(summary) => setDraft((value) => ({ ...value, summary }))} />
</label>
<label>
<Text></Text>
<Select
showSearch
value={draft.projectId}
placeholder="选择项目"
onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}
>
{projectOptions.map((project) => (
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
))}
</Select>
</label>
<label>
<Text></Text>
<Select
allowClear
value={draft.tag || undefined}
placeholder="不设置标签"
onChange={(tag) => setDraft((value) => ({ ...value, tag: tag ?? '' }))}
>
{tagOptions.map((tag) => (
<Select.Option key={tag} value={tag}>{tag}</Select.Option>
))}
</Select>
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={draft.completed} checkedText="完成" uncheckedText="未完成" onChange={(completed) => setDraft((value) => ({ ...value, completed }))} />
</label>
</Space>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,223 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Button, Card, Empty, Grid, Space, Typography } from '@arco-design/web-react'
import {
IconBook,
IconCheckCircle,
IconCompass,
IconDelete,
IconEdit,
IconFile,
IconLink,
IconRefresh,
IconStar,
IconStorage,
} from '@arco-design/web-react/icon'
import type { InboxItem, Project, ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
type DataSourceID = 'all' | 'manual' | 'requirements' | 'architecture'
type ExploreArticle = InboxItem & {
project: Project
sourceID: DataSourceID
}
type DataSourceCard = {
id: DataSourceID
name: string
count: number
icon: ReactNode
color: string
}
const SOURCE_META: Record<DataSourceID, { name: string; icon: ReactNode; color: string }> = {
all: { name: '全部', icon: <IconStorage />, color: 'blue' },
manual: { name: '手动收集', icon: <IconCompass />, color: 'green' },
requirements: { name: '需求文档', icon: <IconFile />, color: 'orange' },
architecture: { name: '架构讨论', icon: <IconBook />, color: 'purple' },
}
export function WorkspaceExplorePage({
workspaces,
onSelectItem,
}: {
workspaces: ProjectWorkspace[]
onSelectItem: (title: string) => void
}) {
const articles = useMemo(
() =>
workspaces.flatMap((workspace) =>
workspace.inbox.map((item) => ({
...item,
project: workspace.project,
sourceID: detectSource(item),
})),
),
[workspaces],
)
const [activeSourceID, setActiveSourceID] = useState<DataSourceID>('all')
const filteredArticles = useMemo(
() => (activeSourceID === 'all' ? articles : articles.filter((article) => article.sourceID === activeSourceID)),
[activeSourceID, articles],
)
const sources = useMemo(() => dataSources(articles), [articles])
const [activeArticleID, setActiveArticleID] = useState<string | null>(filteredArticles[0]?.id ?? null)
useEffect(() => {
if (filteredArticles.length === 0) {
setActiveArticleID(null)
return
}
if (!activeArticleID || !filteredArticles.some((article) => article.id === activeArticleID)) {
setActiveArticleID(filteredArticles[0].id)
}
}, [activeArticleID, filteredArticles])
const selected = filteredArticles.find((article) => article.id === activeArticleID) ?? filteredArticles[0]
const activeSource = SOURCE_META[activeSourceID]
return (
<div className="workspace-explore-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"></Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconLink />}>
</Button>
</Space>
</div>
<Row gutter={10} className="explore-source-row">
{sources.map((source) => (
<Col span={6} key={source.id}>
<Card
className={activeSourceID === source.id ? 'compact-card explore-source-card active' : 'compact-card explore-source-card'}
bordered
onClick={() => setActiveSourceID(source.id)}
>
<span className={`explore-source-icon ${source.color}`}>{source.icon}</span>
<span className="explore-source-copy">
<Text className="explore-source-name">{source.name}</Text>
<Text type="secondary">{source.count} </Text>
</span>
{source.id !== 'all' ? (
<span className="explore-source-actions" onClick={(event) => event.stopPropagation()}>
<Button type="text" size="mini" icon={<IconEdit />} />
<Button type="text" size="mini" status="danger" icon={<IconDelete />} />
</span>
) : null}
</Card>
</Col>
))}
</Row>
{selected ? (
<section className="explore-reader-layout">
<Card className="explore-article-list queue-section" bordered>
<div className="explore-list-header">
<Title heading={5}>
<Space size={6}>
<span className={`explore-source-icon mini ${activeSource.color}`}>{activeSource.icon}</span>
<span>{activeSource.name}</span>
</Space>
</Title>
<Button type="text" icon={<IconRefresh />} />
</div>
<div className="explore-list-body">
{filteredArticles.map((article) => (
<button
key={`${article.project.id}-${article.id}`}
className={article.id === selected.id ? 'explore-article-item active' : 'explore-article-item'}
onClick={() => {
setActiveArticleID(article.id)
onSelectItem(article.title)
}}
>
<span className={`explore-source-logo ${SOURCE_META[article.sourceID].color}`}>
{sourceInitial(SOURCE_META[article.sourceID].name)}
</span>
<span className="explore-article-copy">
<Text type="secondary">
{SOURCE_META[article.sourceID].name} · {article.time}
</Text>
<Text className="explore-article-title">{article.title}</Text>
<Text className="explore-article-summary" type="secondary" ellipsis={{ showTooltip: true }}>
{article.summary || '暂无正文'}
</Text>
</span>
</button>
))}
</div>
</Card>
<Card className="explore-article-detail queue-section" bordered>
<div className="explore-detail-toolbar">
<Title heading={5}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconCheckCircle />} />
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconBook />} />
</Space>
</div>
<article className="explore-article-body">
<Space className="explore-article-meta" wrap>
<span className={`explore-source-logo small ${SOURCE_META[selected.sourceID].color}`}>
{sourceInitial(SOURCE_META[selected.sourceID].name)}
</span>
<Text type="secondary">{SOURCE_META[selected.sourceID].name}</Text>
<Text type="secondary">{selected.project.name}</Text>
<Text type="secondary">{selected.time}</Text>
</Space>
<Paragraph className="explore-article-content">{selected.summary || '暂无正文'}</Paragraph>
<blockquote>
线
</blockquote>
</article>
</Card>
</section>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无数据源文章" />
</Card>
)}
</div>
)
}
function dataSources(articles: ExploreArticle[]): DataSourceCard[] {
const counts = new Map<DataSourceID, number>([
['all', articles.length],
['manual', 0],
['requirements', 0],
['architecture', 0],
])
articles.forEach((article) => counts.set(article.sourceID, (counts.get(article.sourceID) ?? 0) + 1))
return (Object.keys(SOURCE_META) as DataSourceID[]).map((id) => ({
id,
...SOURCE_META[id],
count: counts.get(id) ?? 0,
}))
}
function detectSource(article: InboxItem): DataSourceID {
const text = `${article.title} ${article.meta} ${article.tag} ${article.summary}`
if (/架构|技术方案|系统设计|architecture/i.test(text)) {
return 'architecture'
}
if (/需求|PRD|产品文档|requirement/i.test(text)) {
return 'requirements'
}
return 'manual'
}
function sourceInitial(name: string) {
return name.trim().slice(0, 1) || '源'
}

View File

@@ -0,0 +1,126 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
import { WorkspaceExplorePage } from './workspace-explore'
import { ProjectChannelPage } from './projects/project-channel-page'
import type { ProjectTaskUpdate } from './projects/project-task-edit-modal'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
export function ProjectPage({
activeView,
activeWorkspace,
workspaces,
activeChannel,
activeTaskID,
theme,
onSelectProject,
onSelectWorkspace,
onSelectWorkspaceExplore,
onSelectChannel,
onSelectItem,
onOpenTask,
onCloseTask,
onToggleTheme,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
workspaces: ProjectWorkspace[]
activeChannel: ChannelKey
activeTaskID: string | null
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectWorkspaceExplore: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void
onCloseTask: () => void
onToggleTheme: () => void
onCreateProject: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => void
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
onUpdateWorkspaceTask({
originalProjectId: activeWorkspace.project.id,
nextProjectId: activeWorkspace.project.id,
taskId: update.taskId,
title: update.title,
summary: update.summary,
tag: update.tag,
completed: update.completed,
})
}
return (
<Layout className="workbench-shell">
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
<Layout className="workbench-main" hasSider>
<ProjectRail
activeProject={activeWorkspace.project}
projects={projects}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
{isProject && (
<ProjectSidebar
activeView={activeView}
activeWorkspace={activeWorkspace}
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
onUpdateProject={onUpdateProject}
/>
)}
<Content className="stage">
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-explore' ? (
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
) : (
<ProjectChannelPage
activeChannel={activeChannel}
activeWorkspace={activeWorkspace}
activeTaskID={activeTaskID}
onSelectItem={onSelectItem}
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
onCloseTask={onCloseTask}
onCreateTask={onCreateTask}
onUploadSource={onUploadSource}
onCreateCronPlan={onCreateCronPlan}
onCreateProjectTag={onCreateProjectTag}
onUpdateTask={updateActiveProjectTask}
/>
)}
</Content>
</Layout>
<ProjectStatusbar />
</Layout>
)
}