summaryrefslogtreecommitdiff
path: root/frontend/src/components/Layout.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/Layout.svelte')
-rw-r--r--frontend/src/components/Layout.svelte344
1 files changed, 76 insertions, 268 deletions
diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte
index ddd5090..18a79a2 100644
--- a/frontend/src/components/Layout.svelte
+++ b/frontend/src/components/Layout.svelte
@@ -5,6 +5,8 @@
import { postLogout } from '../lib/client';
import { projectsStore, projectOf, projectInvisible } from '../lib/listsStore.svelte';
import { Button } from '$lib/components/ui/button';
+ import * as Tabs from '$lib/components/ui/tabs';
+ import { Separator } from '$lib/components/ui/separator';
interface Props {
route: Route;
@@ -55,9 +57,7 @@
if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined);
});
- // ---- 移动端导航重设计(375 → 底部 Tab Bar)----
- // 小型 matchMedia 反应式:jsdom 没有布局,真实渲染断言
- // (mobileTabbar.test.ts)通过 matchMedia stub 驱动 phone/桌面两种形态。
+ // ---- 移动端导航(≤767px 底部 Tab Bar,shadcn Tabs 组件实现)----
import { onDestroy } from 'svelte';
let phoneMode = $state(false);
let mqListener: ((e: MediaQueryListEvent) => void) | null = null;
@@ -90,301 +90,109 @@
const activeTab = $derived(
isProject || route.name === 'admin' ? 'projects' : route.name === 'account' ? 'account' : route.name
);
+
+ const navLinkBase = 'block rounded-md py-1.5 px-2 text-sm no-underline hover:text-foreground';
</script>
-<div class="layout">
+<div class="grid min-h-screen grid-cols-1 md:grid-cols-[208px_minmax(0,1fr)]">
{#if mobile}
- <header class="topbar" data-testid="mobile-topbar">
- <span class="topbar-brand">策研 <em>Strategy Lab</em></span>
+ <header
+ class="sticky top-0 z-30 flex items-center gap-2 min-h-10 px-2.5 border-b border-border bg-sidebar/90 backdrop-blur font-sm text-[0.85rem]"
+ data-testid="mobile-topbar"
+ >
+ <span class="font-bold text-foreground whitespace-nowrap">策研 <em class="not-italic font-medium text-[0.62rem] text-muted-foreground block">Strategy Lab</em></span>
{#if isProject}
- <span class="topbar-crumb" aria-label="当前项目">{activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''}</span>
+ <span class="flex-1 min-w-0 truncate text-center font-semibold text-foreground" aria-label="当前项目">{activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''}</span>
{/if}
- <button class="topbar-me" onclick={() => go('/account')} aria-label="账户与安全">
+ <Button variant="ghost" size="icon-sm" 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>
+ </Button>
</header>
{/if}
- <aside class="sidebar" aria-label="全局导航">
- <a href="#/projects" class="brand">
- <span class="brand-mark" aria-hidden="true"></span>
- <span class="brand-text">策研 <span class="brand-sub">Strategy Lab</span></span>
+ <aside
+ class="bg-sidebar border-b md:border-b-0 md:border-r border-border md:sticky md:top-0 md:overflow-y-auto md:h-screen max-md:hidden flex flex-row md:flex-col flex-wrap md:flex-nowrap items-center md:items-stretch gap-1 md:gap-2 px-2 md:px-3 py-2 md:py-3"
+ aria-label="全局导航"
+ >
+ <a href="#/projects" class="flex items-center gap-1.5 py-1 px-1 pb-1 border-b border-border mb-1 no-underline w-full text-foreground">
+ <span class="size-5 rounded-lg bg-primary shrink-0" aria-hidden="true"></span>
+ <span class="font-bold text-[0.98rem] tracking-tight">策研 <span class="text-muted-foreground/70 font-medium text-[0.68rem] block">Strategy Lab</span></span>
</a>
{#if isProject}
- <div class="projctx">
- <div class="nav-title">当前项目</div>
- <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div>
+ <div class="flex flex-wrap md:block gap-0.5 w-full min-w-0">
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">当前项目</div>
+ <div class="font-semibold px-1.5 pt-0.5 pb-1 text-[0.9rem] break-all text-foreground md:max-md:w-full">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div>
{#each projectNav as [t, label] (t)}
<a
- class="navlink"
+ class={`${navLinkBase} text-[0.85rem] ${projectTab === t ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`}
href={`#/projects/${route.params.id}/${t}`}
aria-current={projectTab === t ? 'page' : undefined}
>{label}</a>
{/each}
- <div class="divider" aria-hidden="true"></div>
+ <Separator class="hidden md:block mx-2 my-2" />
</div>
{/if}
- <div class="nav-title">工作台</div>
- {#each topLeft as item (item.name)}
- <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
- {item.label}
- </a>
- {/each}
-
- <div class="push"></div>
- <div class="nav-title">账户</div>
- {#each bottom as item (item.name)}
- <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
- {item.label}
- </a>
- {/each}
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">工作台</div>
+ <div class="flex flex-wrap md:flex-col gap-0.5">
+ {#each topLeft as item (item.name)}
+ <a class={`${navLinkBase} ${route.name === item.name ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`} href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+ </div>
+
+ <div class="flex-1 md:min-h-4"></div>
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">账户</div>
+ <div class="flex flex-wrap md:flex-col gap-0.5">
+ {#each bottom as item (item.name)}
+ <a class={`${navLinkBase} ${route.name === item.name ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`} href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+ </div>
{#if session.me}
- <div class="me">
- <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span>
+ <div class="flex items-center justify-between gap-1 p-1 bg-secondary rounded-md text-[0.84rem] w-full">
+ <span class="truncate text-muted-foreground min-w-0">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span>
<Button variant="ghost" size="sm" onclick={() => logout()}>退出</Button>
</div>
{/if}
</aside>
- <main class="content" aria-label="主内容">
+ <main class="p-4 md:px-8 md:pt-6 max-w-[1360px] w-full min-w-0 pb-16 md:pb-12" aria-label="主内容">
{@render children?.()}
</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>
+ <Tabs.Root
+ value={activeTab}
+ onValueChange={(v) => go(`/${v}`)}
+ data-testid="mobile-tabbar"
+ class="fixed left-0 right-0 bottom-0 z-40 flex items-stretch justify-around min-h-14 pb-[env(safe-area-inset-bottom,0)] bg-card/95 border-t border-border backdrop-blur"
+ aria-label="底部导航"
+ >
+ <Tabs.List variant="line" class="w-full max-w-none justify-around p-0 rounded-none h-auto">
+ {#each tabs as t (t.name)}
+ <Tabs.Trigger
+ value={t.name}
+ data-tab={t.name}
+ class="flex-1 min-h-14 flex-col gap-0.5 rounded-none text-[0.66rem] data-active:font-semibold"
+ aria-current={activeTab === t.name ? 'page' : undefined}
+ >
+ {#if t.icon === 'project'}
+ <svg viewBox="0 0 24 24" class="size-5" 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" class="size-5" 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" class="size-5" 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" class="size-5" 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>{t.label}</span>
+ </Tabs.Trigger>
+ {/each}
+ </Tabs.List>
+ </Tabs.Root>
{/if}
-
-<style>
- .layout {
- display: grid;
- grid-template-columns: 208px minmax(0, 1fr);
- min-height: 100vh;
- }
- .sidebar {
- background: var(--sidebar);
- border-right: 1px solid var(--border);
- padding: 1rem 0.75rem;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- position: sticky;
- top: 0;
- height: 100vh;
- overflow-y: auto;
- }
- .brand {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- padding: 0.3rem 0.4rem 0.6rem;
- border-bottom: 1px solid var(--border);
- margin-bottom: 0.4rem;
- color: var(--foreground);
- }
- .brand:hover { text-decoration: none; }
- .brand-mark {
- width: 22px;
- height: 22px;
- border-radius: calc(var(--radius) * 1.2);
- background: var(--primary);
- flex-shrink: 0;
- position: relative;
- overflow: hidden;
- }
- .brand-mark::after {
- content: '';
- position: absolute;
- inset: 4px;
- background: linear-gradient(
- to top,
- transparent 4px,
- #f0fdfa 4px,
- #f0fdfa 7px,
- transparent 7px,
- transparent 11px,
- #f0fdfa 11px,
- #f0fdfa 15px,
- transparent 15px
- ) left/ 3px 100% no-repeat;
- opacity: 0.9;
- }
- .brand-text { font-weight: 700; font-size: 0.98rem; letter-spacing: 0.01em; }
- .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.68rem; display: block; }
- .nav-title {
- font-size: 0.68rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-3);
- padding: 0.35rem 0.5rem 0.1rem;
- font-weight: 700;
- }
- .navlink {
- display: block;
- padding: 0.32rem 0.55rem;
- border-radius: var(--radius);
- color: var(--text-2);
- font-size: 0.9rem;
- line-height: 1.5;
- }
- .navlink:hover { background: var(--secondary); text-decoration: none; color: var(--foreground); }
- .navlink[aria-current='page'] {
- background: var(--primary);
- color: var(--primary-foreground);
- font-weight: 600;
- }
- .projctx .navlink { font-size: 0.85rem; }
- .projname {
- font-weight: 600;
- padding: 0 0.6rem 0.25rem;
- font-size: 0.9rem;
- overflow-wrap: anywhere;
- color: var(--foreground);
- }
- .divider {
- height: 1px;
- background: var(--border);
- margin: 0.6rem 0.3rem;
- }
- .push { flex: 1; min-height: 1rem; }
- .me {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.4rem;
- padding: 0.4rem;
- background: var(--secondary);
- border-radius: var(--radius);
- font-size: 0.84rem;
- }
- .me-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--text-2);
- min-width: 0;
- }
- .content {
- padding: 1.5rem 2rem 3rem;
- max-width: 1360px;
- width: 100%;
- /* grid/flex children default to min-content sizing; without min-width:0 a
- wide table cell propagates min-content into the column and inflates the
- whole document (375px probe measured 552px). */
- min-width: 0;
- }
- @media (max-width: 900px) {
- .layout { grid-template-columns: minmax(0, 1fr); }
- .sidebar {
- position: static;
- height: auto;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: center;
- border-bottom: 1px solid var(--border);
- border-right: none;
- gap: 0.25rem;
- min-width: 0;
- }
- .nav-title, .divider, .push { display: none; }
- .navlink { padding: 0.45rem 0.7rem; }
- /* the project tab row must wrap, not force one long line */
- .projctx { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
- .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>