feat: add project action modals and APIs

This commit is contained in:
2026-07-20 21:45:36 +08:00
parent df12201caa
commit c1a144cb44
16 changed files with 877 additions and 23 deletions

View File

@@ -815,6 +815,41 @@
padding: 12px;
}
.action-modal .arco-modal-content {
border-radius: 8px;
}
.action-form {
width: 100%;
}
.action-form label {
display: grid;
gap: 6px;
}
.action-switch {
grid-template-columns: 1fr auto;
align-items: center;
}
.native-file-input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-panel);
color: var(--senlin-text);
padding: 8px;
}
.theme-dark .arco-modal,
.theme-dark .arco-modal-header,
.theme-dark .arco-modal-content {
background: var(--senlin-panel);
color: var(--senlin-text);
}
.inspector {
min-height: 0;
overflow-x: hidden;

View File

@@ -28,13 +28,15 @@ export async function login(email: string, password: string): Promise<ApiSession
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
const response = await fetch(`${configuredBaseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
body: requestBody as BodyInit | undefined,
})
if (!response.ok) {

View File

@@ -95,3 +95,62 @@ export async function fetchProjects(session: ApiSession) {
export async function fetchProjectWorkspace(session: ApiSession, projectID: string | number) {
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectID}/workspace`, { token: session.token })
}
export type CreateProjectInput = {
name: string
description?: string
}
export type CreateTaskInput = {
title: string
description?: string
status?: string
dueAt?: string
}
export type UploadSourceInput = {
title?: string
file: File
}
export type CreateCronPlanInput = {
title: string
schedule: string
enabled: boolean
nextRunAt?: string
}
export async function createProject(session: ApiSession, input: CreateProjectInput) {
return apiRequest<BackendProject>('/api/projects', {
method: 'POST',
token: session.token,
body: input,
})
}
export async function createTask(session: ApiSession, projectID: string | number, input: CreateTaskInput) {
return apiRequest<BackendTask>(`/api/projects/${projectID}/tasks`, {
method: 'POST',
token: session.token,
body: input,
})
}
export async function uploadSource(session: ApiSession, projectID: string | number, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
return apiRequest<BackendNoteSource>(`/api/projects/${projectID}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
export async function createCronPlan(session: ApiSession, projectID: string | number, input: CreateCronPlanInput) {
return apiRequest<BackendCronPlan>(`/api/projects/${projectID}/cron-plans`, {
method: 'POST',
token: session.token,
body: input,
})
}

View File

@@ -4,8 +4,16 @@ import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { login, setApiBaseUrl, type ApiSession } from '../api/client'
import { mapWorkspace } from '../api/mappers'
import { fetchProjectWorkspace, fetchProjects } from '../api/projects'
import {
createCronPlan,
createProject,
createTask,
fetchProjectWorkspace,
fetchProjects,
uploadSource,
} from '../api/projects'
import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import { ProjectPage } from '../pages/workspace-home'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
@@ -20,21 +28,30 @@ function App() {
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('Inbox 待处理36')
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const dark = theme === 'dark'
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
)
setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
setActiveProjectID(nextProjectID)
return backendWorkspaces
}
async function handleLogin(input: { server: string; email: string; password: string }) {
setLoading(true)
try {
setApiBaseUrl(input.server)
const nextSession = await login(input.email, input.password)
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
)
const backendWorkspaces = await loadWorkspaces(nextSession, '')
setSession(nextSession)
setWorkspaces(backendWorkspaces)
setActiveProjectID(backendWorkspaces[0]?.project.id ?? '')
setActiveChannel('overview')
setActiveView('workspace')
@@ -46,6 +63,110 @@ function App() {
}
}
async function refreshAfterAction(nextProjectID = activeProjectID) {
if (!session) return
await loadWorkspaces(session, nextProjectID)
}
async function runAction(action: () => Promise<void>, success: string) {
setActionLoading(true)
try {
await action()
setActiveModal(null)
Message.success(success)
} catch (error) {
Message.error(error instanceof Error ? error.message : '操作失败')
} finally {
setActionLoading(false)
}
}
function requireSession() {
if (!session) throw new Error('未登录')
return session
}
function requireActiveProject() {
if (!activeWorkspace?.project.id) throw new Error('未选择项目')
return activeWorkspace.project.id
}
function normalizeOptionalTime(value: string) {
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function handleCreateProject(draft: ProjectDraft) {
if (!draft.name.trim()) {
Message.warning('请输入项目名称')
return
}
void runAction(async () => {
const created = await createProject(requireSession(), { name: draft.name.trim(), description: draft.description.trim() })
const nextProjectID = String(created.ID ?? created.id ?? '')
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
setActiveProjectID(nextProjectID)
setActiveView('project')
setActiveChannel('overview')
}
}, '项目已创建')
}
function handleCreateTask(draft: TaskDraft) {
if (!draft.title.trim()) {
Message.warning('请输入任务标题')
return
}
void runAction(async () => {
await createTask(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
description: draft.description.trim(),
status: 'open',
dueAt: normalizeOptionalTime(draft.dueAt),
})
await refreshAfterAction()
setActiveChannel('tasks')
}, '任务已创建')
}
function handleUploadSource(draft: SourceDraft) {
if (!draft.file) {
Message.warning('请选择文件')
return
}
const file = draft.file
void runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
file,
})
await refreshAfterAction()
setActiveChannel('notes')
}, '文件已上传')
}
function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) {
Message.warning('请输入计划名称')
return
}
if (!draft.schedule.trim()) {
Message.warning('请输入 Cron 表达式')
return
}
void runAction(async () => {
await createCronPlan(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
schedule: draft.schedule.trim(),
enabled: draft.enabled,
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
})
await refreshAfterAction()
setActiveChannel('cron')
}, '计划任务已创建')
}
function openTask(project: Project, taskID: string) {
setActiveProjectID(project.id)
setActiveView('project')
@@ -84,10 +205,23 @@ function App() {
onOpenTask={openTask}
onCloseTask={() => setActiveTaskID(null)}
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
onCreateProject={() => setActiveModal('project')}
onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')}
/>
) : (
<Spin loading />
)}
<ProjectActionModals
activeModal={activeModal}
loading={actionLoading}
onClose={() => setActiveModal(null)}
onCreateProject={handleCreateProject}
onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan}
/>
</main>
</ConfigProvider>
)

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { Input, Modal, 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
description: string
}
export type TaskDraft = {
title: string
description: string
dueAt: 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,
}: {
activeModal: ProjectActionModal
loading: boolean
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', dueAt: '' })
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: '' })
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>
<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>
<Input placeholder="2026-07-21T09:30:00Z可留空" value={task.dueAt} onChange={(dueAt) => setTask((draft) => ({ ...draft, dueAt }))} />
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<input
className="native-file-input"
type="file"
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
/>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建计划任务"
visible={activeModal === 'cron'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateCronPlan(cron)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text>Cron </Text>
<Input value={cron.schedule} onChange={(schedule) => setCron((draft) => ({ ...draft, schedule }))} />
</label>
<label>
<Text></Text>
<Input placeholder="2026-07-22T08:00:00Z可留空" value={cron.nextRunAt} onChange={(nextRunAt) => setCron((draft) => ({ ...draft, nextRunAt }))} />
</label>
<label className="action-switch">
<Text></Text>
<Switch checked={cron.enabled} onChange={(enabled) => setCron((draft) => ({ ...draft, enabled }))} />
</label>
</Space>
</Modal>
</>
)
}

View File

@@ -12,6 +12,9 @@ export function ProjectChannelPage({
onSelectItem,
onOpenTask,
onCloseTask,
onCreateTask,
onUploadSource,
onCreateCronPlan,
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
@@ -19,16 +22,19 @@ export function ProjectChannelPage({
onSelectItem: (title: string) => void
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
}) {
switch (activeChannel) {
case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} />
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} />
case 'ai':
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'overview':
default:
return <ProjectOverview activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />

View File

@@ -4,7 +4,7 @@ import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectCron({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
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
@@ -16,7 +16,7 @@ export function ProjectCron({ activeWorkspace, onSelectItem }: { activeWorkspace
<Title heading={4}>Cron </Title>
<Text type="secondary">{project.name} AI </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onCreateCronPlan}></Button>
</div>
<Card className="queue-section" bordered>

View File

@@ -6,7 +6,7 @@ const { Title, Text } = Typography
const folders = ['需求文档', '技术方案', '会议纪要', '竞品资料']
export function ProjectNotes({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
const { notes, project } = activeWorkspace
return (
@@ -18,7 +18,7 @@ export function ProjectNotes({ activeWorkspace, onSelectItem }: { activeWorkspac
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />}>/</Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space>
</div>

View File

@@ -12,6 +12,7 @@ export function ProjectRail({
onSelectWorkspace,
onSelectWorkspaceInbox,
onSelectProject,
onCreateProject,
}: {
activeProject: Project
projects: Project[]
@@ -19,6 +20,7 @@ export function ProjectRail({
onSelectWorkspace: () => void
onSelectWorkspaceInbox: () => void
onSelectProject: (project: Project) => void
onCreateProject: () => void
}) {
return (
<Sider className="project-rail" width={96}>
@@ -50,7 +52,7 @@ export function ProjectRail({
</Badge>
))}
</Space>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" />
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={onCreateProject} />
</Sider>
)
}

View File

@@ -10,12 +10,14 @@ export function ProjectTasks({
onOpenTask,
onCloseTask,
onSelectItem,
onCreateTask,
}: {
activeWorkspace: ProjectWorkspace
activeTaskID: string | null
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onSelectItem: (title: string) => void
onCreateTask: () => void
}) {
const { tasks, project } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID)
@@ -32,7 +34,7 @@ export function ProjectTasks({
<Title heading={4}></Title>
<Text type="secondary">{project.name} TODO </Text>
</div>
<Button type="primary" icon={<IconPlus />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onCreateTask}></Button>
</div>
<Card className="queue-section" bordered>

View File

@@ -25,6 +25,10 @@ export function ProjectPage({
onOpenTask,
onCloseTask,
onToggleTheme,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -40,6 +44,10 @@ export function ProjectPage({
onOpenTask: (project: Project, taskID: string) => void
onCloseTask: () => void
onToggleTheme: () => void
onCreateProject: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
}) {
const isProject = activeView === 'project'
const projects = workspaces.map((workspace) => workspace.project)
@@ -56,6 +64,7 @@ export function ProjectPage({
onSelectWorkspace={onSelectWorkspace}
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
onSelectProject={onSelectProject}
onCreateProject={onCreateProject}
/>
{isProject && (
@@ -81,6 +90,9 @@ export function ProjectPage({
onSelectItem={onSelectItem}
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
onCloseTask={onCloseTask}
onCreateTask={onCreateTask}
onUploadSource={onUploadSource}
onCreateCronPlan={onCreateCronPlan}
/>
)}
</Content>