PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
// AppShell 响应式单测(B1):四档断点的 class 切换(FR-RESP-001..004)
// jsdom + matchMedia mock。验证:
// desktop ≥1280:无 drawers-mode,折叠 class 生效
// laptop 1024–1279:无 drawers-mode(CSS 强制折叠由样式层处理)
// tablet 768–1023:drawers-mode
// mobile <768:drawers-mode + mobile
// 折叠持久化:localStorage pa-side-collapsed / pa-bench-collapsed
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia } from 'pinia';
import { nextTick } from 'vue';
import AppShell from '../../../../frontend/src/components/layout/AppShell.vue';
// mock fetch(health store 若被使用则不真发请求)
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))));
/** 用 matchMedia mock 设置视口宽度 */
function setViewport(w) {
window.matchMedia = (q) => ({
matches: false,
media: q,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: w });
}
async function mountShell(w = 1440) {
setViewport(w);
const pinia = createPinia();
const wrapper = mount(AppShell, { global: { plugins: [pinia] } });
await nextTick();
return wrapper;
}
describe('AppShell 响应式四档断点', () => {
beforeEach(() => {
localStorage.clear();
});
it('desktop ≥1280px:三栏,无 drawers-mode;折叠 class 与 localStorage 联动', async () => {
const w = await mountShell(1440);
const shell = w.find('.app-shell');
expect(shell.exists()).toBe(true);
expect(shell.classes()).not.toContain('drawers-mode');
expect(shell.classes()).not.toContain('mobile');
// 默认展开
expect(shell.classes()).not.toContain('side-collapsed');
expect(shell.classes()).not.toContain('bench-collapsed');
// 折叠把手点击 → side-collapsed + localStorage
await w.find('.side-handle-btn').trigger('click');
expect(w.find('.app-shell').classes()).toContain('side-collapsed');
expect(localStorage.getItem('pa-side-collapsed')).toBe('1');
await w.find('.side-handle-btn').trigger('click');
expect(localStorage.getItem('pa-side-collapsed')).toBe('0');
// bench 折叠(折叠入口按钮)
await w.find('.bench-collapse-btn').trigger('click');
expect(w.find('.app-shell').classes()).toContain('bench-collapsed');
expect(localStorage.getItem('pa-bench-collapsed')).toBe('1');
w.unmount();
});
it('折叠状态刷新后保持(localStorage 持久化)', async () => {
localStorage.setItem('pa-side-collapsed', '1');
localStorage.setItem('pa-bench-collapsed', '1');
const w = await mountShell(1440);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('side-collapsed');
expect(cls).toContain('bench-collapsed');
w.unmount();
});
it('laptop 1024–1279px:三栏保留(CSS 锁折叠),无 drawers-mode', async () => {
const w = await mountShell(1180);
const cls = w.find('.app-shell').classes();
expect(cls).not.toContain('drawers-mode');
expect(cls).not.toContain('mobile');
// 折叠把手在笔记本档由 CSS 隐藏(类逻辑上仍可点击,但桌面外不提供)
expect(w.find('.side-handle-btn').exists()).toBe(true);
w.unmount();
});
it('tablet 768–1023px:drawers-mode(左右栏改抽屉),无 mobile', async () => {
const w = await mountShell(900);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('drawers-mode');
expect(cls).not.toContain('mobile');
expect(cls).toContain('has-tools');
// 网格中的左右栏不再渲染(抽屉形态)
expect(w.find('.aside-host').exists()).toBe(false);
expect(w.find('.bench-wrap').exists()).toBe(false);
// 抽屉组件存在(初始关闭)
expect(w.findAllComponents({ name: 'Drawer' })).toBeTruthy();
w.unmount();
});
it('mobile <768px:drawers-mode + mobile', async () => {
const w = await mountShell(600);
const cls = w.find('.app-shell').classes();
expect(cls).toContain('drawers-mode');
expect(cls).toContain('mobile');
expect(w.find('.aside-host').exists()).toBe(false);
expect(w.find('.bench-wrap').exists()).toBe(false);
w.unmount();
});
it('汉堡按钮打开会话抽屉(<1024px)', async () => {
const w = await mountShell(900);
// 顶栏汉堡按钮(menuAvailable 为 true 时渲染)
const burger = w.find('.top-bar .hamburger-btn');
expect(burger.exists()).toBe(true);
await burger.trigger('click');
await nextTick();
// Drawer 已打开(Teleport 到 body,用 document 查询)
expect(document.querySelectorAll('.drawer.left.open').length).toBe(1);
// Esc 关闭
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await nextTick();
expect(document.querySelectorAll('.drawer.left.open').length).toBe(0);
w.unmount();
});
it('工作台按钮打开右抽屉(<1024px)', async () => {
const w = await mountShell(900);
const btn = w.find('.top-bar .bench-btn');
expect(btn.exists()).toBe(true);
await btn.trigger('click');
await nextTick();
expect(document.querySelectorAll('.drawer.right.open').length).toBe(1);
// scrim 点击关闭
document.querySelector('.drawer-scrim')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(document.querySelectorAll('.drawer.right.open').length).toBe(0);
w.unmount();
});
});