diff --git a/apps/web_v1/scripts/mutation-wiring.test.mjs b/apps/web_v1/scripts/mutation-wiring.test.mjs index 8c90975..7f456f4 100644 --- a/apps/web_v1/scripts/mutation-wiring.test.mjs +++ b/apps/web_v1/scripts/mutation-wiring.test.mjs @@ -38,5 +38,7 @@ test('project settings and Inbox capture keep save success separate from refresh test('refresh recovery remains actionable after a save', () => { assert.match(appSource, /数据已保存,但页面刷新失败/) 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\]\)/) }) diff --git a/apps/web_v1/scripts/project-refresh-coordinator.test.mjs b/apps/web_v1/scripts/project-refresh-coordinator.test.mjs new file mode 100644 index 0000000..80acc0f --- /dev/null +++ b/apps/web_v1/scripts/project-refresh-coordinator.test.mjs @@ -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) +}) diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 7f1ebce..854b2da 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs' const requiredFiles = [ 'src/app/App.tsx', 'src/app/mutation-refresh.ts', + 'src/app/project-refresh-coordinator.ts', 'src/pages/login.tsx', 'src/pages/workspace-body.tsx', 'src/pages/workspace-home.tsx', @@ -31,6 +32,7 @@ const requiredFiles = [ 'scripts/workspace-refresh-gate.test.mjs', 'scripts/mutation-refresh.test.mjs', 'scripts/mutation-wiring.test.mjs', + 'scripts/project-refresh-coordinator.test.mjs', ] 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}`) } } - 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}`) } } diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index ef0215e..47aef22 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -27,13 +27,13 @@ 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' -import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate' import { executeSavedMutation } from './mutation-refresh' - -type RefreshRecovery = { - message: string - retry: () => Promise -} +import { + clearPendingRefreshProjects, + createProjectRefreshCoordinator, + mergePendingRefreshProjects, + type ProjectRefreshCoordinator, +} from './project-refresh-coordinator' function App() { const [screen, setScreen] = useState('login') @@ -50,14 +50,14 @@ function App() { const [actionLoading, setActionLoading] = useState(false) const [activeModal, setActiveModal] = useState(null) const [searchResultPreview, setSearchResultPreview] = useState(null) - const [refreshRecovery, setRefreshRecovery] = useState(null) + const [pendingRefreshProjectIDs, setPendingRefreshProjectIDs] = useState([]) const [refreshRetrying, setRefreshRetrying] = useState(false) const workspaceSearch = useWorkbenchSearch(session) - const workspaceRefreshGate = useRef(null) + const refreshCoordinator = useRef(null) 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) => { if (!session) return Promise.reject(new Error('未登录')) @@ -79,7 +79,7 @@ function App() { } async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) { - const request = workspaceRefreshGate.current!.begin() + const request = refreshCoordinator.current!.beginFull() const projectIDWhenStarted = activeProjectIDRef.current const backendProjects = await fetchProjects(nextSession, request.signal) const backendWorkspaces = await Promise.all( @@ -93,26 +93,31 @@ function App() { : preferredProjectID ?? latestProjectID const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? '' selectActiveProject(nextProjectID) + setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current)) return backendWorkspaces } 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 refreshed = await Promise.all(uniqueProjectIDs.map(async (projectID) => { + const outcomes = await Promise.all(uniqueProjectIDs.map(async (projectID) => { + const request = refreshCoordinator.current!.beginProject(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) + try { + 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() - setWorkspaces((current) => { - 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 + const failedProjectIDs = outcomes.filter((outcome) => outcome.status === 'failed').map((outcome) => outcome.projectID) + if (failedProjectIDs.length > 0) throw new ProjectRefreshFailure(failedProjectIDs) + return outcomes.flatMap((outcome) => outcome.status === 'refreshed' ? [outcome.workspace] : []) } async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) { @@ -138,26 +143,26 @@ function App() { } } - function offerRefreshRecovery(retry: () => Promise) { - setRefreshRecovery({ message: '数据已保存,但页面刷新失败。', retry }) + function offerRefreshRecovery(projectIDs: string[]) { + setPendingRefreshProjectIDs((current) => mergePendingRefreshProjects(current, projectIDs)) Message.warning('已保存但刷新失败,请点击“刷新数据”重试') } async function retryFailedRefresh() { - if (!refreshRecovery || refreshRetrying) return + if (pendingRefreshProjectIDs.length === 0 || refreshRetrying) return + const targets = [...pendingRefreshProjectIDs] setRefreshRetrying(true) try { - await refreshRecovery.retry() - setRefreshRecovery(null) + await refreshProjectWorkspaces(requireSession(), targets) Message.success('数据已刷新') } catch (error) { - Message.error(error instanceof Error ? error.message : '刷新失败,请稍后重试') + 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) { + async function runAction(mutate: () => Promise, refresh: (value: T) => Promise, refreshTargets: (value: T) => string[], success: string, afterSaved?: (value: T) => void) { if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交') actionInFlight.current = true setActionLoading(true) @@ -172,9 +177,7 @@ function App() { refresh, }) if (outcome.refreshError) { - offerRefreshRecovery(() => refresh(outcome.value)) - } else { - setRefreshRecovery(null) + offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, refreshTargets(outcome.value))) } } catch (error) { Message.error(error instanceof Error ? error.message : '操作失败') @@ -212,7 +215,7 @@ function App() { icon: draft.icon.trim(), background: draft.background.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) { selectActiveProject(created.id) setActiveView('project') @@ -233,7 +236,7 @@ function App() { description: draft.description.trim(), status: 'open', tag: draft.tag.trim(), - }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '任务已创建', () => { + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '任务已创建', () => { if (activeProjectIDRef.current === projectID) setActiveChannel('tasks') }) } @@ -249,7 +252,7 @@ function App() { return runAction(() => uploadSource(currentSession, projectID, { title: draft.title.trim(), file, - }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '文件已上传', () => { + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '文件已上传', () => { if (activeProjectIDRef.current === projectID) setActiveChannel('notes') }) } @@ -270,7 +273,7 @@ function App() { schedule: draft.schedule.trim(), enabled: draft.enabled, nextRunAt: normalizeOptionalTime(draft.nextRunAt), - }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), '计划任务已创建', () => { + }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), () => [projectID], '计划任务已创建', () => { if (activeProjectIDRef.current === projectID) setActiveChannel('cron') }) } @@ -286,6 +289,7 @@ function App() { return runAction( () => createProjectTag(currentSession, projectID, { name: trimmedName }), () => refreshProjectWorkspace(currentSession, projectID).then(() => undefined), + () => [projectID], '标签已创建', ) } @@ -305,7 +309,7 @@ function App() { completed: update.completed, nextProjectId: update.nextProjectId, 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) { @@ -322,8 +326,7 @@ function App() { onSaved: () => Message.success('项目设置已更新'), refresh, }) - if (outcome.refreshError) offerRefreshRecovery(refresh) - else setRefreshRecovery(null) + if (outcome.refreshError) offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, [update.projectId])) } async function handleAnalyzeInbox(inboxId: string) { @@ -339,8 +342,7 @@ function App() { onSaved: () => Message.success('已收集到 Inbox'), refresh, }) - if (outcome.refreshError) offerRefreshRecovery(refresh) - else setRefreshRecovery(null) + if (outcome.refreshError) offerRefreshRecovery(refreshFailureProjectIDs(outcome.refreshError, [projectId])) return outcome.value.id } @@ -354,16 +356,16 @@ function App() { try { await refreshProjectWorkspace(requireSession(), confirmationProjectID) } catch { + offerRefreshRecovery([confirmationProjectID]) throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入原项目核对') } throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对') } try { await refreshProjectWorkspace(requireSession(), confirmationProjectID) - setRefreshRecovery(null) return { createdCount: response.createdCount } - } catch { - offerRefreshRecovery(() => refreshProjectWorkspace(requireSession(), confirmationProjectID).then(() => undefined)) + } catch (error) { + offerRefreshRecovery(refreshFailureProjectIDs(error, [confirmationProjectID])) return { createdCount: response.createdCount, refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目', @@ -396,11 +398,11 @@ function App() { return (
- {refreshRecovery && ( + {pendingRefreshProjectIDs.length > 0 && ( void retryFailedRefresh()}>刷新数据} /> )} @@ -479,6 +481,27 @@ function searchResultTarget(type: string): { channel: ChannelKey; openTask: bool 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() { return new DOMException('workspace refresh superseded', 'AbortError') } diff --git a/apps/web_v1/src/app/project-refresh-coordinator.ts b/apps/web_v1/src/app/project-refresh-coordinator.ts new file mode 100644 index 0000000..64a4ad2 --- /dev/null +++ b/apps/web_v1/src/app/project-refresh-coordinator.ts @@ -0,0 +1,42 @@ +import { createWorkspaceRefreshGate } from './workspace-refresh-gate.ts' + +export function createProjectRefreshCoordinator() { + const fullGate = createWorkspaceRefreshGate() + const projectGates = new Map>() + + 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