diff --git a/apps/senlinai-acro-react/src/App.css b/apps/senlinai-acro-react/src/App.css index ff6eaa3..7433a7e 100644 --- a/apps/senlinai-acro-react/src/App.css +++ b/apps/senlinai-acro-react/src/App.css @@ -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; diff --git a/apps/senlinai-acro-react/src/api/client.ts b/apps/senlinai-acro-react/src/api/client.ts index a22ea84..34aee88 100644 --- a/apps/senlinai-acro-react/src/api/client.ts +++ b/apps/senlinai-acro-react/src/api/client.ts @@ -28,13 +28,15 @@ export async function login(email: string, password: string): Promise(path: string, options: RequestOptions = {}): Promise { + 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) { diff --git a/apps/senlinai-acro-react/src/api/projects.ts b/apps/senlinai-acro-react/src/api/projects.ts index a111419..d90c9bf 100644 --- a/apps/senlinai-acro-react/src/api/projects.ts +++ b/apps/senlinai-acro-react/src/api/projects.ts @@ -95,3 +95,62 @@ export async function fetchProjects(session: ApiSession) { export async function fetchProjectWorkspace(session: ApiSession, projectID: string | number) { return apiRequest(`/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('/api/projects', { + method: 'POST', + token: session.token, + body: input, + }) +} + +export async function createTask(session: ApiSession, projectID: string | number, input: CreateTaskInput) { + return apiRequest(`/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(`/api/projects/${projectID}/sources`, { + method: 'POST', + token: session.token, + body, + }) +} + +export async function createCronPlan(session: ApiSession, projectID: string | number, input: CreateCronPlanInput) { + return apiRequest(`/api/projects/${projectID}/cron-plans`, { + method: 'POST', + token: session.token, + body: input, + }) +} diff --git a/apps/senlinai-acro-react/src/app/App.tsx b/apps/senlinai-acro-react/src/app/App.tsx index 1a93351..42ccd91 100644 --- a/apps/senlinai-acro-react/src/app/App.tsx +++ b/apps/senlinai-acro-react/src/app/App.tsx @@ -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(null) const [, setSelectedItem] = useState('Inbox 待处理(36)') const [loading, setLoading] = useState(false) + const [actionLoading, setActionLoading] = useState(false) + const [activeModal, setActiveModal] = useState(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, 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')} /> ) : ( )} + setActiveModal(null)} + onCreateProject={handleCreateProject} + onCreateTask={handleCreateTask} + onUploadSource={handleUploadSource} + onCreateCronPlan={handleCreateCronPlan} + /> ) diff --git a/apps/senlinai-acro-react/src/pages/projects/project-action-modals.tsx b/apps/senlinai-acro-react/src/pages/projects/project-action-modals.tsx new file mode 100644 index 0000000..de61e1e --- /dev/null +++ b/apps/senlinai-acro-react/src/pages/projects/project-action-modals.tsx @@ -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({ name: '', description: '' }) + const [task, setTask] = useState({ title: '', description: '', dueAt: '' }) + const [source, setSource] = useState({ title: '', file: null }) + const [cron, setCron] = useState({ 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 ( + <> + onCreateProject(project)} + > + + +