fix(ui): 修复菜单/工作区切换时的状态错乱与跨用户数据泄漏 (#483)

### 背景
切换菜单(对话 ↔ 知识库)或工作区时,存在多个状态错乱问题:

1. 员工名称闪烁为数据库数字 ID(如 1000000001 )
2. 对话生成中切菜单再切回,出现白屏重渲染
3. Wiki 工作区切换后仍显示上一工作区内容
4. 登出后 keepAlive 缓存与模块级变量未清空,存在跨用户数据泄漏风险

### 改动内容

| 文件 | 改动 |
|------|------|
| `ChatConsole.vue` | 新增模块级 `cachedAgents` 缓存,组件重建首帧即有员工数据;启用 keepAlive,新增 `onActivated`/`onDeactivated` 生命周期管理,释放定时器/图表/监听器并自动重连流式对话 |
| `AgentPickerDialog.vue` | `isUnknown` 计算属性增加 `agents.length > 0` 守卫,列表未加载时走 placeholder 而非显示原始 ID |
| `router/index.ts` | `/chat` 路由添加 `keepAlive: true` |
| `MainLayout.vue` | 登出改为 `window.location.href` 整页刷新,确保清空 keepAlive 缓存与模块级变量 |
| `useWikiStore.ts` | `fetchKnowledgeBases` 检测 currentKB 不在新列表时调用 `backToLibrary` 清理旧工作区上下文 |
| `zh-CN.ts` / `en-US.ts` | 新增 `unknownAgent` 国际化键 |

### 测试

- 新增 `agentPickerLogic.test.ts`:覆盖 isUnknown 判定的 4 类边界场景(空值/正常匹配/列表为空/员工被删除)
- 新增 `wikiStoreWorkspaceSwitch.test.ts`:覆盖工作区切换清理逻辑(跨工作区清理/同工作区保留/首次进入/接口异常/组合路径)
- 前端全量测试:**58/58 通过**(原 40 + 新增 18)
- TypeScript 类型检查:通过
- 后端 Maven 测试:3473/3555 通过,9 失败均为预存 Windows 路径兼容问题,与本次改动无关

### 安全性

- 不同对话:`currentConversationId` 单一来源 + API 校验
- 不同账号:登出整页刷新,零残留
- 不同工作区:`router-view` key 重建 + wiki store 主动清理

---
This commit is contained in:
MIST 2026-07-03 19:27:18 +08:00 committed by GitHub
parent 35142508db
commit 9fb3550e91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 305 additions and 8 deletions

View File

@ -204,12 +204,18 @@ const selectedAgent = computed<PickableAgent | null>(() => {
})
/** modelValue is set but resolves to no known agent the referenced
* employee was likely renamed or removed. */
const isUnknown = computed(() => hasValue.value && !selectedAgent.value)
* employee was likely renamed or removed.
* Requires agents to have loaded first: while the list is still empty
* (component just rebuilt, /agents in flight) we must NOT treat a missing
* match as "unknown" otherwise the trigger flashes the raw numeric id
* until the list lands. Falling through to the placeholder during that
* window keeps the trigger readable and self-heals once agents arrive. */
const isUnknown = computed(() =>
hasValue.value && !selectedAgent.value && props.agents.length > 0)
const triggerLabel = computed(() => {
if (selectedAgent.value) return selectedAgent.value.name
if (isUnknown.value) return props.unknownLabel || String(props.modelValue)
if (isUnknown.value) return props.unknownLabel || t('agentContext.unknownAgent')
return props.placeholder || t('agentContext.selectAgent')
})

View File

@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import type { PickableAgent } from '../AgentPickerDialog.vue'
/**
* AgentPickerDialog isUnknown / triggerLabel
* computed
* agents.length > 0 IDBug 1
*/
const AGENTS: PickableAgent[] = [
{ id: 1000000001, name: '通用助手' },
{ id: 1000000002, name: '代码助手' },
]
// hasValue复刻组件中的判定
function hasValue(modelValue: string | number | null | undefined): boolean {
return modelValue !== '' && modelValue !== null && modelValue !== undefined
}
// selectedAgent复刻组件中的查找
function findSelected(
modelValue: string | number | null | undefined,
agents: PickableAgent[],
): PickableAgent | null {
if (!hasValue(modelValue)) return null
return agents.find(a => String(a.id) === String(modelValue)) || null
}
// isUnknown复刻组件中的 computed —— 关键修复点
// hasValue && !selectedAgent && agents.length > 0
function computeIsUnknown(
modelValue: string | number | null | undefined,
agents: PickableAgent[],
): boolean {
const selected = findSelected(modelValue, agents)
return hasValue(modelValue) && !selected && agents.length > 0
}
// triggerLabel复刻组件中的优先级链
function computeTriggerLabel(
modelValue: string | number | null | undefined,
agents: PickableAgent[],
placeholder: string,
unknownLabel: string,
): string {
const selected = findSelected(modelValue, agents)
if (selected) return selected.name
if (computeIsUnknown(modelValue, agents)) return unknownLabel
return placeholder
}
describe('AgentPickerDialog isUnknown 判定', () => {
describe('空值场景', () => {
it('modelValue 为 null 时不判定为未知', () => {
expect(computeIsUnknown(null, AGENTS)).toBe(false)
})
it('modelValue 为空字符串时不判定为未知', () => {
expect(computeIsUnknown('', AGENTS)).toBe(false)
})
it('modelValue 为 undefined 时不判定为未知', () => {
expect(computeIsUnknown(undefined, AGENTS)).toBe(false)
})
})
describe('正常匹配场景', () => {
it('数字 id 匹配到员工时不判定为未知', () => {
expect(computeIsUnknown(1000000001, AGENTS)).toBe(false)
})
it('字符串 id 匹配到员工时不判定为未知Snowflake 精度场景)', () => {
expect(computeIsUnknown('1000000001', AGENTS)).toBe(false)
})
})
describe('列表为空场景Bug 1 核心修复点)', () => {
// 组件被 keepAlive 重建后 /agents 尚在飞行中,此时 agents=[]
// 旧逻辑hasValue && !selectedAgent → true → 显示原始 ID
// 新逻辑:追加 agents.length > 0 → false → 走 placeholder
it('modelValue 有值但员工列表为空时不判定为未知', () => {
expect(computeIsUnknown(1000000001, [])).toBe(false)
expect(computeIsUnknown('1000000001', [])).toBe(false)
})
it('modelValue 为无效值且列表为空时也不判定为未知', () => {
expect(computeIsUnknown(9999999999, [])).toBe(false)
})
})
describe('员工被删除/改名场景', () => {
it('modelValue 有值、列表非空但无匹配时判定为未知', () => {
expect(computeIsUnknown(9999999999, AGENTS)).toBe(true)
expect(computeIsUnknown('9999999999', AGENTS)).toBe(true)
})
it('被删除的员工在恢复列表后仍显示未知标记,直到用户重新选择', () => {
// 模拟:员工 1000000001 被从列表移除
const reduced = AGENTS.filter(a => a.id !== 1000000001)
expect(computeIsUnknown(1000000001, reduced)).toBe(true)
})
})
})
describe('AgentPickerDialog triggerLabel 优先级链', () => {
const PLACEHOLDER = '请选择员工'
const UNKNOWN_LABEL = '未知员工'
it('选中员工时显示员工名称(最高优先级)', () => {
expect(computeTriggerLabel(1000000001, AGENTS, PLACEHOLDER, UNKNOWN_LABEL))
.toBe('通用助手')
})
it('列表为空 + modelValue 有值时显示 placeholderBug 1 修复核心)', () => {
// 旧逻辑会显示 "1000000001",新逻辑走 placeholder
expect(computeTriggerLabel(1000000001, [], PLACEHOLDER, UNKNOWN_LABEL))
.toBe(PLACEHOLDER)
})
it('列表非空但无匹配时显示 unknownLabel', () => {
expect(computeTriggerLabel(9999999999, AGENTS, PLACEHOLDER, UNKNOWN_LABEL))
.toBe(UNKNOWN_LABEL)
})
it('未选择时显示 placeholder', () => {
expect(computeTriggerLabel(null, AGENTS, PLACEHOLDER, UNKNOWN_LABEL))
.toBe(PLACEHOLDER)
})
})

View File

@ -1203,6 +1203,7 @@ export default {
title: 'Agent Context',
desc: 'Manage prompt files and memory for agents',
selectAgent: 'Select Employee',
unknownAgent: 'Unknown employee',
noAgent: 'Please select an agent first',
files: 'Files',
coreFiles: 'Core Files',

View File

@ -1077,6 +1077,7 @@ export default {
title: '智能体上下文',
desc: '管理智能体的提示文件和记忆',
selectAgent: '选择员工',
unknownAgent: '未知员工',
noAgent: '请先选择一个智能体',
files: '文件列表',
coreFiles: '核心文件',

View File

@ -25,7 +25,7 @@ const router = createRouter({
path: 'chat',
name: 'Chat',
component: () => import('@/views/ChatConsole.vue'),
meta: { title: 'Chat', requiredCapability: 'chat' },
meta: { title: 'Chat', requiredCapability: 'chat', keepAlive: true },
},
{
path: 'dashboard',

View File

@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
// vi.mock 在顶层会被提升,工厂内部不能引用外部变量,所以直接返回固定
// 的 mock 实现,具体的返回数据在测试用例中通过 mockResolvedValue 设置。
vi.mock('@/api/index', () => ({
wikiApi: {
listKBs: vi.fn(),
listRaw: vi.fn().mockResolvedValue({ data: [] }),
listPages: vi.fn().mockResolvedValue({ data: [] }),
listPageRefs: vi.fn().mockResolvedValue({ data: { items: [] } }),
getBrokenLinksReport: vi.fn().mockRejectedValue({ code: 404 }),
getPageTypeProfile: vi.fn().mockRejectedValue(new Error('no profile')),
},
}))
// 必须在 mock 之后导入,否则 store 初始化时拿到的是真实 wikiApi
import { useWikiStore } from '../useWikiStore'
import { wikiApi } from '@/api/index'
const KB_WS_A = { id: 100, name: 'WS-A-Wiki' }
const KB_WS_B = { id: 200, name: 'WS-B-Wiki' }
describe('useWikiStore 工作区切换 KB 清理', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('切换工作区后 currentKB 不在新列表中时清理旧 KB 上下文', async () => {
const store = useWikiStore()
// 模拟工作区 A已选中 KB 并加载了 pages / pageRefs
store.currentKB = KB_WS_A as any
store.pages = [{ id: 1, title: 'page-A' }] as any
store.pageRefs = [{ slug: 'page-a', title: 'Page A', archived: false }] as any
// 切到工作区 BlistKBs 返回的列表不含 id=100
;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_B] })
await store.fetchKnowledgeBases()
// backToLibrary 应被触发currentKB / pages / pageRefs 全部归零
expect(store.currentKB).toBeNull()
expect(store.pages).toEqual([])
expect(store.pageRefs).toEqual([])
// 新列表已写入
expect(store.knowledgeBases).toEqual([KB_WS_B])
expect(store.loading).toBe(false)
})
it('同一工作区刷新 KB 列表时保留当前 currentKB', async () => {
const store = useWikiStore()
store.currentKB = KB_WS_A as any
store.pages = [{ id: 1, title: 'page-A' }] as any
// 同一工作区listKBs 返回的列表包含 id=100
;(wikiApi.listKBs as any).mockResolvedValue({
data: [KB_WS_A, { id: 101, name: 'WS-A-Wiki-2' }],
})
await store.fetchKnowledgeBases()
// currentKB 保留pages 保留(不误清理)
expect(store.currentKB).not.toBeNull()
expect(store.currentKB?.id).toBe(100)
expect(store.pages).toEqual([{ id: 1, title: 'page-A' }])
})
it('首次进入(无 currentKB时不触发 backToLibrary', async () => {
const store = useWikiStore()
// currentKB 初始为 null
expect(store.currentKB).toBeNull()
;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_A, KB_WS_B] })
await store.fetchKnowledgeBases()
expect(store.knowledgeBases).toHaveLength(2)
expect(store.loading).toBe(false)
// 无异常即可backToLibrary 对 null currentKB 本身也是安全空操作
})
it('listKBs 接口异常时不崩溃且 loading 复位', async () => {
const store = useWikiStore()
store.currentKB = KB_WS_A as any
;(wikiApi.listKBs as any).mockRejectedValue(new Error('network down'))
await store.fetchKnowledgeBases()
// 异常路径catch 吞错knowledgeBases 不变loading 复位
expect(store.loading).toBe(false)
expect(store.currentKB?.id).toBe(100)
})
it('selectKB 后切换工作区触发 fetchKnowledgeBases 能正确清理', async () => {
// 验证 selectKB → fetchKnowledgeBases 的组合路径
const store = useWikiStore()
;(wikiApi.listKBs as any).mockResolvedValue({ data: [KB_WS_B] })
// 先在工作区 A 选了 KB
store.currentKB = KB_WS_A as any
store.pages = [{ id: 1, title: 'page-A' }] as any
await store.fetchKnowledgeBases()
// 旧 KB 上下文被清理
expect(store.currentKB).toBeNull()
expect(store.pages).toEqual([])
})
})

