fix(web): isolate project refresh recovery
This commit is contained in:
@@ -38,5 +38,7 @@ test('project settings and Inbox capture keep save success separate from refresh
|
|||||||
test('refresh recovery remains actionable after a save', () => {
|
test('refresh recovery remains actionable after a save', () => {
|
||||||
assert.match(appSource, /数据已保存,但页面刷新失败/)
|
assert.match(appSource, /数据已保存,但页面刷新失败/)
|
||||||
assert.match(appSource, />刷新数据<\/Button>/)
|
assert.match(appSource, />刷新数据<\/Button>/)
|
||||||
assert.match(appSource, /refreshRecovery\.retry\(\)/)
|
assert.match(appSource, /pendingRefreshProjectIDs/)
|
||||||
|
assert.match(appSource, /refreshProjectWorkspaces\(requireSession\(\), targets\)/)
|
||||||
|
assert.match(appSource, /clearPendingRefreshProjects\(current, \[projectID\]\)/)
|
||||||
})
|
})
|
||||||
|
|||||||
57
apps/web_v1/scripts/project-refresh-coordinator.test.mjs
Normal file
57
apps/web_v1/scripts/project-refresh-coordinator.test.mjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearPendingRefreshProjects,
|
||||||
|
createProjectRefreshCoordinator,
|
||||||
|
mergePendingRefreshProjects,
|
||||||
|
} from '../src/app/project-refresh-coordinator.ts'
|
||||||
|
|
||||||
|
test('project A and B refresh generations do not abort each other', () => {
|
||||||
|
const coordinator = createProjectRefreshCoordinator()
|
||||||
|
const projectA = coordinator.beginProject('project-a')
|
||||||
|
const projectB = coordinator.beginProject('project-b')
|
||||||
|
|
||||||
|
assert.equal(projectA.signal.aborted, false)
|
||||||
|
assert.equal(projectA.isCurrent(), true)
|
||||||
|
assert.equal(projectB.signal.aborted, false)
|
||||||
|
assert.equal(projectB.isCurrent(), true)
|
||||||
|
|
||||||
|
const newerProjectA = coordinator.beginProject('project-a')
|
||||||
|
assert.equal(projectA.signal.aborted, true)
|
||||||
|
assert.equal(projectB.signal.aborted, false)
|
||||||
|
assert.equal(newerProjectA.isCurrent(), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('project B success cannot clear project A pending recovery', () => {
|
||||||
|
let pending = mergePendingRefreshProjects([], ['project-a'])
|
||||||
|
|
||||||
|
pending = clearPendingRefreshProjects(pending, ['project-b'])
|
||||||
|
|
||||||
|
assert.deepEqual(pending, ['project-a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovery clears only successful targets and full success clears all', () => {
|
||||||
|
let pending = mergePendingRefreshProjects([], ['project-a', 'project-b'])
|
||||||
|
|
||||||
|
pending = clearPendingRefreshProjects(pending, ['project-a'])
|
||||||
|
assert.deepEqual(pending, ['project-b'])
|
||||||
|
|
||||||
|
pending = clearPendingRefreshProjects(pending)
|
||||||
|
assert.deepEqual(pending, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('full and targeted refreshes supersede only when their coverage overlaps globally', () => {
|
||||||
|
const coordinator = createProjectRefreshCoordinator()
|
||||||
|
const projectA = coordinator.beginProject('project-a')
|
||||||
|
const projectB = coordinator.beginProject('project-b')
|
||||||
|
const full = coordinator.beginFull()
|
||||||
|
|
||||||
|
assert.equal(projectA.signal.aborted, true)
|
||||||
|
assert.equal(projectB.signal.aborted, true)
|
||||||
|
assert.equal(full.isCurrent(), true)
|
||||||
|
|
||||||
|
const newerProjectA = coordinator.beginProject('project-a')
|
||||||
|
assert.equal(full.signal.aborted, true)
|
||||||
|
assert.equal(newerProjectA.isCurrent(), true)
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|||||||
const requiredFiles = [
|
const requiredFiles = [
|
||||||
'src/app/App.tsx',
|
'src/app/App.tsx',
|
||||||
'src/app/mutation-refresh.ts',
|
'src/app/mutation-refresh.ts',
|
||||||
|
'src/app/project-refresh-coordinator.ts',
|
||||||
'src/pages/login.tsx',
|
'src/pages/login.tsx',
|
||||||
'src/pages/workspace-body.tsx',
|
'src/pages/workspace-body.tsx',
|
||||||
'src/pages/workspace-home.tsx',
|
'src/pages/workspace-home.tsx',
|
||||||
@@ -31,6 +32,7 @@ const requiredFiles = [
|
|||||||
'scripts/workspace-refresh-gate.test.mjs',
|
'scripts/workspace-refresh-gate.test.mjs',
|
||||||
'scripts/mutation-refresh.test.mjs',
|
'scripts/mutation-refresh.test.mjs',
|
||||||
'scripts/mutation-wiring.test.mjs',
|
'scripts/mutation-wiring.test.mjs',
|
||||||
|
'scripts/project-refresh-coordinator.test.mjs',
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
||||||
@@ -62,7 +64,7 @@ if (existsSync('src/app/App.tsx')) {
|
|||||||
failures.push(`src/app/App.tsx still contains ${forbidden}`)
|
failures.push(`src/app/App.tsx still contains ${forbidden}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const required of ['createWorkspaceRefreshGate', 'captureProjectInbox', 'refreshProjectWorkspace']) {
|
for (const required of ['createProjectRefreshCoordinator', 'pendingRefreshProjectIDs', 'captureProjectInbox', 'refreshProjectWorkspace']) {
|
||||||
if (!appSource.includes(required)) failures.push(`src/app/App.tsx must include ${required}`)
|
if (!appSource.includes(required)) failures.push(`src/app/App.tsx must include ${required}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ import { ProjectPage } from '../pages/workspace-home'
|
|||||||
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||||
import { useWorkbenchSearch } from './use-workbench-search'
|
import { useWorkbenchSearch } from './use-workbench-search'
|
||||||
import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate'
|
|
||||||
import { executeSavedMutation } from './mutation-refresh'
|
import { executeSavedMutation } from './mutation-refresh'
|
||||||
|
import {
|
||||||
type RefreshRecovery = {
|
clearPendingRefreshProjects,
|
||||||
message: string
|
createProjectRefreshCoordinator,
|
||||||
retry: () => Promise<void>
|
mergePendingRefreshProjects,
|
||||||
}
|
type ProjectRefreshCoordinator,
|
||||||
|
} from './project-refresh-coordinator'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [screen, setScreen] = useState<Screen>('login')
|
const [screen, setScreen] = useState<Screen>('login')
|
||||||
@@ -50,14 +50,14 @@ function App() {
|
|||||||
const [actionLoading, setActionLoading] = useState(false)
|
const [actionLoading, setActionLoading] = useState(false)
|
||||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||||
const [refreshRecovery, setRefreshRecovery] = useState<RefreshRecovery | null>(null)
|
const [pendingRefreshProjectIDs, setPendingRefreshProjectIDs] = useState<string[]>([])
|
||||||
const [refreshRetrying, setRefreshRetrying] = useState(false)
|
const [refreshRetrying, setRefreshRetrying] = useState(false)
|
||||||
const workspaceSearch = useWorkbenchSearch(session)
|
const workspaceSearch = useWorkbenchSearch(session)
|
||||||
const workspaceRefreshGate = useRef<WorkspaceRefreshGate | null>(null)
|
const refreshCoordinator = useRef<ProjectRefreshCoordinator | null>(null)
|
||||||
const actionInFlight = useRef(false)
|
const actionInFlight = useRef(false)
|
||||||
if (!workspaceRefreshGate.current) workspaceRefreshGate.current = createWorkspaceRefreshGate()
|
if (!refreshCoordinator.current) refreshCoordinator.current = createProjectRefreshCoordinator()
|
||||||
|
|
||||||
useEffect(() => () => workspaceRefreshGate.current?.invalidate(), [])
|
useEffect(() => () => refreshCoordinator.current?.invalidateAll(), [])
|
||||||
|
|
||||||
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
||||||
if (!session) return Promise.reject(new Error('未登录'))
|
if (!session) return Promise.reject(new Error('未登录'))
|
||||||
@@ -79,7 +79,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
|
||||||
const request = workspaceRefreshGate.current!.begin()
|
const request = refreshCoordinator.current!.beginFull()
|
||||||
const projectIDWhenStarted = activeProjectIDRef.current
|
const projectIDWhenStarted = activeProjectIDRef.current
|
||||||
const backendProjects = await fetchProjects(nextSession, request.signal)
|
const backendProjects = await fetchProjects(nextSession, request.signal)
|
||||||
const backendWorkspaces = await Promise.all(
|
const backendWorkspaces = await Promise.all(
|
||||||
@@ -93,26 +93,31 @@ function App() {
|
|||||||
: preferredProjectID ?? latestProjectID
|
: preferredProjectID ?? latestProjectID
|
||||||
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
||||||
selectActiveProject(nextProjectID)
|
selectActiveProject(nextProjectID)
|
||||||
|
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current))
|
||||||
return backendWorkspaces
|
return backendWorkspaces
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) {
|
async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) {
|
||||||
const uniqueProjectIDs = [...new Set(projectIDs.filter(Boolean))]
|
const uniqueProjectIDs = [...new Set(projectIDs.filter(Boolean))]
|
||||||
if (uniqueProjectIDs.length === 0) return []
|
if (uniqueProjectIDs.length === 0) return []
|
||||||
const request = workspaceRefreshGate.current!.begin()
|
const outcomes = await Promise.all(uniqueProjectIDs.map(async (projectID) => {
|
||||||
const refreshed = await Promise.all(uniqueProjectIDs.map(async (projectID) => {
|
const request = refreshCoordinator.current!.beginProject(projectID)
|
||||||
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
|
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
|
||||||
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
|
try {
|
||||||
return mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
|
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
|
||||||
|
if (!request.isCurrent()) return { projectID, status: 'superseded' as const }
|
||||||
|
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
|
||||||
|
setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed]))
|
||||||
|
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID]))
|
||||||
|
return { projectID, status: 'refreshed' as const, workspace: refreshed }
|
||||||
|
} catch (error) {
|
||||||
|
if (!request.isCurrent()) return { projectID, status: 'superseded' as const }
|
||||||
|
return { projectID, status: 'failed' as const, error }
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
|
const failedProjectIDs = outcomes.filter((outcome) => outcome.status === 'failed').map((outcome) => outcome.projectID)
|
||||||
setWorkspaces((current) => {
|
if (failedProjectIDs.length > 0) throw new ProjectRefreshFailure(failedProjectIDs)
|
||||||
const byProjectID = new Map(refreshed.map((workspace) => [workspace.project.id, workspace]))
|
return outcomes.flatMap((outcome) => outcome.status === 'refreshed' ? [outcome.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) {
|
async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) {
|
||||||
@@ -138,26 +143,26 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function offerRefreshRecovery(retry: () => Promise<void>) {
|
function offerRefreshRecovery(projectIDs: string[]) {
|
||||||
setRefreshRecovery({ message: '数据已保存,但页面刷新失败。', retry })
|
setPendingRefreshProjectIDs((current) => mergePendingRefreshProjects(current, projectIDs))
|
||||||
Message.warning('已保存但刷新失败,请点击“刷新数据”重试')
|
Message.warning('已保存但刷新失败,请点击“刷新数据”重试')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryFailedRefresh() {
|
async function retryFailedRefresh() {
|
||||||
if (!refreshRecovery || refreshRetrying) return
|
if (pendingRefreshProjectIDs.length === 0 || refreshRetrying) return
|
||||||
|
const targets = [...pendingRefreshProjectIDs]
|
||||||
setRefreshRetrying(true)
|
setRefreshRetrying(true)
|
||||||
try {
|
try {
|
||||||
await refreshRecovery.retry()
|
await refreshProjectWorkspaces(requireSession(), targets)
|
||||||
setRefreshRecovery(null)
|
|
||||||
Message.success('数据已刷新')
|
Message.success('数据已刷新')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(error instanceof Error ? error.message : '刷新失败,请稍后重试')
|
Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshRetrying(false)
|
setRefreshRetrying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAction<T>(mutate: () => Promise<T>, refresh: (value: T) => Promise<void>, success: string, afterSaved?: (value: T) => void) {
|
async function runAction<T>(mutate: () => Promise<T>, refresh: (value: T) => Promise<void>, refreshTargets: (value: T) => string[], success: string, afterSaved?: (value: T) => void) {
|
||||||
if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交')
|
if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交')
|
||||||
actionInFlight.current = true
|
actionInFlight.current = true
|
||||||
setActionLoading(true)
|
setActionLoading(true)
|
||||||
@@ -172,9 +177,7 @@ function App() {
|
|||||||
refresh,
|
refresh,
|
||||||
})
|
})
|
||||||
if (outcome.refreshError) {
|
if (outcome.refreshError) {
|
||||||
offerRefreshRecovery(() => refresh(outcome.value))
|
offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, refreshTargets(outcome.value)))
|
||||||
} else {
|
|
||||||
setRefreshRecovery(null)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(error instanceof Error ? error.message : '操作失败')
|
Message.error(error instanceof Error ? error.message : '操作失败')
|
||||||
@@ -212,7 +215,7 @@ function App() {
|
|||||||
icon: draft.icon.trim(),
|
icon: draft.icon.trim(),
|
||||||
background: draft.background.trim(),
|
background: draft.background.trim(),
|
||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
}), (created) => loadWorkspaces(currentSession, created.id).then(() => undefined), '项目已创建', (created) => {
|
}), (created) => loadWorkspaces(currentSession, created.id).then(() => undefined), (created) => [created.id], '项目已创建', (created) => {
|
||||||
if (created.id) {
|
if (created.id) {
|
||||||
selectActiveProject(created.id)
|
selectActiveProject(created.id)
|
||||||
setActiveView('project')
|
setActiveView('project')
|
||||||
@@ -233,7 +236,7 @@ function App() {
|
|||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
status: 'open',
|
status: 'open',
|
||||||
tag: draft.tag.trim(),
|
tag: draft.tag.trim(),
|
||||||
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '任务已创建', () => {
|
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '任务已创建', () => {
|
||||||
if (activeProjectIDRef.current === projectID) setActiveChannel('tasks')
|
if (activeProjectIDRef.current === projectID) setActiveChannel('tasks')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -249,7 +252,7 @@ function App() {
|
|||||||
return runAction(() => uploadSource(currentSession, projectID, {
|
return runAction(() => uploadSource(currentSession, projectID, {
|
||||||
title: draft.title.trim(),
|
title: draft.title.trim(),
|
||||||
file,
|
file,
|
||||||
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '文件已上传', () => {
|
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '文件已上传', () => {
|
||||||
if (activeProjectIDRef.current === projectID) setActiveChannel('notes')
|
if (activeProjectIDRef.current === projectID) setActiveChannel('notes')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -270,7 +273,7 @@ function App() {
|
|||||||
schedule: draft.schedule.trim(),
|
schedule: draft.schedule.trim(),
|
||||||
enabled: draft.enabled,
|
enabled: draft.enabled,
|
||||||
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
|
nextRunAt: normalizeOptionalTime(draft.nextRunAt),
|
||||||
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '计划任务已创建', () => {
|
}), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '计划任务已创建', () => {
|
||||||
if (activeProjectIDRef.current === projectID) setActiveChannel('cron')
|
if (activeProjectIDRef.current === projectID) setActiveChannel('cron')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -286,6 +289,7 @@ function App() {
|
|||||||
return runAction(
|
return runAction(
|
||||||
() => createProjectTag(currentSession, projectID, { name: trimmedName }),
|
() => createProjectTag(currentSession, projectID, { name: trimmedName }),
|
||||||
() => refreshProjectWorkspace(currentSession, projectID).then(() => undefined),
|
() => refreshProjectWorkspace(currentSession, projectID).then(() => undefined),
|
||||||
|
() => [projectID],
|
||||||
'标签已创建',
|
'标签已创建',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -305,7 +309,7 @@ function App() {
|
|||||||
completed: update.completed,
|
completed: update.completed,
|
||||||
nextProjectId: update.nextProjectId,
|
nextProjectId: update.nextProjectId,
|
||||||
tag: update.tag,
|
tag: update.tag,
|
||||||
}), () => refreshProjectWorkspaces(currentSession, [update.originalProjectId, update.nextProjectId]).then(() => undefined), '任务已更新')
|
}), () => refreshProjectWorkspaces(currentSession, [update.originalProjectId, update.nextProjectId]).then(() => undefined), () => [update.originalProjectId, update.nextProjectId], '任务已更新')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateProject(update: ProjectSettingsUpdate) {
|
async function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||||
@@ -322,8 +326,7 @@ function App() {
|
|||||||
onSaved: () => Message.success('项目设置已更新'),
|
onSaved: () => Message.success('项目设置已更新'),
|
||||||
refresh,
|
refresh,
|
||||||
})
|
})
|
||||||
if (outcome.refreshError) offerRefreshRecovery(refresh)
|
if (outcome.refreshError) offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, [update.projectId]))
|
||||||
else setRefreshRecovery(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAnalyzeInbox(inboxId: string) {
|
async function handleAnalyzeInbox(inboxId: string) {
|
||||||
@@ -339,8 +342,7 @@ function App() {
|
|||||||
onSaved: () => Message.success('已收集到 Inbox'),
|
onSaved: () => Message.success('已收集到 Inbox'),
|
||||||
refresh,
|
refresh,
|
||||||
})
|
})
|
||||||
if (outcome.refreshError) offerRefreshRecovery(refresh)
|
if (outcome.refreshError) offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, [projectId]))
|
||||||
else setRefreshRecovery(null)
|
|
||||||
return outcome.value.id
|
return outcome.value.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,16 +356,16 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
||||||
} catch {
|
} catch {
|
||||||
|
offerRefreshRecovery([confirmationProjectID])
|
||||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入原项目核对')
|
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入原项目核对')
|
||||||
}
|
}
|
||||||
throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对')
|
throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
|
||||||
setRefreshRecovery(null)
|
|
||||||
return { createdCount: response.createdCount }
|
return { createdCount: response.createdCount }
|
||||||
} catch {
|
} catch (error) {
|
||||||
offerRefreshRecovery(() => refreshProjectWorkspace(requireSession(), confirmationProjectID).then(() => undefined))
|
offerRefreshRecovery(refreshFailureProjectIDs(error, [confirmationProjectID]))
|
||||||
return {
|
return {
|
||||||
createdCount: response.createdCount,
|
createdCount: response.createdCount,
|
||||||
refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目',
|
refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目',
|
||||||
@@ -396,11 +398,11 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||||
{refreshRecovery && (
|
{pendingRefreshProjectIDs.length > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
className="refresh-recovery-alert"
|
className="refresh-recovery-alert"
|
||||||
type="warning"
|
type="warning"
|
||||||
content={refreshRecovery.message}
|
content={`${pendingRefreshProjectIDs.length} 个项目的数据已保存,但页面刷新失败。`}
|
||||||
action={<Button size="small" loading={refreshRetrying} onClick={() => void retryFailedRefresh()}>刷新数据</Button>}
|
action={<Button size="small" loading={refreshRetrying} onClick={() => void retryFailedRefresh()}>刷新数据</Button>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -479,6 +481,27 @@ function searchResultTarget(type: string): { channel: ChannelKey; openTask: bool
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ProjectRefreshFailure extends Error {
|
||||||
|
readonly projectIDs: string[]
|
||||||
|
|
||||||
|
constructor(projectIDs: string[]) {
|
||||||
|
super(`项目数据刷新失败:${projectIDs.join('、')}`)
|
||||||
|
this.name = 'ProjectRefreshFailure'
|
||||||
|
this.projectIDs = projectIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFailureProjectIDs(error: unknown, fallback: string[]) {
|
||||||
|
return error instanceof ProjectRefreshFailure ? error.projectIDs : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRefreshedWorkspaces(current: ProjectWorkspace[], refreshed: ProjectWorkspace[]) {
|
||||||
|
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))]
|
||||||
|
}
|
||||||
|
|
||||||
function staleWorkspaceRefreshError() {
|
function staleWorkspaceRefreshError() {
|
||||||
return new DOMException('workspace refresh superseded', 'AbortError')
|
return new DOMException('workspace refresh superseded', 'AbortError')
|
||||||
}
|
}
|
||||||
|
|||||||
42
apps/web_v1/src/app/project-refresh-coordinator.ts
Normal file
42
apps/web_v1/src/app/project-refresh-coordinator.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createWorkspaceRefreshGate } from './workspace-refresh-gate.ts'
|
||||||
|
|
||||||
|
export function createProjectRefreshCoordinator() {
|
||||||
|
const fullGate = createWorkspaceRefreshGate()
|
||||||
|
const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>()
|
||||||
|
|
||||||
|
function projectGate(projectID: string) {
|
||||||
|
const existing = projectGates.get(projectID)
|
||||||
|
if (existing) return existing
|
||||||
|
const created = createWorkspaceRefreshGate()
|
||||||
|
projectGates.set(projectID, created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginFull() {
|
||||||
|
for (const gate of projectGates.values()) gate.invalidate()
|
||||||
|
return fullGate.begin()
|
||||||
|
},
|
||||||
|
beginProject(projectID: string) {
|
||||||
|
fullGate.invalidate()
|
||||||
|
return projectGate(projectID).begin()
|
||||||
|
},
|
||||||
|
invalidateAll() {
|
||||||
|
fullGate.invalidate()
|
||||||
|
for (const gate of projectGates.values()) gate.invalidate()
|
||||||
|
projectGates.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePendingRefreshProjects(current: readonly string[], failed: readonly string[]) {
|
||||||
|
return [...new Set([...current, ...failed].filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingRefreshProjects(current: readonly string[], succeeded?: readonly string[]) {
|
||||||
|
if (succeeded === undefined) return []
|
||||||
|
const covered = new Set(succeeded)
|
||||||
|
return current.filter((projectID) => !covered.has(projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectRefreshCoordinator = ReturnType<typeof createProjectRefreshCoordinator>
|
||||||
Reference in New Issue
Block a user