diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
| commit | a7aab01f051487327e5ce52b6a8905208c546e8c (patch) | |
| tree | 630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src/components/Layout.svelte | |
| parent | 375584174fb7191a57c5423fea45924ad6d812d5 (diff) | |
| download | strategy-lab-a7aab01f051487327e5ce52b6a8905208c546e8c.tar.gz | |
[变更性质] feat(frontend)
[新增功能] 12 页面组件全量 shadcn:Card/Button/Table/Tabs/Select/Alert;375 底部 Tab Bar 改用 Tabs 组件实现
[实现方案] 页面级手写 class 全部移除(.btn/.banner/table.data/.badge/.tabs/.metric-row/.m-tile/.hint 等改为 shadcn + tailwind 工具类);Modal/DatasetWizard/DatasetCard/DiffView/CodeEditor/Status/Layout 同步
[影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src/components/Layout.svelte')
| -rw-r--r-- | frontend/src/components/Layout.svelte | 344 |
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> |
