fix(web): preserve saved writes across refresh failures
This commit is contained in:
41
apps/web_v1/scripts/mutation-refresh.test.mjs
Normal file
41
apps/web_v1/scripts/mutation-refresh.test.mjs
Normal file
@@ -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'])
|
||||
})
|
||||
42
apps/web_v1/scripts/mutation-wiring.test.mjs
Normal file
42
apps/web_v1/scripts/mutation-wiring.test.mjs
Normal file
@@ -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\(\)/)
|
||||
})
|
||||
@@ -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('暂停</Tag>')) {
|
||||
failures.push('Cron summary must describe enabled metadata as 已启用/已停用')
|
||||
}
|
||||
const projectOverviewSource = readFileSync('src/pages/projects/project-overview.tsx', 'utf8')
|
||||
if (
|
||||
projectOverviewSource.includes('<span>进度 {task.progress}</span>')
|
||||
&& !projectOverviewSource.includes('{task.progress && <span>进度 {task.progress}</span>}')
|
||||
) {
|
||||
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`)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('login')
|
||||
@@ -44,6 +50,8 @@ function App() {
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||
const [refreshRecovery, setRefreshRecovery] = useState<RefreshRecovery | null>(null)
|
||||
const [refreshRetrying, setRefreshRetrying] = useState(false)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
const workspaceRefreshGate = useRef<WorkspaceRefreshGate | null>(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<void>) {
|
||||
setRefreshRecovery({ message: '数据已保存,但页面刷新失败。', retry })
|
||||
Message.warning('已保存但刷新失败,请点击“刷新数据”重试')
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<void>, 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<T>(mutate: () => Promise<T>, refresh: (value: T) => Promise<void>, 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 (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
{refreshRecovery && (
|
||||
<Alert
|
||||
className="refresh-recovery-alert"
|
||||
type="warning"
|
||||
content={refreshRecovery.message}
|
||||
action={<Button size="small" loading={refreshRetrying} onClick={() => void retryFailedRefresh()}>刷新数据</Button>}
|
||||
/>
|
||||
)}
|
||||
{screen === 'login' ? (
|
||||
<Spin loading={loading} style={{ width: '100%' }}>
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
|
||||
27
apps/web_v1/src/app/mutation-refresh.ts
Normal file
27
apps/web_v1/src/app/mutation-refresh.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type SavedMutationOptions<T> = {
|
||||
mutate: () => Promise<T>
|
||||
onSaved: (value: T) => void
|
||||
refresh: (value: T) => Promise<void>
|
||||
}
|
||||
|
||||
export type SavedMutationOutcome<T> = {
|
||||
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<T>({
|
||||
mutate,
|
||||
onSaved,
|
||||
refresh,
|
||||
}: SavedMutationOptions<T>): Promise<SavedMutationOutcome<T>> {
|
||||
const value = await mutate()
|
||||
onSaved(value)
|
||||
try {
|
||||
await refresh(value)
|
||||
return { value }
|
||||
} catch (refreshError) {
|
||||
return { value, refreshError }
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
|
||||
<div className="section-header">
|
||||
<Title heading={6}>任务列表</Title>
|
||||
<Space>
|
||||
<Tag color="green">{enabledCount} 运行中</Tag>
|
||||
<Tag color="gray">{pausedCount} 暂停</Tag>
|
||||
<Tag color="green">{enabledCount} 已启用</Tag>
|
||||
<Tag color="gray">{pausedCount} 已停用</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
{cronJobs.map((job) => (
|
||||
|
||||
@@ -105,7 +105,7 @@ function TaskCardSection({
|
||||
<Text type="secondary" ellipsis={{ showTooltip: true }}>{task.summary || '暂无任务内容'}</Text>
|
||||
<div className="overview-task-meta">
|
||||
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag || (task.completed ? '已完成' : '进行中')}</Tag>
|
||||
<span>进度 {task.progress}</span>
|
||||
{task.progress && <span>进度 {task.progress}</span>}
|
||||
<span>创建 {task.createdAt}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -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 ? <IconClose /> : <IconSearch />}
|
||||
onClick={() => setMobileSearchOpen((open) => !open)}
|
||||
onClick={() => setMobileSearchOpen((open) => {
|
||||
if (open) setPopupVisible(false)
|
||||
return !open
|
||||
})}
|
||||
/>
|
||||
<Button className="mobile-nav-button project-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
|
||||
{showChannels && (
|
||||
|
||||
Reference in New Issue
Block a user