diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/components/Layout.svelte | 144 | ||||
| -rw-r--r-- | frontend/src/lib/mobileTabbar.test.ts | 69 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectPage.svelte | 48 |
3 files changed, 261 insertions, 0 deletions
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 + ); </script> <div class="layout"> + {#if mobile} + <header class="topbar" data-testid="mobile-topbar"> + <span class="topbar-brand">策研 <em>Strategy Lab</em></span> + {#if isProject} + <span class="topbar-crumb" aria-label="当前项目">{activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''}</span> + {/if} + <button class="topbar-me" onclick={() => go('/account')} aria-label="账户与安全"> + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg> + </button> + </header> + {/if} + <aside class="sidebar" aria-label="全局导航"> <a href="#/projects" class="brand"> <span class="brand-mark" aria-hidden="true"></span> @@ -105,6 +153,25 @@ </main> </div> +{#if mobile} + <nav class="tabbar" data-testid="mobile-tabbar" aria-label="底部导航"> + {#each tabs as t (t.name)} + <a class="item" data-tab={t.name} href={`#/${t.name}`} aria-current={activeTab === t.name ? 'page' : undefined}> + {#if t.icon === 'project'} + <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M3 10h18" stroke="currentColor" stroke-width="2"/></svg> + {:else if t.icon === 'dataset'} + <svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="6" rx="8" ry="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="none" stroke="currentColor" stroke-width="2"/></svg> + {:else if t.icon === 'run'} + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20v-8" stroke="currentColor" stroke-width="2"/><path d="M9 20V4" stroke="currentColor" stroke-width="2"/><path d="M14 20v-6" stroke="currentColor" stroke-width="2"/><path d="M19 20V9" stroke="currentColor" stroke-width="2"/></svg> + {:else} + <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg> + {/if} + <span class="tab-label">{t.label}</span> + </a> + {/each} + </nav> +{/if} + <style> .layout { display: grid; @@ -243,4 +310,81 @@ .projname { max-width: 100%; overflow-wrap: anywhere; } .content { padding: 1.1rem 1rem 3rem; max-width: 100%; } } + + /* ---- 移动端形态(≤767px):侧栏隐藏,底部 Tab Bar + 极窄标题栏 ---- */ + @media (max-width: 767px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .sidebar { display: none; } + .content { padding: 0.9rem 0.9rem 5.2rem; max-width: 100%; } + } + .topbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: 0.6rem; + min-height: 2.6rem; + padding: 0 0.9rem; + background: color-mix(in srgb, var(--sidebar) 92%, transparent); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + } + .topbar-brand { font-weight: 700; color: var(--foreground); white-space: nowrap; } + .topbar-brand em { font-style: normal; font-weight: 500; font-size: 0.62rem; color: var(--text-3); display: block; } + .topbar-crumb { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--foreground); + text-align: center; + font-weight: 600; + } + .topbar-me { + all: unset; + cursor: pointer; + display: inline-flex; + padding: 0.25rem; + border-radius: 999px; + color: var(--text-2); + transition: background-color 150ms ease, color 150ms ease; + } + .topbar-me:hover { background: var(--secondary); color: var(--primary); } + + .tabbar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 40; + display: flex; + align-items: stretch; + justify-content: space-around; + min-height: 3.4rem; + padding-bottom: env(safe-area-inset-bottom, 0); + background: color-mix(in srgb, var(--surface) 96%, transparent); + border-top: 1px solid var(--border); + backdrop-filter: blur(8px); + } + .tabbar .item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.14rem; + flex: 1; + min-height: 3.4rem; + color: var(--text-3); + text-decoration: none; + font-size: 0.66rem; + transition: color 150ms ease, background-color 150ms ease; + } + .tabbar .item svg { width: 22px; height: 22px; display: block; } + .tabbar .item:hover { color: var(--text-2); text-decoration: none; } + /* 选中态:青绿强调色 */ + .tabbar .item[aria-current='page'] { color: var(--primary); font-weight: 600; } + .tabbar .item[aria-current='page'] svg { color: var(--primary); } </style> 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 @@ <script lang="ts"> import type { Route } from '../lib/router'; + import { projectsStore, projectOf, projectInvisible } from '../lib/listsStore.svelte'; import ProjectDataTab from '../pages/ProjectDataTab.svelte'; import ProjectStrategyTab from '../pages/ProjectStrategyTab.svelte'; import ProjectBacktestTab from '../pages/ProjectBacktestTab.svelte'; @@ -9,8 +10,28 @@ route: Route; } let { route }: Props = $props(); + + // 极简面包屑:我的项目 / 项目名 / 当前标签 + const isProject = $derived(route.name.startsWith('project-')); + const projectName = $derived( + projectOf(route.params.id)?.name ?? (projectInvisible(route.params.id) ? '不可见项目' : '加载中…') + ); + const tabLabel = $derived( + (({ data: '数据', strategy: '策略', backtest: '回测', results: '结果' })[route.name.replace('project-', '')] ?? '') + ); + $effect(() => { + if (isProject) void projectsStore.ensure(route.params.id).catch(() => undefined); + }); </script> +<nav class="crumbs" aria-label="面包屑"> + <a href="#/projects">我的项目</a> + <span aria-hidden="true">/</span> + <span class="crumb-name">{projectName}</span> + <span aria-hidden="true">/</span> + <span class="crumb-tab">{tabLabel}</span> +</nav> + <!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. --> {#if route.name === 'project-data'} <ProjectDataTab projectId={route.params.id} /> @@ -21,3 +42,30 @@ {:else if route.name === 'project-results'} <ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} /> {/if} + +<style> + /* 极简面包屑(桌面 + 移动端顶部共用) */ + .crumbs { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0 0.7rem; + font-size: 0.82rem; + color: var(--text-3); + min-width: 0; + } + .crumbs a { + color: var(--text-3); + transition: color 150ms ease; + } + .crumbs a:hover { color: var(--primary); text-decoration: none; } + .crumb-name { + color: var(--foreground); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 45vw; + } + .crumb-tab { color: var(--text-2); } +</style> |
