chore: rename web client to web_v1
This commit is contained in:
147
apps/web_v1/src/pages/login.tsx
Normal file
147
apps/web_v1/src/pages/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
188
apps/web_v1/src/pages/projects/project-action-modals.tsx
Normal file
188
apps/web_v1/src/pages/projects/project-action-modals.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
70
apps/web_v1/src/pages/projects/project-ai.tsx
Normal file
70
apps/web_v1/src/pages/projects/project-ai.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/web_v1/src/pages/projects/project-channel-page.tsx
Normal file
50
apps/web_v1/src/pages/projects/project-channel-page.tsx
Normal 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} />
|
||||
}
|
||||
}
|
||||
60
apps/web_v1/src/pages/projects/project-cron.tsx
Normal file
60
apps/web_v1/src/pages/projects/project-cron.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/web_v1/src/pages/projects/project-file-grid.tsx
Normal file
57
apps/web_v1/src/pages/projects/project-file-grid.tsx
Normal 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 />
|
||||
}
|
||||
62
apps/web_v1/src/pages/projects/project-new-channel.tsx
Normal file
62
apps/web_v1/src/pages/projects/project-new-channel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/web_v1/src/pages/projects/project-notes.tsx
Normal file
33
apps/web_v1/src/pages/projects/project-notes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
apps/web_v1/src/pages/projects/project-overview.tsx
Normal file
151
apps/web_v1/src/pages/projects/project-overview.tsx
Normal 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))`
|
||||
}
|
||||
58
apps/web_v1/src/pages/projects/project-rail.tsx
Normal file
58
apps/web_v1/src/pages/projects/project-rail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
apps/web_v1/src/pages/projects/project-sidebar.tsx
Normal file
167
apps/web_v1/src/pages/projects/project-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
apps/web_v1/src/pages/projects/project-statusbar.tsx
Normal file
47
apps/web_v1/src/pages/projects/project-statusbar.tsx
Normal 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 />
|
||||
}
|
||||
93
apps/web_v1/src/pages/projects/project-task-edit-modal.tsx
Normal file
93
apps/web_v1/src/pages/projects/project-task-edit-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
apps/web_v1/src/pages/projects/project-tasks.tsx
Normal file
178
apps/web_v1/src/pages/projects/project-tasks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
apps/web_v1/src/pages/projects/project-topbar.tsx
Normal file
45
apps/web_v1/src/pages/projects/project-topbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
apps/web_v1/src/pages/projects/project-types.ts
Normal file
94
apps/web_v1/src/pages/projects/project-types.ts
Normal 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[]
|
||||
}
|
||||
206
apps/web_v1/src/pages/workspace-body.tsx
Normal file
206
apps/web_v1/src/pages/workspace-body.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
apps/web_v1/src/pages/workspace-explore.tsx
Normal file
223
apps/web_v1/src/pages/workspace-explore.tsx
Normal 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) || '源'
|
||||
}
|
||||
126
apps/web_v1/src/pages/workspace-home.tsx
Normal file
126
apps/web_v1/src/pages/workspace-home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user