401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
import { useCallback, useRef, useState } from 'react'
|
|
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
|
|
import '@arco-design/web-react/dist/css/arco.css'
|
|
import '../App.css'
|
|
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
|
|
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
|
|
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
|
|
import { mapWorkspace } from '../api/mappers'
|
|
import {
|
|
createCronPlan,
|
|
createProject,
|
|
createProjectTag,
|
|
createTask,
|
|
fetchProjectWorkspace,
|
|
fetchProjects,
|
|
updateProject,
|
|
updateTask,
|
|
uploadSource,
|
|
} from '../api/projects'
|
|
import type { SearchResultDTO } from '../api/search'
|
|
import { LoginPage } from '../pages/login'
|
|
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
|
import { SearchResultPreview } from '../pages/projects/search-result-preview'
|
|
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
|
import { ProjectPage } from '../pages/workspace-home'
|
|
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
|
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
|
import { useWorkbenchSearch } from './use-workbench-search'
|
|
|
|
function App() {
|
|
const [screen, setScreen] = useState<Screen>('login')
|
|
const [theme, setTheme] = useState<Theme>('light')
|
|
const [activeView, setActiveView] = useState<WorkbenchView>('workspace')
|
|
const [session, setSession] = useState<ApiSession | null>(null)
|
|
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
|
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
|
const activeProjectIDRef = useRef('')
|
|
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
|
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
|
const [, setSelectedItem] = useState('探索采集')
|
|
const [loading, setLoading] = useState(false)
|
|
const [actionLoading, setActionLoading] = useState(false)
|
|
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
|
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
|
const workspaceSearch = useWorkbenchSearch(session)
|
|
|
|
const handleListAISessions = useCallback((projectId: string) => {
|
|
if (!session) return Promise.reject(new Error('未登录'))
|
|
return listAISessions(session, projectId)
|
|
}, [session])
|
|
|
|
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput) => {
|
|
if (!session) return Promise.reject(new Error('未登录'))
|
|
return createAISession(session, projectId, input)
|
|
}, [session])
|
|
|
|
const dark = theme === 'dark'
|
|
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
|
const activeTagOptions = activeWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
|
|
|
|
function selectActiveProject(projectID: string) {
|
|
activeProjectIDRef.current = projectID
|
|
setActiveProjectID(projectID)
|
|
}
|
|
|
|
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
|
const projectIDWhenStarted = activeProjectIDRef.current
|
|
const backendProjects = await fetchProjects(nextSession)
|
|
const backendWorkspaces = await Promise.all(
|
|
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
|
|
)
|
|
setWorkspaces(backendWorkspaces)
|
|
const latestProjectID = activeProjectIDRef.current
|
|
const requestedProjectID = latestProjectID !== projectIDWhenStarted
|
|
? latestProjectID
|
|
: preferredProjectID ?? latestProjectID
|
|
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
|
selectActiveProject(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 backendWorkspaces = await loadWorkspaces(nextSession, '')
|
|
setSession(nextSession)
|
|
selectActiveProject(backendWorkspaces[0]?.project.id ?? '')
|
|
setActiveChannel('overview')
|
|
setActiveView('workspace')
|
|
setScreen('workbench')
|
|
} catch (error) {
|
|
Message.error(error instanceof Error ? error.message : '登录失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function refreshAfterAction(nextProjectID?: string) {
|
|
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(),
|
|
identifier: draft.identifier.trim(),
|
|
icon: draft.icon.trim(),
|
|
background: draft.background.trim(),
|
|
description: draft.description.trim(),
|
|
})
|
|
const nextProjectID = created.id
|
|
await refreshAfterAction(nextProjectID)
|
|
if (nextProjectID) {
|
|
selectActiveProject(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',
|
|
tag: draft.tag.trim(),
|
|
})
|
|
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 handleCreateProjectTag(name: string) {
|
|
const trimmedName = name.trim()
|
|
if (!trimmedName) {
|
|
Message.warning('请输入标签名称')
|
|
return
|
|
}
|
|
void runAction(async () => {
|
|
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
|
|
await refreshAfterAction()
|
|
}, '标签已创建')
|
|
}
|
|
|
|
function openTask(project: Project, taskID: string) {
|
|
selectActiveProject(project.id)
|
|
setActiveView('project')
|
|
setActiveChannel('tasks')
|
|
setActiveTaskID(taskID)
|
|
}
|
|
|
|
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
|
|
void runAction(async () => {
|
|
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
|
|
title: update.title,
|
|
description: update.summary,
|
|
completed: update.completed,
|
|
nextProjectId: update.nextProjectId,
|
|
tag: update.tag,
|
|
})
|
|
await refreshAfterAction(update.nextProjectId)
|
|
}, '任务已更新')
|
|
}
|
|
|
|
async function handleUpdateProject(update: ProjectSettingsUpdate) {
|
|
const currentSession = requireSession()
|
|
await updateProject(currentSession, update.projectId, {
|
|
name: update.name,
|
|
identifier: update.identifier,
|
|
icon: update.icon,
|
|
background: update.background,
|
|
description: update.description,
|
|
})
|
|
await loadWorkspaces(currentSession, update.projectId)
|
|
Message.success('项目设置已更新')
|
|
}
|
|
|
|
async function handleAnalyzeInbox(inboxId: string) {
|
|
const response = await analyzeInboxItem(requireSession(), inboxId)
|
|
return response.suggestions
|
|
}
|
|
|
|
async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) {
|
|
const confirmationProjectID = activeProjectIDRef.current
|
|
let response
|
|
try {
|
|
response = await confirmInboxItem(requireSession(), inboxId, suggestionIds)
|
|
} catch (error) {
|
|
if (!(error instanceof ApiError) || error.status !== 409) throw error
|
|
if (activeProjectIDRef.current !== confirmationProjectID) {
|
|
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区')
|
|
}
|
|
try {
|
|
await refreshAfterAction()
|
|
} catch {
|
|
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
|
|
}
|
|
throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对')
|
|
}
|
|
if (activeProjectIDRef.current !== confirmationProjectID) {
|
|
return { createdCount: response.createdCount }
|
|
}
|
|
try {
|
|
await refreshAfterAction()
|
|
return { createdCount: response.createdCount }
|
|
} catch {
|
|
return {
|
|
createdCount: response.createdCount,
|
|
refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目',
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleSelectSearchResult(result: SearchResultDTO) {
|
|
const target = searchResultTarget(result.type)
|
|
if (!target) {
|
|
Message.warning('该搜索结果暂不支持导航')
|
|
return
|
|
}
|
|
const owningWorkspace = workspaces.find((workspace) => workspace.project.id === result.projectId)
|
|
if (!owningWorkspace) {
|
|
if (isAuthorizedExternalPreview(result)) {
|
|
setSearchResultPreview(result)
|
|
} else {
|
|
Message.warning('该搜索结果所在项目当前不可访问')
|
|
}
|
|
return
|
|
}
|
|
selectActiveProject(owningWorkspace.project.id)
|
|
setActiveView('project')
|
|
setActiveChannel(target.channel)
|
|
setActiveTaskID(target.openTask ? result.id : null)
|
|
if (result.type === 'note') setSelectedItem(result.title)
|
|
}
|
|
|
|
return (
|
|
<ConfigProvider>
|
|
<main className={dark ? 'app theme-dark' : 'app'}>
|
|
{screen === 'login' ? (
|
|
<Spin loading={loading} style={{ width: '100%' }}>
|
|
<LoginPage onLogin={handleLogin} />
|
|
</Spin>
|
|
) : activeWorkspace && session ? (
|
|
<ProjectPage
|
|
activeView={activeView}
|
|
activeWorkspace={activeWorkspace}
|
|
workspaces={workspaces}
|
|
activeChannel={activeChannel}
|
|
activeTaskID={activeTaskID}
|
|
theme={theme}
|
|
onSelectWorkspace={() => setActiveView('workspace')}
|
|
onSelectWorkspaceExplore={() => setActiveView('workspace-explore')}
|
|
onSelectProject={(project) => {
|
|
selectActiveProject(project.id)
|
|
setActiveView('project')
|
|
setActiveChannel('overview')
|
|
setActiveTaskID(null)
|
|
}}
|
|
onSelectChannel={(channel) => {
|
|
setActiveChannel(channel)
|
|
setActiveTaskID(null)
|
|
}}
|
|
onSelectItem={setSelectedItem}
|
|
onOpenTask={openTask}
|
|
onCloseTask={() => setActiveTaskID(null)}
|
|
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
|
onCreateProject={() => setActiveModal('project')}
|
|
onCreateTask={() => setActiveModal('task')}
|
|
onUploadSource={() => setActiveModal('source')}
|
|
onCreateCronPlan={() => setActiveModal('cron')}
|
|
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
|
onUpdateProject={handleUpdateProject}
|
|
onCreateProjectTag={handleCreateProjectTag}
|
|
searchQuery={workspaceSearch.query}
|
|
searchLoading={workspaceSearch.loading}
|
|
searchSearched={workspaceSearch.searched}
|
|
searchResults={workspaceSearch.results}
|
|
onSearchQueryChange={workspaceSearch.onQueryChange}
|
|
onSearch={() => void workspaceSearch.onSearch()}
|
|
onSelectSearchResult={handleSelectSearchResult}
|
|
onAnalyzeInbox={handleAnalyzeInbox}
|
|
onConfirmInbox={handleConfirmInbox}
|
|
onListAISessions={handleListAISessions}
|
|
onCreateAISession={handleCreateAISession}
|
|
/>
|
|
) : (
|
|
<Spin loading />
|
|
)}
|
|
<ProjectActionModals
|
|
activeModal={activeModal}
|
|
loading={actionLoading}
|
|
onClose={() => setActiveModal(null)}
|
|
onCreateProject={handleCreateProject}
|
|
onCreateTask={handleCreateTask}
|
|
onUploadSource={handleUploadSource}
|
|
onCreateCronPlan={handleCreateCronPlan}
|
|
tagOptions={activeTagOptions}
|
|
/>
|
|
<SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} />
|
|
</main>
|
|
</ConfigProvider>
|
|
)
|
|
}
|
|
|
|
function searchResultTarget(type: string): { channel: ChannelKey; openTask: boolean } | null {
|
|
if (type === 'project') return { channel: 'overview', openTask: false }
|
|
if (type === 'task') return { channel: 'tasks', openTask: true }
|
|
if (type === 'note') return { channel: 'notes', openTask: false }
|
|
return null
|
|
}
|
|
|
|
function isAuthorizedExternalPreview(result: SearchResultDTO) {
|
|
return (
|
|
(result.type === 'task' || result.type === 'note')
|
|
&& typeof result.projectId === 'string'
|
|
&& result.projectId.trim() !== ''
|
|
&& typeof result.title === 'string'
|
|
&& result.title.trim() !== ''
|
|
&& typeof result.snippet === 'string'
|
|
)
|
|
}
|
|
|
|
export default App
|