summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Layout.svelte144
1 files changed, 144 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>