feat: complete project workspace tags and notes UI

This commit is contained in:
2026-07-21 00:46:09 +08:00
parent 54958382f1
commit c613d4c997
28 changed files with 1621 additions and 284 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Space, Switch, Typography } from '@arco-design/web-react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
@@ -8,6 +8,9 @@ export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectDraft = {
name: string
identifier: string
icon: string
background: string
description: string
}
@@ -15,6 +18,7 @@ export type TaskDraft = {
title: string
description: string
dueAt: string
tag: string
}
export type SourceDraft = {
@@ -37,6 +41,7 @@ export function ProjectActionModals({
onCreateTask,
onUploadSource,
onCreateCronPlan,
tagOptions,
}: {
activeModal: ProjectActionModal
loading: boolean
@@ -45,16 +50,17 @@ export function ProjectActionModals({
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '', 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: '', description: '' })
setTask({ title: '', description: '', dueAt: '' })
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', dueAt: '', tag: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
@@ -76,7 +82,19 @@ export function ProjectActionModals({
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
</label>
<label>
<Text></Text>
<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>
@@ -103,6 +121,19 @@ export function ProjectActionModals({
<Text></Text>
<Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
</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>

View File

@@ -1,5 +1,6 @@
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 { ProjectTasks } from './project-tasks'
@@ -35,6 +36,8 @@ export function ProjectChannelPage({
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} />

View File

@@ -13,7 +13,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<div className="project-channel-page project-cron-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Cron </Title>
<Title heading={4}></Title>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>

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

@@ -1,5 +1,6 @@
import { Button, Card, Space, Tag, Typography } from '@arco-design/web-react'
import { IconFile, IconFolder, IconPlus, IconSearch } from '@arco-design/web-react/icon'
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
@@ -22,31 +23,26 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
</Space>
</div>
<div className="folder-grid">
{folders.map((folder) => (
<Card className="folder-card" bordered key={folder}>
<Space>
<Tag color="arcoblue"><IconFolder /></Tag>
<Text>{folder}</Text>
</Space>
</Card>
))}
</div>
<ProjectFileGrid items={folderItems(folders)} onSelectItem={onSelectItem} />
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button className="data-row note-row" key={note.title} onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span>{note.updated}</span>
<span>{note.owner}</span>
</button>
))}
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>
</div>
)
}
function folderItems(names: string[]) {
return names.map((name) => ({
id: `folder-${name}`,
title: name,
type: 'folder',
updated: '文件夹',
owner: '',
source: 'folder',
}))
}

View File

@@ -1,43 +1,50 @@
import { useMemo } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCalendar, IconCheckCircleFill, IconClockCircle, IconEmail, IconFile, IconMore, IconRobot } from '@arco-design/web-react/icon'
import type { InboxItem, ProjectWorkspace } from './project-types'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace, TaskItem } from './project-types'
const { Row, Col } = Grid
const { Title, Text } = Typography
const { Title, Text, Paragraph } = Typography
export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
const { inbox, tasks, aiSessions, notes, cronJobs } = activeWorkspace
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 shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [
{ title: 'Inbox 待处理', value: inbox.length, delta: '实时接口', icon: <IconEmail />, color: 'arcoblue' },
{ title: '进行中的任务', value: tasks.filter((task) => !task.completed).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' },
{ title: '计划任务', value: cronJobs.length, delta: '实时接口', icon: <IconClockCircle />, color: 'orange' },
], [aiSessions.length, cronJobs.length, inbox.length, notes.length, tasks])
{ 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">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<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>
<Space>
<Button icon={<IconCalendar />}>2026-07-20</Button>
<Button icon={<IconMore />} />
</Space>
</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={24 / metrics.length} key={metric.title}>
<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">{metric.delta}</Text>
<Text className="metric-delta"></Text>
</div>
</Space>
</Card>
@@ -45,69 +52,40 @@ export function ProjectOverview({ activeWorkspace, onSelectItem }: { activeWorks
))}
</Row>
<QueueSection title={`Inbox 待处理(${inbox.length}`} items={inbox} onSelectItem={onSelectItem} />
<TaskSection tasks={tasks} onSelectItem={onSelectItem} />
<AISessionSection aiSessions={aiSessions} onSelectItem={onSelectItem} />
<TaskCardSection title={`进行中的任务(${runningTasks.length}`} tasks={runningTasks} emptyText="当前没有进行中的任务" onSelectItem={onSelectItem} />
<TaskCardSection title={`完成的任务(${completedTasks.length}`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} />
</div>
)
}
function QueueSection({ title, items, onSelectItem }: { title: string; items: InboxItem[]; onSelectItem: (title: string) => void }) {
function TaskCardSection({ title, tasks, emptyText, onSelectItem }: { title: string; tasks: TaskItem[]; emptyText: string; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<Card className="queue-section overview-task-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{items.map((item) => (
<button key={item.title} className="data-row" onClick={() => onSelectItem(item.title)}>
<span className="row-title"><IconFile /> {item.title}</span>
<span>{item.meta}</span>
<Tag>{item.tag}</Tag>
<span>{item.owner}</span>
<time>{item.time}</time>
</button>
))}
</Card>
)
}
function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{tasks.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.map((task) => (
<button key={task.title} className="data-row task-row" onClick={() => onSelectItem(task.title)}>
<span className="row-title"><IconCheckCircleFill /> {task.title}</span>
<Tag color="arcoblue">{task.tag}</Tag>
<span> {task.progress}</span>
<span> {task.owner}</span>
<time> {task.createdAt}</time>
</button>
))}
</Card>
)
}
function AISessionSection({ aiSessions, onSelectItem }: { aiSessions: ProjectWorkspace['aiSessions']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>AI {aiSessions.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{aiSessions.map((session) => (
<button key={session.title} className="data-row ai-row" onClick={() => onSelectItem(session.title)}>
<span className="row-title"><IconRobot /> {session.title}</span>
<span>{session.summary}</span>
<span>{session.owner}</span>
<time>{session.time}</time>
</button>
))}
{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={() => 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>
)
}
@@ -116,17 +94,27 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>5</Title>
<Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button>
</div>
{notes.map((note) => (
<button key={note.title} className="data-row note-row" onClick={() => onSelectItem(note.title)}>
<span className="row-title"><IconFile /> {note.title}</span>
<Tag>{note.type}</Tag>
<span> {note.updated}</span>
<span>{note.owner}</span>
</button>
))}
<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

@@ -1,6 +1,6 @@
import type { CSSProperties } from 'react'
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
import { IconCompass, IconDashboard, IconPlus } from '@arco-design/web-react/icon'
import type { Project, WorkbenchView } from './project-types'
const { Sider } = Layout
@@ -10,7 +10,7 @@ export function ProjectRail({
projects,
activeView,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectWorkspaceExplore,
onSelectProject,
onCreateProject,
}: {
@@ -18,7 +18,7 @@ export function ProjectRail({
projects: Project[]
activeView: WorkbenchView
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectWorkspaceExplore: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
@@ -32,11 +32,11 @@ export function ProjectRail({
title="工作台"
/>
<Button
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
icon={<IconEmail />}
onClick={onSelectWorkspaceInbox}
aria-label="Inbox"
title="Inbox"
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) => (

View File

@@ -1,9 +1,20 @@
import { Badge, Button, Divider, Layout, Space, Tag, Typography } from '@arco-design/web-react'
import { IconApps, IconDashboard, IconMore, IconSettings } from '@arco-design/web-react/icon'
import type { ChannelKey, ProjectWorkspace, WorkbenchView } from './project-types'
import { Fragment, useEffect, useState } from 'react'
import { Badge, Button, Divider, Input, Layout, Modal, Space, Tag, 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,
@@ -11,24 +22,57 @@ export function ProjectSidebar({
activeChannel,
onSelectChannel,
onSelectItem,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
activeChannel: ChannelKey
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onCreateProjectTag: (name: string) => 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,
})
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
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)
const submitTag = () => {
if (!tagName.trim()) return
onCreateProjectTag(tagName.trim())
setTagName('')
setTagModalOpen(false)
}
return (
<Sider className="channel-sidebar" width={248}>
<div className="project-title">
<Space>
{workspaceMode ? <IconApps /> : <IconDashboard />}
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" />
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div>
<div className="channel-list">
@@ -44,16 +88,24 @@ export function ProjectSidebar({
</button>
</>
) : (
channels.map((channel) => (
<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.count !== undefined ? <Badge count={channel.count} maxCount={999} /> : <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>
@@ -62,7 +114,20 @@ export function ProjectSidebar({
<Text className="section-title"></Text>
<Space wrap>
{activeWorkspace.tags.map((tag) => (
<Tag key={tag} color={tag === '全部' ? 'arcoblue' : 'gray'}>{tag}</Tag>
<Fragment key={tag}>
<Tag color={isAllTag(tag) ? 'arcoblue' : 'gray'}>{tag}</Tag>
{isAllTag(tag) && (
<Button
className="tag-create-button"
size="mini"
type="text"
icon={<IconPlus />}
onClick={() => setTagModalOpen(true)}
>
</Button>
)}
</Fragment>
))}
</Space>
</section>
@@ -78,6 +143,86 @@ export function ProjectSidebar({
))}
</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>
<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>
</Sider>
)
}
function isAllTag(tag: string) {
return tag === '全部' || tag === 'all'
}
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

@@ -2,12 +2,16 @@ import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
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

View File

@@ -1,4 +1,5 @@
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
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,
@@ -6,14 +7,44 @@ import {
IconFile,
IconRobot,
} from '@arco-design/web-react/icon'
import type { Project, ProjectWorkspace } from './projects/project-types'
import type { Project, ProjectWorkspace, TaskItem } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
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 unfinishedTasks = workspaces.flatMap((workspace) =>
const unfinishedTasks: WorkspaceTask[] = workspaces.flatMap((workspace) =>
workspace.tasks
.filter((task) => !task.completed)
.map((task) => ({
@@ -21,6 +52,28 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
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 tags = new Set<string>()
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
selectedWorkspace?.tags
.filter((tag) => tag !== 'all' && tag !== '全部')
.forEach((tag) => tags.add(tag))
if (draft.tag) tags.add(draft.tag)
return Array.from(tags)
}, [draft.projectId, draft.tag, workspaces])
useEffect(() => {
if (!editingTask) return
setDraft({
title: editingTask.title,
summary: editingTask.summary,
projectId: editingTask.project.id,
tag: editingTask.tag,
completed: editingTask.completed,
})
}, [editingTask])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const urgentProjects = projects.filter((project) => project.urgent).length
@@ -65,7 +118,7 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
{unfinishedTasks.length ? (
<div className="workspace-task-list">
{unfinishedTasks.map((task) => (
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
<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 />
@@ -87,6 +140,62 @@ export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectW
<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 value={draft.projectId} onChange={(projectId) => setDraft((value) => ({ ...value, projectId }))}>
{projects.map((project) => (
<Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>
))}
</Select>
</label>
<label>
<Text></Text>
<Select
allowCreate
value={draft.tag}
placeholder="选择或输入标签"
onChange={(tag) => setDraft((value) => ({ ...value, 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,116 @@
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconBook, IconCompass, IconLink, IconLoop, IconRefresh, IconStar } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
const rssSources = [
{ name: '产品更新', url: 'https://example.com/product.xml', status: '运行中', count: 18, color: 'arcoblue' },
{ name: '行业情报', url: 'https://example.com/market.xml', status: '运行中', count: 24, color: 'green' },
{ name: '技术博客', url: 'https://example.com/engineering.xml', status: '待同步', count: 9, color: 'orange' },
]
export function WorkspaceExplorePage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
const articles = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
const selected = articles[0]
const pendingCount = articles.filter((item) => item.status === 'open').length
const sourceCount = rssSources.length
return (
<div className="workspace-explore-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary"> RSS 线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}> RSS</Button>
<Button type="primary" icon={<IconLink />}></Button>
</Space>
</div>
<Row gutter={12}>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="arcoblue">{sourceCount}</Tag><Text>RSS </Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="red">{pendingCount}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="green">{articles.length}</Tag><Text></Text></Space>
</Card>
</Col>
</Row>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>RSS </Title>
<Button type="text" size="mini"></Button>
</div>
{rssSources.map((source) => (
<button className="data-row rss-source-row" key={source.url} onClick={() => onSelectItem(source.name)}>
<span className="row-title"><IconLink /> {source.name}</span>
<Tag color={source.color}>{source.status}</Tag>
<span>{source.url}</span>
<span>{source.count} </span>
</button>
))}
</Card>
{articles.length ? (
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{articles.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={`${item.project.id}-${item.id}`}
onClick={() => onSelectItem(item.title)}
>
<span><IconCompass /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconLoop />} />
</Space>
</div>
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
<Paragraph>{selected.summary || '暂无采集正文'}</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button icon={<IconBook />}></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无 RSS 采集条目" />
</Card>
)}
</div>
)
}

View File

@@ -1,9 +1,9 @@
import { Layout } from '@arco-design/web-react'
import { WorkspacePage } from './workspace-body'
import { WorkspaceInboxPage } from './workspace-inbox'
import { WorkspacePage, type WorkspaceTaskUpdate } from './workspace-body'
import { WorkspaceExplorePage } from './workspace-explore'
import { ProjectChannelPage } from './projects/project-channel-page'
import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar } from './projects/project-sidebar'
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'
@@ -19,7 +19,7 @@ export function ProjectPage({
theme,
onSelectProject,
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectWorkspaceExplore,
onSelectChannel,
onSelectItem,
onOpenTask,
@@ -29,6 +29,9 @@ export function ProjectPage({
onCreateTask,
onUploadSource,
onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -38,7 +41,7 @@ export function ProjectPage({
theme: Theme
onSelectProject: (project: Project) => void
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectWorkspaceExplore: () => void
onSelectChannel: (key: ChannelKey) => void
onSelectItem: (title: string) => void
onOpenTask: (project: Project, taskID: string) => void
@@ -48,6 +51,9 @@ export function ProjectPage({
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)
@@ -62,7 +68,7 @@ export function ProjectPage({
projects={projects}
activeView={activeView}
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
onSelectWorkspaceExplore={onSelectWorkspaceExplore}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
@@ -74,14 +80,16 @@ export function ProjectPage({
activeChannel={activeChannel}
onSelectChannel={onSelectChannel}
onSelectItem={onSelectItem}
onUpdateProject={onUpdateProject}
onCreateProjectTag={onCreateProjectTag}
/>
)}
<Content className="stage">
{activeView === 'workspace' ? (
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
) : activeView === 'workspace-inbox' ? (
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} onUpdateTask={onUpdateWorkspaceTask} />
) : activeView === 'workspace-explore' ? (
<WorkspaceExplorePage workspaces={workspaces} onSelectItem={onSelectItem} />
) : (
<ProjectChannelPage
activeChannel={activeChannel}

View File

@@ -1,95 +0,0 @@
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './projects/project-types'
const { Row, Col } = Grid
const { Title, Text, Paragraph } = Typography
export function WorkspaceInboxPage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
const messages = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
const selected = messages[0]
const openCount = messages.filter((item) => item.status === 'open').length
const handledCount = messages.length - openCount
return (
<div className="workspace-inbox-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}>Inbox</Title>
<Text type="secondary">线</Text>
</div>
<Space>
<Button icon={<IconRefresh />}></Button>
<Button type="primary" icon={<IconArchive />}></Button>
</Space>
</div>
<Row gutter={12}>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="red">{openCount}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="arcoblue">{messages.length}</Tag><Text></Text></Space>
</Card>
</Col>
<Col span={8}>
<Card className="compact-card" bordered>
<Space><Tag color="green">{handledCount}</Tag><Text></Text></Space>
</Card>
</Col>
</Row>
{messages.length ? (
<div className="mail-layout">
<Card className="mail-list queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
{messages.map((item, index) => (
<button
className={index === 0 ? 'mail-item active' : 'mail-item'}
key={`${item.project.id}-${item.id}`}
onClick={() => onSelectItem(item.title)}
>
<span><IconEmail /> {item.title}</span>
<Text type="secondary">{item.meta}</Text>
<div>
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
<time>{item.time}</time>
</div>
</button>
))}
</Card>
<Card className="mail-detail queue-section" bordered>
<div className="section-header">
<Title heading={6}>{selected.title}</Title>
<Space>
<Button type="text" icon={<IconStar />} />
<Button type="text" icon={<IconSend />} />
</Space>
</div>
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
<Paragraph>{selected.summary || '暂无消息正文'}</Paragraph>
<div className="reply-box">
<Text type="secondary"></Text>
<Space>
<Button></Button>
<Button></Button>
<Button type="primary"></Button>
</Space>
</div>
</Card>
</div>
) : (
<Card className="queue-section" bordered>
<Empty description="暂无外部消息" />
</Card>
)}
</div>
)
}