From 110423406b1eeeda190d4b6912fed4cb61983c67 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 17:16:45 +0800 Subject: [PATCH] feat: connect search and project settings --- apps/web_v1/scripts/visual-check.mjs | 140 ++++++++++++++- apps/web_v1/src/App.css | 46 +++++ apps/web_v1/src/app/App.tsx | 74 ++++---- apps/web_v1/src/app/use-workbench-search.ts | 58 +++++++ .../src/pages/projects/project-sidebar.tsx | 45 +++-- .../src/pages/projects/project-topbar.tsx | 89 +++++++++- apps/web_v1/src/pages/workspace-home.tsx | 24 ++- backend/cmd/api/main.go | 4 +- backend/internal/logic/projects/handlers.go | 7 +- .../internal/logic/projects/handlers_test.go | 22 ++- backend/internal/logic/search/handlers.go | 54 ++++++ .../internal/logic/search/handlers_test.go | 161 ++++++++++++++++++ backend/internal/logic/search/service.go | 149 ++++++++-------- .../logic/search/service_postgres_test.go | 61 +++++++ backend/internal/logic/search/service_test.go | 30 +++- 15 files changed, 825 insertions(+), 139 deletions(-) create mode 100644 apps/web_v1/src/app/use-workbench-search.ts create mode 100644 backend/internal/logic/search/handlers.go create mode 100644 backend/internal/logic/search/handlers_test.go create mode 100644 backend/internal/logic/search/service_postgres_test.go diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index d2b5de9..4d37199 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -19,8 +19,23 @@ const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, de const errors = [] const failures = [] const projectId = '019b0000-0000-7000-8000-000000000001' +const taskSearchResultId = '019b0000-0000-7000-8000-000000000003' +const unknownProjectId = '019b0000-0000-7000-8000-000000000099' +const searchRequests = [] +const projectPatchRequests = [] +let expectingProjectPatchError = false +let expectedProjectPatchConsoleErrorCount = 0 page.on('console', (message) => { - if (message.type() === 'error') errors.push(message.text()) + if (message.type() !== 'error') return + if ( + expectingProjectPatchError && + expectedProjectPatchConsoleErrorCount === 0 && + message.text() === 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)' + ) { + expectedProjectPatchConsoleErrorCount += 1 + return + } + errors.push(message.text()) }) const visualCheckWorkspace = { @@ -54,6 +69,7 @@ const visualCheckWorkspace = { await page.route('http://localhost:9150/api/v1/**', async (route) => { const url = new URL(route.request().url()) + const method = route.request().method() if (url.pathname === '/api/v1/status') { await route.fulfill({ json: { timestamp: new Date().toISOString() } }) return @@ -70,6 +86,39 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { await route.fulfill({ json: visualCheckWorkspace }) return } + if (url.pathname === '/api/v1/search') { + const query = url.searchParams.get('q') ?? '' + searchRequests.push(query) + if (query === '慢请求') await new Promise((resolve) => setTimeout(resolve, 300)) + const projectIdForResult = query === '未知项目' ? unknownProjectId : projectId + await route.fulfill({ + json: { + items: [{ + id: taskSearchResultId, + type: 'task', + title: query === '未知项目' ? '未知项目任务' : query === '慢请求' ? '慢请求任务' : query === '快请求' ? '快请求任务' : '回调任务', + projectId: projectIdForResult, + snippet: '检查签名', + }], + }, + }) + return + } + if (url.pathname === `/api/v1/projects/${projectId}` && method === 'PATCH') { + const input = route.request().postDataJSON() + projectPatchRequests.push(input) + if (input.description === '触发失败') { + await new Promise((resolve) => setTimeout(resolve, 250)) + await route.fulfill({ + status: 500, + json: { error: { code: 'internal_error', message: '项目设置保存失败,请稍后重试' } }, + }) + return + } + Object.assign(visualCheckWorkspace.project, input) + await route.fulfill({ json: visualCheckWorkspace.project }) + return + } await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } }) }) @@ -282,6 +331,95 @@ if (await channelNavButton.count() === 0) { } await page.setViewportSize({ width: 1440, height: 1024 }) +const searchInput = page.getByPlaceholder('搜索项目、任务和笔记') +const searchButton = page.getByRole('button', { name: '搜索', exact: true }) +if (await searchInput.count() === 0 || await searchButton.count() === 0) { + failures.push('topbar must expose the connected project/task/note search controls') +} else { + await searchInput.fill('回调') + await searchInput.press('Enter') + await page.waitForTimeout(150) + if (!searchRequests.includes('回调')) failures.push(`search Enter must request q=回调, got ${JSON.stringify(searchRequests)}`) + const callbackResult = page.getByRole('button', { name: /回调任务/ }) + if (await callbackResult.count() === 0) { + failures.push('search results must render in a selectable dropdown list') + } else { + await callbackResult.click() + const activeAfterSearch = await page.locator('.channel-button.active').textContent() + if (!activeAfterSearch?.includes('工作计划')) { + failures.push(`task search result must navigate to 工作计划, got ${JSON.stringify(activeAfterSearch)}`) + } + } + + await searchInput.fill('未知项目') + await searchButton.click() + await page.waitForTimeout(150) + const unknownResult = page.getByRole('button', { name: /未知项目任务/ }) + if (await unknownResult.count() === 0) { + failures.push('Search button must submit the current query and show results') + } else { + const projectBeforeUnknownResult = await page.locator('.project-title h5').textContent() + await unknownResult.click() + const projectAfterUnknownResult = await page.locator('.project-title h5').textContent() + if (projectAfterUnknownResult !== projectBeforeUnknownResult) { + failures.push('a result for an unavailable project must not fabricate project navigation') + } + } + + await searchInput.fill('慢请求') + await searchInput.press('Enter') + await page.waitForTimeout(30) + await searchInput.fill('快请求') + await searchInput.press('Enter') + await page.waitForTimeout(400) + if (await page.getByRole('button', { name: /慢请求任务/ }).count() !== 0) { + failures.push('a stale search response must not replace newer results') + } + if (await page.getByRole('button', { name: /快请求任务/ }).count() === 0) { + failures.push('the newest search response must remain visible after requests resolve out of order') + } + await searchInput.fill('') +} + +const settingsButton = page.getByRole('button', { name: '编辑项目设置', exact: true }) +if (await settingsButton.count() === 0) { + failures.push('project sidebar must expose an accessible project settings button') +} else { + await settingsButton.click() + let settingsModal = page.getByRole('dialog', { name: '编辑项目' }) + await settingsModal.getByLabel('名称').fill('森林项目已更新') + await settingsModal.getByRole('button', { name: '确定', exact: true }).click() + await settingsModal.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {}) + if (projectPatchRequests[0]?.name !== '森林项目已更新') { + failures.push(`project settings must PATCH the edited identity, got ${JSON.stringify(projectPatchRequests)}`) + } + if (await page.locator('.project-title h5').textContent() !== '森林项目已更新') { + failures.push('successful project settings update must reload the same project identity') + } + if (await settingsModal.isVisible()) failures.push('successful project settings update must close the modal') + + await settingsButton.click() + settingsModal = page.getByRole('dialog', { name: '编辑项目' }) + await settingsModal.getByLabel('简介').fill('触发失败') + expectingProjectPatchError = true + await settingsModal.getByRole('button', { name: '确定', exact: true }).click() + await page.waitForTimeout(50) + const confirmButton = settingsModal.getByRole('button', { name: '确定', exact: true }) + if (!await confirmButton.evaluate((button) => button.classList.contains('arco-btn-loading'))) { + failures.push('project settings confirmation must show loading while PATCH is pending') + } + await page.waitForTimeout(300) + if (!await settingsModal.isVisible()) failures.push('failed project settings update must keep the modal open') + if (!await settingsModal.getByText('项目设置保存失败,请稍后重试', { exact: true }).isVisible()) { + failures.push('failed project settings update must render the API Chinese error inside the modal') + } + expectingProjectPatchError = false + if (expectedProjectPatchConsoleErrorCount !== 1) { + failures.push(`expected one simulated PATCH console error, got ${expectedProjectPatchConsoleErrorCount}`) + } + await settingsModal.getByRole('button', { name: '取消', exact: true }).click() +} + await page.locator('.channel-sidebar').hover() const channelSidebarHoverMetrics = await collectMetrics() await page.locator('.stage').hover() diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index 982af23..65f449f 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -227,6 +227,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 95acfd6..6c74c63 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' @@ -33,7 +33,7 @@ export function ProjectSidebar({ onClose: () => void onSelectChannel: (key: ChannelKey) => void onSelectItem: (title: string) => void - onUpdateProject: (update: ProjectSettingsUpdate) => void + onUpdateProject: (update: ProjectSettingsUpdate) => Promise }) { const workspaceMode = activeView === 'workspace' const channels = activeWorkspace.channels @@ -42,6 +42,8 @@ export function ProjectSidebar({ onClose() } 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, @@ -52,6 +54,7 @@ export function ProjectSidebar({ useEffect(() => { if (!settingsOpen) return + setSettingsError('') setDraft({ name: activeWorkspace.project.name, identifier: activeWorkspace.project.identifier, @@ -69,7 +72,7 @@ export function ProjectSidebar({ {workspaceMode ? : {projectIcon}} {workspaceMode ? '工作台' : activeWorkspace.project.name} - + + )} + /> + + ) return (
@@ -26,10 +81,28 @@ export function ProjectTopbar({ 森林AI -
- } placeholder="搜索项目、频道、文档、任务、联系人..." /> - -
+ setPopupVisible(visible && searched)} + > +
+ } + placeholder="搜索项目、任务和笔记" + value={query} + onChange={(value) => { + setPopupVisible(false) + onQueryChange(value) + }} + onPressEnter={submitSearch} + /> + +
+