summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/Layout.svelte144
-rw-r--r--frontend/src/lib/mobileTabbar.test.ts69
-rw-r--r--frontend/src/pages/ProjectPage.svelte48
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>