From 9fb3550e91f4415d5013cff5ae750a97bd0707cc Mon Sep 17 00:00:00 2001 From: MIST <52695829+MISTLXC@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:27:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E8=8F=9C=E5=8D=95?= =?UTF-8?q?/=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=88=87=E6=8D=A2=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E7=8A=B6=E6=80=81=E9=94=99=E4=B9=B1=E4=B8=8E=E8=B7=A8?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=95=B0=E6=8D=AE=E6=B3=84=E6=BC=8F=20(#483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 背景 切换菜单(对话 ↔ 知识库)或工作区时,存在多个状态错乱问题: 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 主动清理 --- --- .../components/common/AgentPickerDialog.vue | 12 +- .../common/__tests__/agentPickerLogic.test.ts | 125 ++++++++++++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 1 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 + mateclaw-ui/src/router/index.ts | 2 +- .../wikiStoreWorkspaceSwitch.test.ts | 112 ++++++++++++++++ mateclaw-ui/src/stores/useWikiStore.ts | 8 +- mateclaw-ui/src/views/ChatConsole.vue | 48 ++++++- mateclaw-ui/src/views/layout/MainLayout.vue | 4 +- 9 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts create mode 100644 mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts diff --git a/mateclaw-ui/src/components/common/AgentPickerDialog.vue b/mateclaw-ui/src/components/common/AgentPickerDialog.vue index 63523a04..6ae61b7a 100644 --- a/mateclaw-ui/src/components/common/AgentPickerDialog.vue +++ b/mateclaw-ui/src/components/common/AgentPickerDialog.vue @@ -204,12 +204,18 @@ const selectedAgent = computed(() => { }) /** 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') }) diff --git a/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts b/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts new file mode 100644 index 00000000..231569ac --- /dev/null +++ b/mateclaw-ui/src/components/common/__tests__/agentPickerLogic.test.ts @@ -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 的守卫,组件重建瞬间会闪现原始数字 ID(Bug 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 有值时显示 placeholder(Bug 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) + }) +}) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 1c3dafad..63cdf70c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 64e0abdd..0db8f4f1 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1077,6 +1077,7 @@ export default { title: '智能体上下文', desc: '管理智能体的提示文件和记忆', selectAgent: '选择员工', + unknownAgent: '未知员工', noAgent: '请先选择一个智能体', files: '文件列表', coreFiles: '核心文件', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index c7c1890c..23541516 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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', diff --git a/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts b/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts new file mode 100644 index 00000000..b3a7d878 --- /dev/null +++ b/mateclaw-ui/src/stores/__tests__/wikiStoreWorkspaceSwitch.test.ts @@ -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 + + // 切到工作区 B:listKBs 返回的列表不含 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([]) + }) +}) diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 30ddd0e3..72a5f893 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -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 { diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index c8716882..9a86b5c2 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -263,8 +263,12 @@ + +