feat(studio): 知识库停用/恢复 UI + e2e 真跑

F——补自建知识库停用/恢复端面(后端 disable/restore 端点已有,缺 UI):
- useKnowledge: useKnowledgeDisable/useKnowledgeRestore mutation hook
- KnowledgePage: 自建 KB 卡片按 status 切换「停用」(confirm)/「恢复」按钮
- mock: 补 restore handler(disable 已有)
- 核实真后端 SummaryRespVO.processingStatus = kb.getStatus(),启用状态(disabled/searchable)
  经其透出可观测,排除"RespVO 缺启用状态字段"误判,确认无需改后端
- vitest useKnowledge 5/5(disable→disabled→restore→active)+ 全套 78/78,tsc/eslint 0
- e2e knowledge-disable-restore 真后端 1 passed;global-setup section 9 复位 KB1 status='draft'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-21 09:42:29 -07:00
parent cc57c595d8
commit 494f3b5fd6
6 changed files with 190 additions and 2 deletions

View File

@ -230,7 +230,16 @@ async function globalSetup(): Promise<void> {
AND id <> (SELECT id FROM muse_content_block WHERE tenant_id=1 AND work_id=1 AND chapter_id=1 ORDER BY order_no, id LIMIT 1)`
);
console.log('[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block)');
// 9) knowledge 停用/恢复:复位自建 KB(id=1)status='draft'——供 F disable/restore e2e 幂等重跑
// (e2e 会 disable→'disabled'、restore→'searchable',含中途残留态一并归位到初始 draft)。
const kbReset = await client.query(
"UPDATE muse_knowledge_base SET status='draft' WHERE tenant_id=1 AND id=1"
);
if (kbReset.rowCount === 0) {
console.warn('[e2e globalSetup] ⚠️ 未找到 KB id=1(knowledge 停用/恢复用例需基础 KB 种子)');
}
console.log('[e2e globalSetup] 已复位每轮 fixture(graph/confirm-draft/accept/security-ack/appeal/binding/meta-schema/block/kb-status)');
} finally {
await client.end();
}

View File

@ -0,0 +1,48 @@
import { expect, test, type Page } from '@playwright/test';
/**
* /(,MSW off)
*
* F 链路: /knowledge tab KB (restoresearchable)
*(disabledisabled)
* 关键:真后端 SummaryRespVO.processingStatus = kb.getStatus()(MuseKnowledgeBaseService#toAppSummary),
* (disabled/searchable) processingStatus ,studio normalizeKnowledgeStatus active/disabled
* 幂等:global-setup KB id=1 status='draft'(disable'disabled'restore'searchable' )
*/
const TOKEN = 'test1';
const KB_NAME = '活体绑定测试KB';
async function seedToken(page: Page): Promise<void> {
await page.addInitScript((t) => {
window.localStorage.setItem('accessToken', t);
window.localStorage.setItem('tenantId', '1');
}, TOKEN);
}
test('用户停用/恢复自建知识库(真实后端)', async ({ page }) => {
await seedToken(page);
// 「停用」走 window.confirm,自动接受。
page.on('dialog', (d) => d.accept());
await page.goto('/knowledge');
// 等自建 KB 卡片(真后端 "活体绑定测试KB")。初始 draft → 非 active → 显示「恢复」。
await expect(page.getByText(KB_NAME)).toBeVisible({ timeout: 15_000 });
// 1) 恢复:点「恢复」→ restore(kb.status→searchable→normalize active)→「已启用」+「停用」按钮。
const restoreResp = page.waitForResponse(
(r) => /\/knowledge-bases\/\d+\/restore/.test(r.url()) && r.request().method() === 'POST'
);
await page.getByRole('button', { name: `恢复知识库 ${KB_NAME}` }).click();
expect((await restoreResp).status()).toBe(200);
await expect(page.getByText('已启用')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: `停用知识库 ${KB_NAME}` })).toBeVisible({ timeout: 10_000 });
// 2) 停用:点「停用」(confirm 自动接受)→ disable(kb.status→disabled)→「已停用」+「恢复」按钮。
const disableResp = page.waitForResponse(
(r) => /\/knowledge-bases\/\d+\/disable/.test(r.url()) && r.request().method() === 'POST'
);
await page.getByRole('button', { name: `停用知识库 ${KB_NAME}` }).click();
expect((await disableResp).status()).toBe(200);
await expect(page.getByText('已停用')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: `恢复知识库 ${KB_NAME}` })).toBeVisible({ timeout: 10_000 });
});

View File

@ -365,6 +365,25 @@ export const knowledgeHandlers = [
});
}),
// POST /app-api/muse/knowledge-bases/:kbId/restore — 恢复已停用知识库(status→active)
http.post('/app-api/muse/knowledge-bases/:kbId/restore', ({ params }) => {
const { kbId } = params;
const kb = mockKnowledgeBases.find((item) => item.kbId === kbId);
if (!kb) {
return HttpResponse.json({ code: 40403, msg: '知识库未找到', data: null }, { status: 404 });
}
kb.status = 'active';
kb.updatedAt = new Date().toISOString();
return HttpResponse.json({
code: 0,
msg: 'success',
data: kb,
});
}),
// GET /app-api/muse/knowledge-bases/:kbId/documents — 获取知识库下的资料列表
http.get('/app-api/muse/knowledge-bases/:kbId/documents', ({ params }) => {
const { kbId } = params;

View File

@ -6,6 +6,8 @@ import {
useKnowledgeCreate,
useKnowledgeDelete,
useInstalledKnowledgeUninstall,
useKnowledgeDisable,
useKnowledgeRestore,
useDocumentList,
useDocumentUpload,
type DocumentVO,
@ -172,4 +174,38 @@ describe('useKnowledge API Hooks 单元测试', () => {
unmountActive();
});
it('useKnowledgeDisable & useKnowledgeRestore 应切换知识库启用状态', async () => {
// 拿一个 active 的自建 KB。
const { result: listHook, unmount: unmountList } = renderHook(() => useKnowledgeList('user'), { wrapper });
await waitFor(() => expect(listHook.current.isSuccess).toBe(true));
const target = (listHook.current.data?.list || []).filter(isKnowledgeBaseSummary).find((k) => k.status === 'active');
const kbId = target?.kbId ?? '';
expect(kbId).toBeTruthy();
unmountList();
// 停用 → status=disabled。
const { result: disableHook, unmount: unmountDisable } = renderHook(() => useKnowledgeDisable(), { wrapper });
disableHook.current.mutate(kbId);
await waitFor(() => expect(disableHook.current.isSuccess).toBe(true));
unmountDisable();
const { result: afterDisable, unmount: u1 } = renderHook(() => useKnowledgeList('user'), { wrapper });
await waitFor(() => expect(afterDisable.current.isSuccess).toBe(true));
const disabledKb = (afterDisable.current.data?.list || []).filter(isKnowledgeBaseSummary).find((k) => k.kbId === kbId);
expect(disabledKb?.status).toBe('disabled');
u1();
// 恢复 → status=active。
const { result: restoreHook, unmount: unmountRestore } = renderHook(() => useKnowledgeRestore(), { wrapper });
restoreHook.current.mutate(kbId);
await waitFor(() => expect(restoreHook.current.isSuccess).toBe(true));
unmountRestore();
const { result: afterRestore, unmount: u2 } = renderHook(() => useKnowledgeList('user'), { wrapper });
await waitFor(() => expect(afterRestore.current.isSuccess).toBe(true));
const restoredKb = (afterRestore.current.data?.list || []).filter(isKnowledgeBaseSummary).find((k) => k.kbId === kbId);
expect(restoredKb?.status).toBe('active');
u2();
});
});

View File

@ -328,6 +328,45 @@ export function useInstalledKnowledgeUninstall() {
});
}
/**
* Mutation Hook
* POST /knowledge-bases/{kbId}/disable; statusdisabled,(,)
*
* @returns React Query
*/
export function useKnowledgeDisable() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (kbId: string) => api.post<unknown>(`/knowledge-bases/${kbId}/disable`, {
commandId: createCommandId(),
impactConfirmed: true,
}),
onSuccess: (_, kbId) => {
queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] });
queryClient.invalidateQueries({ queryKey: ['knowledgeDetail', kbId] });
},
});
}
/**
* Mutation Hook
* POST /knowledge-bases/{kbId}/restore; statusactive
*
* @returns React Query
*/
export function useKnowledgeRestore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (kbId: string) => api.post<unknown>(`/knowledge-bases/${kbId}/restore`, {
commandId: createCommandId(),
}),
onSuccess: (_, kbId) => {
queryClient.invalidateQueries({ queryKey: ['knowledgeBases'] });
queryClient.invalidateQueries({ queryKey: ['knowledgeDetail', kbId] });
},
});
}
/**
* of Query Hook
* refetchInterval

View File

@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
import KnowledgeDraftPanel from '@/features/knowledge/components/KnowledgeDraftPanel';
import KnowledgeGraphPanel from '@/features/knowledge/components/KnowledgeGraphPanel';
import KnowledgeBindingsPanel from '@/features/knowledge/components/KnowledgeBindingsPanel';
import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall } from '@/features/knowledge/hooks/useKnowledge';
import { useKnowledgeList, useKnowledgeDelete, useInstalledKnowledgeUninstall, useKnowledgeDisable, useKnowledgeRestore } from '@/features/knowledge/hooks/useKnowledge';
import type { KnowledgeBaseVO } from '@/features/knowledge/hooks/useKnowledge';
import CreateKBModal from '@/features/knowledge/components/CreateKBModal';
import KnowledgePublishModal from '@/features/knowledge/components/KnowledgePublishModal';
@ -27,6 +27,8 @@ export default function KnowledgePage() {
const { data, isLoading, isError } = useKnowledgeList(activeTab);
const { mutate: deleteKB } = useKnowledgeDelete();
const { mutate: uninstallKB } = useInstalledKnowledgeUninstall();
const { mutate: disableKB } = useKnowledgeDisable();
const { mutate: restoreKB } = useKnowledgeRestore();
// 2. 严格的运行期类型收窄与防空校验
const rawList = data?.list || [];
@ -55,6 +57,19 @@ export default function KnowledgePage() {
}
};
// 停用:软关闭知识库(资产保留可恢复,区别于物理删除);恢复:重新启用。均阻止冒泡防误触跳转资料面板。
const handleDisableKB = (e: React.MouseEvent, kbId: string, name: string) => {
e.stopPropagation();
if (window.confirm(`确认停用知识库《${name}》吗?\n停用后该库在生成中不可用,但资产保留,可随时恢复。`)) {
disableKB(kbId);
}
};
const handleRestoreKB = (e: React.MouseEvent, kbId: string) => {
e.stopPropagation();
restoreKB(kbId);
};
// 3. 拦截状态:如果选中了具体知识库,渲染资料文件管理器 (Detail/Materials Panel)
if (selectedKbId) {
const activeKb = knowledgeBases.find((kb) => kb.kbId === selectedKbId);
@ -219,6 +234,28 @@ export default function KnowledgePage() {
>
</button>
{/* 停用/恢复:按 status 切换(active 显示停用,否则显示恢复);软状态管理,区别于物理删除。 */}
{kb.status === 'active' ? (
<button
onClick={(e) => handleDisableKB(e, kb.kbId, kb.name)}
type="button"
title="停用知识库"
aria-label={`停用知识库 ${kb.name}`}
className="rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600 transition-all hover:bg-amber-50 hover:text-amber-800"
>
</button>
) : (
<button
onClick={(e) => handleRestoreKB(e, kb.kbId)}
type="button"
title="恢复知识库"
aria-label={`恢复知识库 ${kb.name}`}
className="rounded px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600 transition-all hover:bg-emerald-50 hover:text-emerald-800"
>
</button>
)}
<button
onClick={(e) => handleDeleteKB(e, kb.kbId, kb.name)}
type="button"