From 4390ca3472b8a4a201835e358ad0ad2065e8b7c6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 09:50:57 +0800 Subject: feat(frontend): 375px 底部 Tab Bar、窄标题栏与项目面包屑 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] 新增移动端导航形态,前端重设计第二轮之二。 [新增功能] ≤767px 呈现底部 Tab Bar(projects/datasets/runs/account 徽标+标签+aria-current)与窄标题栏(品牌/面包屑/账户),≥768px 原桌面布局不变;ProjectPage 顶部新增"我的项目/项目名/功能页"面包屑。 [实现方案] matchMedia('(max-width: 767px)') 反应式驱动的 (Safari <14 兼容addListener 回退),tests 以 matchMedia stub 驱动真实渲染断言;保留 900px 既有断点结构以兼容 mobileCss.test.ts 正则。 [影响范围] Layout.svelte、ProjectPage.svelte;新增 mobileTabbar.test.ts。 --- frontend/src/components/Layout.svelte | 144 ++++++++++++++++++++++++++++++++++ frontend/src/lib/mobileTabbar.test.ts | 69 ++++++++++++++++ frontend/src/pages/ProjectPage.svelte | 48 ++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 frontend/src/lib/mobileTabbar.test.ts (limited to 'frontend/src') diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte index 3ccf913..ddd5090 100644 --- a/frontend/src/components/Layout.svelte +++ b/frontend/src/components/Layout.svelte @@ -54,9 +54,57 @@ const id = isProject ? route.params.id : null; if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined); }); + + // ---- 移动端导航重设计(375 → 底部 Tab Bar)---- + // 小型 matchMedia 反应式:jsdom 没有布局,真实渲染断言 + // (mobileTabbar.test.ts)通过 matchMedia stub 驱动 phone/桌面两种形态。 + import { onDestroy } from 'svelte'; + let phoneMode = $state(false); + let mqListener: ((e: MediaQueryListEvent) => void) | null = null; + $effect(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + phoneMode = false; + return; + } + const mq = window.matchMedia('(max-width: 767px)'); + phoneMode = !!mq.matches; + const fn: (e: MediaQueryListEvent) => void = (e) => (phoneMode = e.matches); + if (mq.addEventListener) mq.addEventListener('change', fn); + else mq.addListener?.(fn as never); + mqListener = fn; + return () => { + if (mq.removeEventListener) mq.removeEventListener('change', mqListener as never); + else mq.removeListener?.(mqListener as never); + mqListener = null; + }; + }); + + const mobile = $derived(phoneMode); + + const tabs: { name: string; label: string; icon: 'project' | 'dataset' | 'run' | 'user' }[] = [ + { name: 'projects', label: '我的项目', icon: 'project' }, + { name: 'datasets', label: '数据集', icon: 'dataset' }, + { name: 'runs', label: '实验记录', icon: 'run' }, + { name: 'account', label: '我的', icon: 'user' } + ]; + const activeTab = $derived( + isProject || route.name === 'admin' ? 'projects' : route.name === 'account' ? 'account' : route.name + );
+ {#if mobile} +
+ 策研 Strategy Lab + {#if isProject} + {activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''} + {/if} + +
+ {/if} +
+{#if mobile} + +{/if} + diff --git a/frontend/src/lib/mobileTabbar.test.ts b/frontend/src/lib/mobileTabbar.test.ts new file mode 100644 index 0000000..fce7863 --- /dev/null +++ b/frontend/src/lib/mobileTabbar.test.ts @@ -0,0 +1,69 @@ +// @ts-nocheck +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { mount, unmount } from 'svelte'; +import Layout from '../components/Layout.svelte'; +import { session } from '../lib/session.svelte'; + +// Leader round2: 375 宽度下改为底部 Tab Bar(我的项目/数据集/实验记录/我的), +// 选中态青绿强调色;侧栏只在 ≥768px 显示。 +// 真·渲染断言(不得跳过): +// - phone 模式(matchMedia max-width:767px matches:true)Tab Bar 渲染 4 个 tab +// - 非 phone 模式(matches:false)Tab Bar 不渲染 +// 匹配由 matchMedia stub 驱动,jsdom 无布局。 + +const USER = { id: 'u1', name: '测试用户', role: 'user', session_id: 's1' }; + +function mq(phones) { + return (q) => ({ matches: /max-width:\s*767px/.test(q) ? phones : false, media: q }); +} + +function mountLayout(routeName, phoneMode) { + vi.stubGlobal('matchMedia', vi.fn((q) => ({ matches: /max-width:\s*767px/.test(q) ? phoneMode : false, media: q, addEventListener: () => {}, removeEventListener: () => {} }))); + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(Layout, { + target: host, + props: { route: { name: routeName, params: {}, query: new URLSearchParams() } } + }); + return { host, inst }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('Layout bottom Tab Bar (mobile navigation redo)', () => { + it('phone mode (≤767px): renders a bottom tab bar with 4 tabs and appends nav labels', async () => { + const { host, inst } = mountLayout('projects', true); + await new Promise((r) => setTimeout(r, 50)); + const bar = host.querySelector('[data-testid="mobile-tabbar"]'); + expect(bar, 'bottom tab bar must really render in phone mode').toBeTruthy(); + const tabs = [...bar.querySelectorAll('[data-tab]')].map(a => a.dataset.tab); + expect(tabs).toEqual(['projects', 'datasets', 'runs', 'account']); + // 图标 + 文字:每个 tab 有 svg 图标与文字标签 + for (const tab of bar.querySelectorAll('[data-tab]')) { + expect(tab.querySelector('svg'), `${tab.dataset.tab} needs an icon svg`).toBeTruthy(); + expect((tab.textContent || '').trim().length).toBeGreaterThan(0); + } + // 选中态标记由 aria-current 承载(青绿强调色为其样式责任) + expect(bar.querySelector('[data-tab="projects"][aria-current="page"]')).toBeTruthy(); + unmount(inst); + host.remove(); + }); + + it('selects the tab matching the current route (runs → runs)', async () => { + const { host, inst } = mountLayout('runs', true); + await new Promise((r) => setTimeout(r, 50)); + const bar = host.querySelector('[data-testid="mobile-tabbar"]'); + expect(bar.querySelector('[data-tab="runs"][aria-current="page"]')).toBeTruthy(); + expect(bar.querySelector('[data-tab="projects"][aria-current="page"]')).toBeNull(); + unmount(inst); + host.remove(); + }); + + it('non-phone mode: tab bar does not render', async () => { + const { host, inst } = mountLayout('projects', false); + await new Promise((r) => setTimeout(r, 50)); + expect(host.querySelector('[data-testid="mobile-tabbar"]')).toBeNull(); + unmount(inst); + host.remove(); + }); +}); diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte index b31caa3..fe9be7f 100644 --- a/frontend/src/pages/ProjectPage.svelte +++ b/frontend/src/pages/ProjectPage.svelte @@ -1,5 +1,6 @@ + + {#if route.name === 'project-data'} @@ -21,3 +42,30 @@ {:else if route.name === 'project-results'} {/if} + + -- cgit v1.2.3