From 89d2f4659fe984082c506d5e2e7d47124c7fec7d Mon Sep 17 00:00:00 2001 From: yanweidong Date: Wed, 22 Jul 2026 13:13:03 +0800 Subject: [PATCH] fix(web): preserve saved writes across refresh failures --- apps/web_v1/scripts/mutation-refresh.test.mjs | 41 ++++ apps/web_v1/scripts/mutation-wiring.test.mjs | 42 ++++ apps/web_v1/scripts/structure-check.mjs | 18 ++ apps/web_v1/scripts/visual-check.mjs | 40 +++- apps/web_v1/src/App.css | 9 + apps/web_v1/src/api/mappers.tsx | 4 +- apps/web_v1/src/app/App.tsx | 185 ++++++++++++------ apps/web_v1/src/app/mutation-refresh.ts | 27 +++ .../src/pages/projects/project-cron.tsx | 4 +- .../src/pages/projects/project-overview.tsx | 2 +- .../src/pages/projects/project-topbar.tsx | 6 +- 11 files changed, 304 insertions(+), 74 deletions(-) create mode 100644 apps/web_v1/scripts/mutation-refresh.test.mjs create mode 100644 apps/web_v1/scripts/mutation-wiring.test.mjs create mode 100644 apps/web_v1/src/app/mutation-refresh.ts diff --git a/apps/web_v1/scripts/mutation-refresh.test.mjs b/apps/web_v1/scripts/mutation-refresh.test.mjs new file mode 100644 index 0000000..0ded4e3 --- /dev/null +++ b/apps/web_v1/scripts/mutation-refresh.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { executeSavedMutation } from '../src/app/mutation-refresh.ts' + +test('a saved mutation stays successful when its refresh fails', async () => { + const events = [] + const refreshFailure = new Error('refresh unavailable') + + const outcome = await executeSavedMutation({ + mutate: async () => { + events.push('mutate') + return { id: 'created-1' } + }, + onSaved: (value) => events.push(`saved:${value.id}`), + refresh: async (value) => { + events.push(`refresh:${value.id}`) + throw refreshFailure + }, + }) + + assert.deepEqual(events, ['mutate', 'saved:created-1', 'refresh:created-1']) + assert.equal(outcome.value.id, 'created-1') + assert.equal(outcome.refreshError, refreshFailure) +}) + +test('a failed mutation does not close its draft or attempt a refresh', async () => { + const events = [] + const mutationFailure = new Error('save rejected') + + await assert.rejects(() => executeSavedMutation({ + mutate: async () => { + events.push('mutate') + throw mutationFailure + }, + onSaved: () => events.push('saved'), + refresh: async () => events.push('refresh'), + }), mutationFailure) + + assert.deepEqual(events, ['mutate']) +}) diff --git a/apps/web_v1/scripts/mutation-wiring.test.mjs b/apps/web_v1/scripts/mutation-wiring.test.mjs new file mode 100644 index 0000000..8c90975 --- /dev/null +++ b/apps/web_v1/scripts/mutation-wiring.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const appSource = readFileSync(new URL('../src/app/App.tsx', import.meta.url), 'utf8') + +function functionSource(name, nextName) { + const start = appSource.indexOf(`function ${name}`) + const end = appSource.indexOf(`function ${nextName}`, start + 1) + assert.notEqual(start, -1, `missing ${name}`) + assert.notEqual(end, -1, `missing boundary ${nextName}`) + return appSource.slice(start, end) +} + +test('modal mutations close at the save boundary and refresh only their affected projects', () => { + for (const [name, nextName] of [ + ['handleCreateProject', 'handleCreateTask'], + ['handleCreateTask', 'handleUploadSource'], + ['handleUploadSource', 'handleCreateCronPlan'], + ['handleCreateCronPlan', 'handleCreateProjectTag'], + ['handleCreateProjectTag', 'openTask'], + ]) { + const source = functionSource(name, nextName) + assert.match(source, /runAction\(/, `${name} must use the saved-mutation boundary`) + } + assert.doesNotMatch(appSource, /refreshAfterAction/) + assert.match(functionSource('handleUpdateWorkspaceTask', 'handleUpdateProject'), /refreshProjectWorkspaces/) +}) + +test('project settings and Inbox capture keep save success separate from refresh failure', () => { + assert.match(functionSource('handleUpdateProject', 'handleAnalyzeInbox'), /executeSavedMutation/) + assert.match(functionSource('handleCaptureInbox', 'handleConfirmInbox'), /executeSavedMutation/) + const confirmSource = functionSource('handleConfirmInbox', 'handleSelectSearchResult') + assert.match(confirmSource, /offerRefreshRecovery/) + assert.match(confirmSource, /createdCount: response\.createdCount/) +}) + +test('refresh recovery remains actionable after a save', () => { + assert.match(appSource, /数据已保存,但页面刷新失败/) + assert.match(appSource, />刷新数据<\/Button>/) + assert.match(appSource, /refreshRecovery\.retry\(\)/) +}) diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 97dc555..7f1ebce 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs' const requiredFiles = [ 'src/app/App.tsx', + 'src/app/mutation-refresh.ts', 'src/pages/login.tsx', 'src/pages/workspace-body.tsx', 'src/pages/workspace-home.tsx', @@ -28,6 +29,8 @@ const requiredFiles = [ 'src/api/ai.ts', 'scripts/api-client.test.mjs', 'scripts/workspace-refresh-gate.test.mjs', + 'scripts/mutation-refresh.test.mjs', + 'scripts/mutation-wiring.test.mjs', ] const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`) @@ -212,6 +215,21 @@ if (appStyles.includes('.brand-symbol')) failures.push('legacy brand-symbol styl if (!appStyles.includes('@media (max-width: 1179px)')) failures.push('channel sidebar must collapse below 1180px') if (!appStyles.includes('.project-stack-scroll')) failures.push('long project rails need a dedicated scroll region') if (!appStyles.includes('.mobile-search-button')) failures.push('mobile workbench must retain a global search entry') +const mapperSource = readFileSync('src/api/mappers.tsx', 'utf8') +if (mapperSource.includes("plan.enabled ? '运行中' : '暂停'")) { + failures.push('Cron enabled metadata must use 已启用/已停用 instead of execution-state wording') +} +const cronSource = readFileSync('src/pages/projects/project-cron.tsx', 'utf8') +if (cronSource.includes('运行中') || cronSource.includes('暂停')) { + failures.push('Cron summary must describe enabled metadata as 已启用/已停用') +} +const projectOverviewSource = readFileSync('src/pages/projects/project-overview.tsx', 'utf8') +if ( + projectOverviewSource.includes('进度 {task.progress}') + && !projectOverviewSource.includes('{task.progress && 进度 {task.progress}}') +) { + failures.push('project overview must hide task progress when the backend provides no real progress') +} for (const file of ['src/pages/login.tsx', 'src/pages/projects/project-topbar.tsx', 'src/pages/projects/project-rail.tsx']) { const source = readFileSync(file, 'utf8') if (!source.includes('/senlinai-icon.svg')) failures.push(`${file} must use the brand icon`) diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 3af18a8..fde95ef 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -684,13 +684,31 @@ if (await mobileSearchButton.count() !== 1) { failures.push('390px workspace must provide one global search entry') } else { await mobileSearchButton.click() - await page.getByPlaceholder('搜索项目、任务和笔记').waitFor({ state: 'visible' }) + const mobileSearchInput = page.getByPlaceholder('搜索项目、任务和笔记') + await mobileSearchInput.waitFor({ state: 'visible' }) + await mobileSearchInput.fill('回调') + await mobileSearchInput.press('Enter') + const mobileSearchResult = page.getByRole('button', { name: /回调任务/ }) + await mobileSearchResult.waitFor({ state: 'visible', timeout: 2000 }).catch(() => {}) + if (!await mobileSearchResult.isVisible().catch(() => false)) { + failures.push('390px search must keep its result panel visible after submit') + } else { + const mobileSearchLayerVisible = await page.evaluate(() => { + const search = document.querySelector('.global-search') + return Boolean(search && getComputedStyle(search).display !== 'none') + }) + if (!mobileSearchLayerVisible) failures.push('390px search must keep its input layer anchored until result selection') + await page.screenshot({ path: 'test-results/workbench-mobile-search-results.png' }) + await mobileSearchResult.click() + const mobileActiveChannel = await page.locator('.channel-button.active').textContent().catch(() => '') + if (!mobileActiveChannel?.includes('工作计划')) { + failures.push('390px search result selection must navigate to its task channel') + } + if (await page.getByRole('button', { name: '关闭全局搜索', exact: true }).count() !== 0) { + failures.push('390px search panel must close after selecting a result') + } + } await page.screenshot({ path: 'test-results/workbench-mobile-search.png' }) - await page.getByRole('button', { name: '关闭全局搜索', exact: true }).click() - await page.waitForFunction(() => { - const search = document.querySelector('.global-search') - return search && getComputedStyle(search).display === 'none' - }) } await page.setViewportSize({ width: 1440, height: 1024 }) @@ -1193,6 +1211,16 @@ if (await inboxChannelButton.count() === 0) { if (!await secondConfirmButton.isDisabled()) { failures.push('a confirmed item must remain non-repeatable when its workspace refresh fails') } + const recoveryRefreshButton = page.getByRole('button', { name: '刷新数据', exact: true }) + if (!await recoveryRefreshButton.isVisible().catch(() => false)) { + failures.push('a saved write with a failed refresh must provide an actionable recovery refresh') + } else { + await recoveryRefreshButton.click() + await recoveryRefreshButton.waitFor({ state: 'hidden', timeout: 2000 }).catch(() => {}) + if (await recoveryRefreshButton.isVisible().catch(() => false)) { + failures.push('a successful recovery refresh must clear its warning action') + } + } expectingInboxRefreshError = false if (expectedInboxRefreshConsoleErrorCount !== 1) { failures.push(`expected one simulated Inbox refresh console error, got ${expectedInboxRefreshConsoleErrorCount}`) diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index 47a79a3..1db6d43 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -15,6 +15,15 @@ --color-status: #007acc; } +.refresh-recovery-alert { + position: fixed; + top: 66px; + right: 16px; + z-index: 80; + width: min(420px, calc(100vw - 32px)); + box-shadow: 0 12px 28px rgba(29, 33, 41, 0.18); +} + .theme-dark .arco-layout, .theme-dark .arco-card, .theme-dark .arco-tabs, diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx index 4263d55..cb70b9f 100644 --- a/apps/web_v1/src/api/mappers.tsx +++ b/apps/web_v1/src/api/mappers.tsx @@ -210,9 +210,9 @@ function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob { id: plan.id, name: plan.title, expr: plan.schedule, - status: plan.enabled ? '运行中' : '暂停', + status: plan.enabled ? '已启用' : '已停用', lastRun: plan.lastResult, - nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中', + nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '未安排', owner: plan.owner, enabled: plan.enabled, } diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 339f397..ef0215e 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { ConfigProvider, Message, Spin } from '@arco-design/web-react' +import { Alert, Button, 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' @@ -28,6 +28,12 @@ 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' import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate' +import { executeSavedMutation } from './mutation-refresh' + +type RefreshRecovery = { + message: string + retry: () => Promise +} function App() { const [screen, setScreen] = useState('login') @@ -44,6 +50,8 @@ function App() { const [actionLoading, setActionLoading] = useState(false) const [activeModal, setActiveModal] = useState(null) const [searchResultPreview, setSearchResultPreview] = useState(null) + const [refreshRecovery, setRefreshRecovery] = useState(null) + const [refreshRetrying, setRefreshRetrying] = useState(false) const workspaceSearch = useWorkbenchSearch(session) const workspaceRefreshGate = useRef(null) const actionInFlight = useRef(false) @@ -88,20 +96,30 @@ function App() { return backendWorkspaces } - async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) { + async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) { + const uniqueProjectIDs = [...new Set(projectIDs.filter(Boolean))] + if (uniqueProjectIDs.length === 0) return [] const request = workspaceRefreshGate.current!.begin() - const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID) - const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal) + const refreshed = await Promise.all(uniqueProjectIDs.map(async (projectID) => { + const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID) + const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal) + return mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex) + })) if (!request.isCurrent()) throw staleWorkspaceRefreshError() - const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex) setWorkspaces((current) => { - const existingIndex = current.findIndex((workspace) => workspace.project.id === projectID) - if (existingIndex < 0) return [...current, refreshed] - return current.map((workspace, index) => index === existingIndex ? refreshed : workspace) + const byProjectID = new Map(refreshed.map((workspace) => [workspace.project.id, workspace])) + const merged = current.map((workspace) => byProjectID.get(workspace.project.id) ?? workspace) + const existingIDs = new Set(current.map((workspace) => workspace.project.id)) + return [...merged, ...refreshed.filter((workspace) => !existingIDs.has(workspace.project.id))] }) return refreshed } + async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) { + const [refreshed] = await refreshProjectWorkspaces(nextSession, [projectID]) + return refreshed + } + async function handleLogin(input: { server: string; email: string; password: string }) { setLoading(true) try { @@ -120,19 +138,44 @@ function App() { } } - async function refreshAfterAction(nextProjectID?: string) { - if (!session) return - await loadWorkspaces(session, nextProjectID) + function offerRefreshRecovery(retry: () => Promise) { + setRefreshRecovery({ message: '数据已保存,但页面刷新失败。', retry }) + Message.warning('已保存但刷新失败,请点击“刷新数据”重试') } - async function runAction(action: () => Promise, success: string) { + async function retryFailedRefresh() { + if (!refreshRecovery || refreshRetrying) return + setRefreshRetrying(true) + try { + await refreshRecovery.retry() + setRefreshRecovery(null) + Message.success('数据已刷新') + } catch (error) { + Message.error(error instanceof Error ? error.message : '刷新失败,请稍后重试') + } finally { + setRefreshRetrying(false) + } + } + + async function runAction(mutate: () => Promise, refresh: (value: T) => Promise, success: string, afterSaved?: (value: T) => void) { if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交') actionInFlight.current = true setActionLoading(true) try { - await action() - setActiveModal(null) - Message.success(success) + const outcome = await executeSavedMutation({ + mutate, + onSaved: (value) => { + setActiveModal(null) + afterSaved?.(value) + Message.success(success) + }, + refresh, + }) + if (outcome.refreshError) { + offerRefreshRecovery(() => refresh(outcome.value)) + } else { + setRefreshRecovery(null) + } } catch (error) { Message.error(error instanceof Error ? error.message : '操作失败') throw error @@ -162,22 +205,20 @@ function App() { Message.warning('请输入项目名称') return } - return runAction(async () => { - const created = await createProject(requireSession(), { + const currentSession = requireSession() + return runAction(() => createProject(currentSession, { 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) + }), (created) => loadWorkspaces(currentSession, created.id).then(() => undefined), '项目已创建', (created) => { + if (created.id) { + selectActiveProject(created.id) setActiveView('project') setActiveChannel('overview') } - }, '项目已创建') + }) } async function handleCreateTask(draft: TaskDraft) { @@ -185,16 +226,16 @@ function App() { Message.warning('请输入任务标题') return } - return runAction(async () => { - await createTask(requireSession(), requireActiveProject(), { + const currentSession = requireSession() + const projectID = requireActiveProject() + return runAction(() => createTask(currentSession, projectID, { title: draft.title.trim(), description: draft.description.trim(), status: 'open', tag: draft.tag.trim(), - }) - await refreshAfterAction() - setActiveChannel('tasks') - }, '任务已创建') + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '任务已创建', () => { + if (activeProjectIDRef.current === projectID) setActiveChannel('tasks') + }) } async function handleUploadSource(draft: SourceDraft) { @@ -203,14 +244,14 @@ function App() { return } const file = draft.file - return runAction(async () => { - await uploadSource(requireSession(), requireActiveProject(), { + const currentSession = requireSession() + const projectID = requireActiveProject() + return runAction(() => uploadSource(currentSession, projectID, { title: draft.title.trim(), file, - }) - await refreshAfterAction() - setActiveChannel('notes') - }, '文件已上传') + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '文件已上传', () => { + if (activeProjectIDRef.current === projectID) setActiveChannel('notes') + }) } async function handleCreateCronPlan(draft: CronDraft) { @@ -222,16 +263,16 @@ function App() { Message.warning('请输入 Cron 表达式') return } - return runAction(async () => { - await createCronPlan(requireSession(), requireActiveProject(), { + const currentSession = requireSession() + const projectID = requireActiveProject() + return runAction(() => createCronPlan(currentSession, projectID, { title: draft.title.trim(), schedule: draft.schedule.trim(), enabled: draft.enabled, nextRunAt: normalizeOptionalTime(draft.nextRunAt), - }) - await refreshAfterAction() - setActiveChannel('cron') - }, '计划任务已创建') + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '计划任务已创建', () => { + if (activeProjectIDRef.current === projectID) setActiveChannel('cron') + }) } async function handleCreateProjectTag(name: string) { @@ -240,10 +281,13 @@ function App() { Message.warning('请输入标签名称') return } - return runAction(async () => { - await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName }) - await refreshAfterAction() - }, '标签已创建') + const currentSession = requireSession() + const projectID = requireActiveProject() + return runAction( + () => createProjectTag(currentSession, projectID, { name: trimmedName }), + () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), + '标签已创建', + ) } function openTask(project: Project, taskID: string) { @@ -254,29 +298,32 @@ function App() { } function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) { - return runAction(async () => { - await updateTask(requireSession(), update.originalProjectId, update.taskId, { + const currentSession = requireSession() + return runAction(() => updateTask(currentSession, update.originalProjectId, update.taskId, { title: update.title, description: update.summary, completed: update.completed, nextProjectId: update.nextProjectId, tag: update.tag, - }) - await refreshAfterAction(update.nextProjectId) - }, '任务已更新') + }), () => refreshProjectWorkspaces(currentSession, [update.originalProjectId, update.nextProjectId]).then(() => undefined), '任务已更新') } 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, + const refresh = () => refreshProjectWorkspace(currentSession, update.projectId).then(() => undefined) + const outcome = await executeSavedMutation({ + mutate: () => updateProject(currentSession, update.projectId, { + name: update.name, + identifier: update.identifier, + icon: update.icon, + background: update.background, + description: update.description, + }), + onSaved: () => Message.success('项目设置已更新'), + refresh, }) - await loadWorkspaces(currentSession, update.projectId) - Message.success('项目设置已更新') + if (outcome.refreshError) offerRefreshRecovery(refresh) + else setRefreshRecovery(null) } async function handleAnalyzeInbox(inboxId: string) { @@ -286,9 +333,15 @@ function App() { async function handleCaptureInbox(projectId: string, input: CaptureInboxInput) { const currentSession = requireSession() - const item = await captureProjectInbox(currentSession, projectId, input) - await refreshProjectWorkspace(currentSession, projectId) - return item.id + const refresh = () => refreshProjectWorkspace(currentSession, projectId).then(() => undefined) + const outcome = await executeSavedMutation({ + mutate: () => captureProjectInbox(currentSession, projectId, input), + onSaved: () => Message.success('已收集到 Inbox'), + refresh, + }) + if (outcome.refreshError) offerRefreshRecovery(refresh) + else setRefreshRecovery(null) + return outcome.value.id } async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) { @@ -307,8 +360,10 @@ function App() { } try { await refreshProjectWorkspace(requireSession(), confirmationProjectID) + setRefreshRecovery(null) return { createdCount: response.createdCount } } catch { + offerRefreshRecovery(() => refreshProjectWorkspace(requireSession(), confirmationProjectID).then(() => undefined)) return { createdCount: response.createdCount, refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目', @@ -341,6 +396,14 @@ function App() { return (
+ {refreshRecovery && ( + void retryFailedRefresh()}>刷新数据} + /> + )} {screen === 'login' ? ( diff --git a/apps/web_v1/src/app/mutation-refresh.ts b/apps/web_v1/src/app/mutation-refresh.ts new file mode 100644 index 0000000..b090526 --- /dev/null +++ b/apps/web_v1/src/app/mutation-refresh.ts @@ -0,0 +1,27 @@ +export type SavedMutationOptions = { + mutate: () => Promise + onSaved: (value: T) => void + refresh: (value: T) => Promise +} + +export type SavedMutationOutcome = { + value: T + refreshError?: unknown +} + +// executeSavedMutation establishes the write boundary: once mutate resolves, +// later refresh failures may affect freshness but can never negate the save. +export async function executeSavedMutation({ + mutate, + onSaved, + refresh, +}: SavedMutationOptions): Promise> { + const value = await mutate() + onSaved(value) + try { + await refresh(value) + return { value } + } catch (refreshError) { + return { value, refreshError } + } +} diff --git a/apps/web_v1/src/pages/projects/project-cron.tsx b/apps/web_v1/src/pages/projects/project-cron.tsx index 2717d50..d3a5d8a 100644 --- a/apps/web_v1/src/pages/projects/project-cron.tsx +++ b/apps/web_v1/src/pages/projects/project-cron.tsx @@ -23,8 +23,8 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
任务列表 - {enabledCount} 运行中 - {pausedCount} 暂停 + {enabledCount} 已启用 + {pausedCount} 已停用
{cronJobs.map((job) => ( diff --git a/apps/web_v1/src/pages/projects/project-overview.tsx b/apps/web_v1/src/pages/projects/project-overview.tsx index 4e970ed..4c08f1d 100644 --- a/apps/web_v1/src/pages/projects/project-overview.tsx +++ b/apps/web_v1/src/pages/projects/project-overview.tsx @@ -105,7 +105,7 @@ function TaskCardSection({ {task.summary || '暂无任务内容'}
{task.tag || (task.completed ? '已完成' : '进行中')} - 进度 {task.progress} + {task.progress && 进度 {task.progress}} 创建 {task.createdAt}
diff --git a/apps/web_v1/src/pages/projects/project-topbar.tsx b/apps/web_v1/src/pages/projects/project-topbar.tsx index e70e7ff..ea3355e 100644 --- a/apps/web_v1/src/pages/projects/project-topbar.tsx +++ b/apps/web_v1/src/pages/projects/project-topbar.tsx @@ -43,7 +43,6 @@ export function ProjectTopbar({ const submitSearch = () => { setPopupVisible(false) - setMobileSearchOpen(false) onSearch() } @@ -110,7 +109,10 @@ export function ProjectTopbar({ className="mobile-search-button" aria-label={mobileSearchOpen ? '关闭全局搜索' : '打开全局搜索'} icon={mobileSearchOpen ? : } - onClick={() => setMobileSearchOpen((open) => !open)} + onClick={() => setMobileSearchOpen((open) => { + if (open) setPopupVisible(false) + return !open + })} />