View File

@ -177,7 +177,13 @@ export const useWikiStore = defineStore('wiki', () => {
loading.value = true
try {
const res: any = await wikiApi.listKBs()
knowledgeBases.value = res.data || []
const next: WikiKB[] = res.data || []
// 工作区切换时清理上一个工作区的 KB 上下文,避免显示其内容。
// backToLibrary 已清理 pages/pageRefs/rawMaterials 等关联状态,这里复用。
if (currentKB.value && !next.some(kb => kb.id === currentKB.value!.id)) {
backToLibrary()
}
knowledgeBases.value = next
} catch (e) {
console.error('Failed to fetch knowledge bases', e)
} finally {

View File

@ -263,8 +263,12 @@
</div>
</template>
<script lang="ts">
let cachedAgents: import('@/types').Agent[] = []
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
@ -359,7 +363,7 @@ const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const agents = ref<Agent[]>([])
const agents = ref<Agent[]>(cachedAgents.length > 0 ? [...cachedAgents] : [])
const conversations = ref<Conversation[]>([])
const selectedAgentId = ref<string | number>('')
const currentConversationId = ref<string>('')
@ -1162,6 +1166,45 @@ onBeforeUnmount(() => {
revokeAllPreviewUrls()
})
onDeactivated(() => {
if (activityPollTimer !== null) {
clearInterval(activityPollTimer)
activityPollTimer = null
}
if (elapsedTickTimer !== null) {
clearInterval(elapsedTickTimer)
elapsedTickTimer = null
}
document.removeEventListener('click', handleCodeCopy)
disposeECharts()
disposeKatex()
disposeMermaid()
revokeAllPreviewUrls()
resetForNewConversation()
})
onActivated(async () => {
document.addEventListener('click', handleCodeCopy)
startECharts()
startKatex()
startMermaid()
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
elapsedTickTimer = window.setInterval(() => {
if (activeCronRuns.value.length > 0) elapsedNow.value = Date.now()
}, 1000)
// window.location.href
if (currentConversationId.value && !isEphemeralConversation(currentConversationId.value)) {
try {
const statusRes: any = await conversationApi.getStatus(currentConversationId.value)
if (currentConversationId.value && statusRes.data?.streamStatus === 'running') {
await reconnectStream(currentConversationId.value)
}
} catch {
//
}
}
})
watch(() => route.query, () => {
// If a fresh action arrives (e.g. user re-fires Ctrl+K via the URL while
// the view is already alive), pick it up immediately.
@ -1331,6 +1374,7 @@ async function loadAgents() {
// a confusing failure path. The admin Agents view passes no filter.
const res: any = await agentApi.list({ enabled: true })
agents.value = res.data || []
if (agents.value.length > 0) cachedAgents = [...agents.value]
// URL agentId
if (agents.value.length > 0 && !selectedAgentId.value && !route.query.agentId) {
selectedAgentId.value = agents.value[0].id

View File

@ -578,7 +578,9 @@ function logout() {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
router.push('/login')
// router.push keepAlive ChatConsole
// cachedAgents
window.location.href = '/login'
}
async function changeLocale(locale: AppLocale) {