feat: connect search and project settings

This commit is contained in:
2026-07-21 17:16:45 +08:00
parent bc1cd19c51
commit 110423406b
15 changed files with 825 additions and 139 deletions

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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<Screen>('login')
@@ -34,6 +37,7 @@ function App() {
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(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}
/>
) : (
<Spin loading />
@@ -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

View File

@@ -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<SearchResultDTO[]>([])
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,
}
}

View File

@@ -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<void>
}) {
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 ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
</Space>
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
<Button aria-label="编辑项目设置" icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
</div>
<div className="channel-list">
@@ -124,20 +127,34 @@ export function ProjectSidebar({
className="action-modal"
title="编辑项目"
visible={settingsOpen}
onCancel={() => setSettingsOpen(false)}
onOk={() => {
onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
confirmLoading={settingsSaving}
maskClosable={!settingsSaving}
cancelButtonProps={{ disabled: settingsSaving }}
onCancel={() => {
if (!settingsSaving) setSettingsOpen(false)
}}
onOk={async () => {
setSettingsSaving(true)
setSettingsError('')
try {
await onUpdateProject({
projectId: activeWorkspace.project.id,
name: draft.name.trim() || activeWorkspace.project.name,
identifier: draft.identifier.trim(),
icon: draft.icon.trim(),
background: draft.background.trim(),
description: draft.description.trim(),
})
setSettingsOpen(false)
} catch (error) {
setSettingsError(error instanceof Error ? error.message : '项目设置保存失败,请稍后重试')
} finally {
setSettingsSaving(false)
}
}}
>
<Space direction="vertical" size={12} className="action-form">
{settingsError && <Alert className="project-settings-error" type="error" content={settingsError} />}
<label>
<Text></Text>
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />

View File

@@ -1,5 +1,7 @@
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
import { useEffect, useState } from 'react'
import { Button, Dropdown, Input, Layout, List, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { SearchResultDTO } from '../../api/search'
import type { Theme } from './project-types'
const { Header } = Layout
@@ -11,14 +13,67 @@ export function ProjectTopbar({
showChannels,
onOpenProjects,
onOpenChannels,
query,
loading,
searched,
results,
onQueryChange,
onSearch,
onSelectSearchResult,
}: {
theme: Theme
onToggleTheme: () => void
showChannels: boolean
onOpenProjects: () => void
onOpenChannels: () => void
query: string
loading: boolean
searched: boolean
results: SearchResultDTO[]
onQueryChange: (value: string) => void
onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void
}) {
const desktop = isDesktopRuntime()
const [popupVisible, setPopupVisible] = useState(false)
useEffect(() => {
if (!loading && searched) setPopupVisible(true)
}, [loading, results, searched])
const submitSearch = () => {
setPopupVisible(false)
onSearch()
}
const searchResults = (
<div className="search-results-dropdown" aria-label="搜索结果">
<List<SearchResultDTO>
size="small"
bordered={false}
dataSource={results}
noDataElement="未找到匹配结果"
render={(result) => (
<List.Item key={`${result.type}:${result.id}`}>
<button
type="button"
className="search-result-item"
onClick={() => {
setPopupVisible(false)
onSelectSearchResult(result)
}}
>
<span className="search-result-heading">
<Text>{result.title}</Text>
<Text className="search-result-type" type="secondary">{searchTypeLabel(result.type)}</Text>
</span>
{result.snippet && <Text className="search-result-snippet" type="secondary">{result.snippet}</Text>}
</button>
</List.Item>
)}
/>
</div>
)
return (
<Header className="topbar">
@@ -26,10 +81,28 @@ export function ProjectTopbar({
<img className="brand-icon" src="/senlinai-icon.svg" alt="" />
<Text className="brand-name">AI</Text>
</div>
<div className="global-search" role="search">
<Input size="large" prefix={<IconSearch />} placeholder="搜索项目、频道、文档、任务、联系人..." />
<Button size="large" type="primary"></Button>
</div>
<Dropdown
droplist={searchResults}
popupVisible={popupVisible && searched}
trigger="click"
position="bl"
onVisibleChange={(visible) => setPopupVisible(visible && searched)}
>
<div className="global-search" role="search">
<Input
size="large"
prefix={<IconSearch />}
placeholder="搜索项目、任务和笔记"
value={query}
onChange={(value) => {
setPopupVisible(false)
onQueryChange(value)
}}
onPressEnter={submitSearch}
/>
<Button size="large" type="primary" loading={loading} onClick={submitSearch}></Button>
</div>
</Dropdown>
<Space className="topbar-actions">
<Button className="mobile-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
{showChannels && (
@@ -48,6 +121,12 @@ export function ProjectTopbar({
)
}
function searchTypeLabel(type: SearchResultDTO['type']) {
if (type === 'project') return '项目'
if (type === 'task') return '任务'
return '笔记'
}
function isDesktopRuntime() {
return typeof window !== 'undefined' && '__TAURI__' in window
}

View File

@@ -8,6 +8,7 @@ import { ProjectRail } from './projects/project-rail'
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search'
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
const { Content } = Layout
@@ -34,6 +35,13 @@ export function ProjectPage({
onUpdateWorkspaceTask,
onUpdateProject,
onCreateProjectTag,
searchQuery,
searchLoading,
searchSearched,
searchResults,
onSearchQueryChange,
onSearch,
onSelectSearchResult,
}: {
activeView: WorkbenchView
activeWorkspace: ProjectWorkspace
@@ -54,8 +62,15 @@ export function ProjectPage({
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
onCreateProjectTag: (name: string) => void
searchQuery: string
searchLoading: boolean
searchSearched: boolean
searchResults: SearchResultDTO[]
onSearchQueryChange: (value: string) => void
onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void
}) {
const isProject = activeView === 'project'
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
@@ -80,6 +95,13 @@ export function ProjectPage({
showChannels={isProject}
onOpenProjects={() => setNavOpen('projects')}
onOpenChannels={() => setNavOpen('channels')}
query={searchQuery}
loading={searchLoading}
searched={searchSearched}
results={searchResults}
onQueryChange={onSearchQueryChange}
onSearch={onSearch}
onSelectSearchResult={onSelectSearchResult}
/>
<Layout className="workbench-main" hasSider>