diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index a14a2d7..7476f8f 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -267,6 +267,52 @@ border-radius: 0; } +.search-results-dropdown { + width: min(680px, calc(100vw - 32px)); + max-height: 360px; + overflow-y: auto; + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-panel); + box-shadow: 0 12px 32px rgba(29, 33, 41, 0.18); +} + +.search-result-item { + display: grid; + width: 100%; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: var(--radius-control); + color: var(--color-text); + background: transparent; + text-align: left; + cursor: pointer; +} + +.search-result-item:hover, +.search-result-item:focus-visible { + background: var(--color-soft-blue); + outline: none; +} + +.search-result-heading { + display: flex; + justify-content: space-between; + gap: var(--space-3); +} + +.search-result-type { + flex: 0 0 auto; +} + +.search-result-snippet { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .topbar-actions { justify-content: flex-end; } diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index d9a7f5d..1f70b99 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -11,15 +11,18 @@ import { 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 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('login') @@ -34,6 +37,7 @@ function App() { const [loading, setLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false) const [activeModal, setActiveModal] = useState(null) + const workspaceSearch = useWorkbenchSearch(session) const dark = theme === 'dark' const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0] @@ -210,23 +214,35 @@ function App() { }, '任务已更新') } - function handleUpdateProject(update: ProjectSettingsUpdate) { - setWorkspaces((current) => - current.map((workspace) => { - if (workspace.project.id !== update.projectId) return workspace - const nextProject = { - ...workspace.project, - name: update.name, - identifier: update.identifier, - icon: update.icon, - background: update.background, - description: update.description, - short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)), - color: projectColor(update.background, workspace.project.color), - } - return { ...workspace, project: nextProject } - }), - ) + 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('项目设置已更新') + } + + function handleSelectSearchResult(result: SearchResultDTO) { + const owningWorkspace = workspaces.find((workspace) => workspace.project.id === result.projectId) + if (!owningWorkspace) { + Message.warning('该搜索结果所在项目当前不可访问') + return + } + const target = searchResultTarget(result.type) + if (!target) { + Message.warning('该搜索结果暂不支持导航') + return + } + setActiveProjectID(owningWorkspace.project.id) + setActiveView('project') + setActiveChannel(target.channel) + setActiveTaskID(target.openTask ? result.id : null) + if (result.type === 'note') setSelectedItem(result.title) } return ( @@ -267,6 +283,13 @@ function App() { 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} /> ) : ( @@ -286,18 +309,11 @@ function App() { ) } -function projectIconLabel(icon: string, fallback: string) { - const trimmed = icon.trim() - if (!trimmed) return fallback - const chars = Array.from(trimmed) - if (chars.length <= 2) return trimmed - return chars.slice(0, 2).join('').toUpperCase() -} - -function projectColor(background: string, fallback: string) { - const trimmed = background.trim() - if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed - return fallback +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 } export default App diff --git a/apps/web_v1/src/app/use-workbench-search.ts b/apps/web_v1/src/app/use-workbench-search.ts new file mode 100644 index 0000000..26c6aad --- /dev/null +++ b/apps/web_v1/src/app/use-workbench-search.ts @@ -0,0 +1,58 @@ +import { useRef, useState } from 'react' +import { Message } from '@arco-design/web-react' +import type { ApiSession } from '../api/client' +import { searchWorkspace, type SearchResultDTO } from '../api/search' + +export function useWorkbenchSearch(session: ApiSession | null) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + const requestSequence = useRef(0) + + function changeQuery(value: string) { + requestSequence.current += 1 + setQuery(value) + setLoading(false) + setSearched(false) + } + + async function submitSearch() { + const requestID = ++requestSequence.current + const trimmedQuery = query.trim() + if (!trimmedQuery) { + setResults([]) + setSearched(false) + Message.warning('请输入搜索关键词') + return + } + if (!session) { + Message.error('未登录或登录已失效') + return + } + + setLoading(true) + setSearched(false) + try { + const response = await searchWorkspace(session, trimmedQuery) + if (requestID !== requestSequence.current) return + setResults(response.items) + setSearched(true) + } catch (error) { + if (requestID !== requestSequence.current) return + setResults([]) + Message.error(error instanceof Error ? error.message : '搜索失败,请稍后重试') + } finally { + if (requestID === requestSequence.current) setLoading(false) + } + } + + return { + query, + results, + loading, + searched, + onQueryChange: changeQuery, + onSearch: submitSearch, + } +} diff --git a/apps/web_v1/src/pages/projects/project-sidebar.tsx b/apps/web_v1/src/pages/projects/project-sidebar.tsx index 7c1834c..73eff41 100644 --- a/apps/web_v1/src/pages/projects/project-sidebar.tsx +++ b/apps/web_v1/src/pages/projects/project-sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Badge, Button, Divider, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react' +import { Alert, 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' @@ -29,11 +29,13 @@ export function ProjectSidebar({ activeChannel: ChannelKey onSelectChannel: (key: ChannelKey) => void onSelectItem: (title: string) => void - onUpdateProject: (update: ProjectSettingsUpdate) => void + onUpdateProject: (update: ProjectSettingsUpdate) => Promise }) { const workspaceMode = activeView === 'workspace' const channels = activeWorkspace.channels const [settingsOpen, setSettingsOpen] = useState(false) + const [settingsSaving, setSettingsSaving] = useState(false) + const [settingsError, setSettingsError] = useState('') const [draft, setDraft] = useState({ name: activeWorkspace.project.name, identifier: activeWorkspace.project.identifier, @@ -44,6 +46,7 @@ export function ProjectSidebar({ useEffect(() => { if (!settingsOpen) return + setSettingsError('') setDraft({ name: activeWorkspace.project.name, identifier: activeWorkspace.project.identifier, @@ -61,7 +64,7 @@ export function ProjectSidebar({ {workspaceMode ? : {projectIcon}} {workspaceMode ? '工作台' : activeWorkspace.project.name} - + + )} + /> + + ) return (
- + 森林AI
-
- } placeholder="搜索项目、频道、文档、任务、联系人..." /> - -
+ setPopupVisible(visible && searched)} + > +
+ } + placeholder="搜索项目、任务和笔记" + value={query} + onChange={(value) => { + setPopupVisible(false) + onQueryChange(value) + }} + onPressEnter={submitSearch} + /> + +
+