feat: add project action modals and APIs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/logic/files"
|
||||
"senlinai-agent/backend/internal/logic/inbox"
|
||||
"senlinai-agent/backend/internal/logic/projects"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
@@ -20,7 +21,7 @@ func main() {
|
||||
}
|
||||
|
||||
authService := auth.NewService(cfg.AuthSecret)
|
||||
projectHandler := projects.NewHandler(projects.NewService())
|
||||
projectHandler := projects.NewHandler(projects.NewService(), files.NewService(cfg.StorageDir))
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||
authHandler := auth.NewHandler(authService)
|
||||
appRouter := httpx.NewProtectedRouter(cfg, authService.VerifySession, authHandler, projectHandler, inboxHandler)
|
||||
|
||||
@@ -3,17 +3,25 @@ package projects
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/logic/files"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
service *Service
|
||||
fileService *files.Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
func NewHandler(service *Service, fileServices ...*files.Service) *Handler {
|
||||
var fileService *files.Service
|
||||
if len(fileServices) > 0 {
|
||||
fileService = fileServices[0]
|
||||
}
|
||||
return &Handler{service: service, fileService: fileService}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
@@ -21,6 +29,9 @@ func (h *Handler) Register(router gin.IRouter) {
|
||||
router.GET("/projects", h.listProjects)
|
||||
router.GET("/projects/:id/dashboard", h.dashboard)
|
||||
router.GET("/projects/:id/workspace", h.workspace)
|
||||
router.POST("/projects/:id/tasks", h.createTask)
|
||||
router.POST("/projects/:id/sources", h.uploadSource)
|
||||
router.POST("/projects/:id/cron-plans", h.createCronPlan)
|
||||
}
|
||||
|
||||
func (h *Handler) createProject(c *gin.Context) {
|
||||
@@ -96,3 +107,149 @@ func (h *Handler) workspace(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, workspace)
|
||||
}
|
||||
|
||||
func (h *Handler) createTask(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
DueAt string `json:"dueAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
dueAt, err := parseOptionalTime(input.DueAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dueAt"})
|
||||
return
|
||||
}
|
||||
task, err := h.service.CreateTask(userID, projectID, CreateTaskInput{
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Status: input.Status,
|
||||
DueAt: dueAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, task)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadSource(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
if h.fileService == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "file service is not configured"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := ensureProjectOwner(userID, projectID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
stored, err := h.fileService.Save(projectID, fileHeader.Filename, file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||
return
|
||||
}
|
||||
title := c.PostForm("title")
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = stored.OriginalName
|
||||
}
|
||||
source, err := h.service.CreateFileSource(userID, projectID, CreateFileSourceInput{
|
||||
Title: title,
|
||||
FilePath: stored.RelativePath,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, source)
|
||||
}
|
||||
|
||||
func (h *Handler) createCronPlan(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
projectID, ok := parseProjectID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Title string `json:"title"`
|
||||
Schedule string `json:"schedule"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt string `json:"nextRunAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
nextRunAt, err := parseOptionalTime(input.NextRunAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid nextRunAt"})
|
||||
return
|
||||
}
|
||||
plan, err := h.service.CreateCronPlan(userID, projectID, CreateCronPlanInput{
|
||||
Title: input.Title,
|
||||
Schedule: input.Schedule,
|
||||
Enabled: input.Enabled,
|
||||
NextRunAt: nextRunAt,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, plan)
|
||||
}
|
||||
|
||||
func parseProjectID(c *gin.Context) (uint, bool) {
|
||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(projectID), true
|
||||
}
|
||||
|
||||
func parseOptionalTime(value string) (*time.Time, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, trimmed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
96
backend/internal/logic/projects/handlers_test.go
Normal file
96
backend/internal/logic/projects/handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/files"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestCreateTaskHandlerPersistsTask(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
body, err := json.Marshal(gin.H{"title": "整理需求", "description": "形成任务清单", "dueAt": "2026-07-21T09:30:00Z"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/tasks", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var task models.SenlinAgentTask
|
||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "整理需求").First(&task).Error)
|
||||
require.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
}
|
||||
|
||||
func TestUploadSourceHandlerStoresFileAndSource(t *testing.T) {
|
||||
router, project, storageDir := newProjectsHandlerTestRouter(t)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
require.NoError(t, writer.WriteField("title", "客户资料.txt"))
|
||||
fileWriter, err := writer.CreateFormFile("file", "客户资料.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = fileWriter.Write([]byte("hello"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/sources", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var source models.SenlinAgentSource
|
||||
require.NoError(t, models.DBService.Where("project_id = ? AND kind = ?", project.ID, "file").First(&source).Error)
|
||||
require.Equal(t, "客户资料.txt", source.Title)
|
||||
require.NotEmpty(t, source.FilePath)
|
||||
require.FileExists(t, filepath.Join(storageDir, source.FilePath))
|
||||
}
|
||||
|
||||
func TestCreateCronPlanHandlerPersistsPlan(t *testing.T) {
|
||||
router, project, _ := newProjectsHandlerTestRouter(t)
|
||||
body, err := json.Marshal(gin.H{"title": "每日整理", "schedule": "0 9 * * *", "enabled": true, "nextRunAt": "2026-07-22T08:00:00Z"})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/1/cron-plans", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, rec.Code)
|
||||
var plan models.SenlinAgentCronPlan
|
||||
require.NoError(t, models.DBService.Where("project_id = ? AND title = ?", project.ID, "每日整理").First(&plan).Error)
|
||||
require.Equal(t, "0 9 * * *", plan.Schedule)
|
||||
require.True(t, plan.Enabled)
|
||||
require.NotNil(t, plan.NextRunAt)
|
||||
}
|
||||
|
||||
func newProjectsHandlerTestRouter(t *testing.T) (*gin.Engine, *models.SenlinAgentProject, string) {
|
||||
t.Helper()
|
||||
newTestDB(t)
|
||||
require.NoError(t, models.DBService.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
storageDir := t.TempDir()
|
||||
router := httpx.NewProtectedRouter(
|
||||
config.Config{Env: "test"},
|
||||
func(token string) (uint, error) { return 1, nil },
|
||||
NewHandler(service, files.NewService(storageDir)),
|
||||
)
|
||||
return router, project, storageDir
|
||||
}
|
||||
@@ -99,6 +99,25 @@ type ProjectWorkspace struct {
|
||||
CronPlans []CronPlan `json:"cronPlans"`
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Status string
|
||||
DueAt *time.Time
|
||||
}
|
||||
|
||||
type CreateFileSourceInput struct {
|
||||
Title string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type CreateCronPlanInput struct {
|
||||
Title string
|
||||
Schedule string
|
||||
Enabled bool
|
||||
NextRunAt *time.Time
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
@@ -118,6 +137,75 @@ func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("task title is required")
|
||||
}
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
status = "open"
|
||||
}
|
||||
task := &models.SenlinAgentTask{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Status: status,
|
||||
DueAt: input.DueAt,
|
||||
}
|
||||
return task, models.DBService.Create(task).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("source title is required")
|
||||
}
|
||||
filePath := strings.TrimSpace(input.FilePath)
|
||||
if filePath == "" {
|
||||
return nil, errors.New("source file path is required")
|
||||
}
|
||||
source := &models.SenlinAgentSource{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
Kind: "file",
|
||||
Title: title,
|
||||
FilePath: filePath,
|
||||
}
|
||||
return source, models.DBService.Create(source).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("cron plan title is required")
|
||||
}
|
||||
schedule := strings.TrimSpace(input.Schedule)
|
||||
if schedule == "" {
|
||||
return nil, errors.New("cron schedule is required")
|
||||
}
|
||||
plan := &models.SenlinAgentCronPlan{
|
||||
ProjectID: projectID,
|
||||
CreatedBy: ownerID,
|
||||
Title: title,
|
||||
Schedule: schedule,
|
||||
Enabled: input.Enabled,
|
||||
NextRunAt: input.NextRunAt,
|
||||
LastResult: "Not run yet",
|
||||
}
|
||||
return plan, models.DBService.Create(plan).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
@@ -134,8 +222,7 @@ func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
||||
if err := ensureProjectOwner(ownerID, projectID); err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
dashboard := Dashboard{ProjectID: projectID}
|
||||
@@ -212,6 +299,17 @@ func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, err
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureProjectOwner(ownerID uint, projectID uint) error {
|
||||
var count int64
|
||||
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("project not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type workspaceCounts struct {
|
||||
inbox int64
|
||||
tasks int64
|
||||
|
||||
@@ -127,6 +127,94 @@ func TestWorkspaceRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateTaskAddsTaskToOwnedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
dueAt := time.Date(2026, 7, 21, 9, 30, 0, 0, time.UTC)
|
||||
|
||||
task, err := service.CreateTask(1, project.ID, CreateTaskInput{
|
||||
Title: "Follow up with client",
|
||||
Description: "整理客户反馈并形成行动项",
|
||||
Status: "",
|
||||
DueAt: &dueAt,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, task.ProjectID)
|
||||
require.Equal(t, uint(1), task.CreatedBy)
|
||||
require.Equal(t, "Follow up with client", task.Title)
|
||||
require.Equal(t, "open", task.Status)
|
||||
require.NotNil(t, task.DueAt)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.Tasks, 1)
|
||||
require.Equal(t, "Follow up with client", workspace.Tasks[0].Title)
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.CreateTask(1, project.ID, CreateTaskInput{Title: "Nope"})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateFileSourceAddsSourceToOwnedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
source, err := service.CreateFileSource(1, project.ID, CreateFileSourceInput{
|
||||
Title: "客户访谈.pdf",
|
||||
FilePath: "projects/1/客户访谈.pdf",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, source.ProjectID)
|
||||
require.Equal(t, uint(1), source.CreatedBy)
|
||||
require.Equal(t, "file", source.Kind)
|
||||
require.Equal(t, "客户访谈.pdf", source.Title)
|
||||
require.Equal(t, "projects/1/客户访谈.pdf", source.FilePath)
|
||||
var count int64
|
||||
require.NoError(t, database.Model(&models.SenlinAgentSource{}).Where("project_id = ? AND kind = ?", project.ID, "file").Count(&count).Error)
|
||||
require.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestCreateCronPlanAddsPlanToOwnedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService()
|
||||
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error)
|
||||
project, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
nextRun := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
plan, err := service.CreateCronPlan(1, project.ID, CreateCronPlanInput{
|
||||
Title: "Daily inbox sweep",
|
||||
Schedule: "0 9 * * *",
|
||||
Enabled: true,
|
||||
NextRunAt: &nextRun,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, project.ID, plan.ProjectID)
|
||||
require.Equal(t, uint(1), plan.CreatedBy)
|
||||
require.Equal(t, "Daily inbox sweep", plan.Title)
|
||||
require.Equal(t, "0 9 * * *", plan.Schedule)
|
||||
require.True(t, plan.Enabled)
|
||||
require.NotNil(t, plan.NextRunAt)
|
||||
workspace, err := service.Workspace(1, project.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspace.CronPlans, 1)
|
||||
require.Equal(t, "Daily inbox sweep", workspace.CronPlans[0].Title)
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
|
||||
Reference in New Issue
Block a user