diff --git a/src/store.ts b/src/store.ts index b3eb32b..3766b70 100644 --- a/src/store.ts +++ b/src/store.ts @@ -539,9 +539,11 @@ export const store = { if (pid !== state.currentProjectId) await store.openProject(pid) if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') const c = await api.createConversation(pid) - if (epoch !== identityEpoch) return + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') await store.refreshTasks() - if (epoch === identityEpoch) await store.openTask(c.id) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') + await store.openTask(c.id) + if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') } catch (e) { if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`) throw e @@ -556,8 +558,10 @@ export const store = { /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */ async openTask(convId: string) { + const epoch = identityEpoch const t = state.tasks.find(x => x.id === convId) if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId) + if (epoch !== identityEpoch) return await store.openConversation(convId) }, @@ -1196,7 +1200,7 @@ export const store = { if (epoch !== identityEpoch || state.currentProjectId !== pid) return null store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`) await store.refreshArtifacts(pid) - return up.artifactId + return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null } catch (e) { if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择') return null @@ -1296,12 +1300,18 @@ export const store = { }, async tidyCards() { + const epoch = identityEpoch // U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。 const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger') for (const c of done) { - try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ } + if (epoch !== identityEpoch) return + try { + const out = await api.cardDismiss(c.id) + if (epoch !== identityEpoch) return + store.upsertCard(out.card) + } catch { /* 单卡失败不阻断 */ } } - if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`) + if (epoch === identityEpoch && done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`) }, closeWorkspace() { setUI({ activeWorkspace: null }) }, /** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */ diff --git a/test/project-access.test.ts b/test/project-access.test.ts index 5ff23cc..6266b3e 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -249,17 +249,18 @@ describe('identity-scoped state', () => { expect(store.getState().artifacts).toEqual([]) }) - it.each(['action', 'pin', 'dismiss', 'to-project'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { + it.each(['action', 'pin', 'dismiss', 'to-project', 'tidy'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => { projects = [ready] await store.refreshProjects() const old = deferred() const previous = route route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init) const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' } + if (operation === 'tidy') store.upsertCard({ ...card, status: 'done' }) const pending = operation === 'action' ? store.cardAction(card, 'finish') : operation === 'pin' ? store.cardPin(card, true) : operation === 'dismiss' ? store.cardDismiss(card) - : store.cardToProject(card) + : operation === 'tidy' ? store.tidyCards() : store.cardToProject(card) auth.beginSession() route = previous await store.refreshProjects() @@ -268,4 +269,57 @@ describe('identity-scoped state', () => { expect(store.getState().projectCards).toEqual([]) expect(store.getState().cards).toEqual([]) }) + + it('does not reopen a previous account conversation after its project load finishes late', async () => { + projects = [ready]; tasks = [task('alice-private')] + await store.refreshProjects() + await store.openProject('another-project') + const old = deferred() + const previous = route + route = (path, init) => path === '/api/projects/host-1/artifacts' ? old.promise : previous(path, init) + const pending = store.openTask('alice-private') + auth.beginSession() + tasks = [] + route = previous + await store.refreshProjects() + old.resolve(response([])) + await pending + expect(store.getState().currentConvId).toBeNull() + expect(requests.map(r => r.path)).not.toContain('/api/conversations/alice-private') + }) + + it('does not resolve old task creation as success after a new login', async () => { + projects = [ready] + await store.refreshProjects() + const old = deferred() + const previous = route + route = (path, init) => path === '/api/projects/host-1/conversations' ? old.promise : previous(path, init) + const pending = store.createTask() + auth.beginSession() + route = previous + await store.refreshProjects() + old.resolve(response(task('alice-created'))) + await expect(pending).rejects.toThrow(/登录状态/) + expect(store.getState().currentConvId).toBeNull() + }) + + it('does not return a previous account attachment ID when artifact refresh finishes after login', async () => { + projects = [ready] + await store.refreshProjects() + const old = deferred() + const started = deferred() + const previous = route + route = (path, init) => { + if (path.endsWith('/uploads')) return Promise.resolve(response({ artifactId: 'alice-upload', filename: 'alice.txt', size: 3 })) + if (path.endsWith('/artifacts')) { started.resolve(); return old.promise } + return previous(path, init) + } + const pending = store.uploadAttachment(new File(['abc'], 'alice.txt')) + await started.promise + auth.beginSession() + route = previous + await store.refreshProjects() + old.resolve(response([])) + expect(await pending).toBeNull() + }) })