diff options
Diffstat (limited to 'frontend/src/components/Layout.svelte')
| -rw-r--r-- | frontend/src/components/Layout.svelte | 241 |
1 files changed, 241 insertions, 0 deletions
diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte new file mode 100644 index 0000000..78cee45 --- /dev/null +++ b/frontend/src/components/Layout.svelte @@ -0,0 +1,241 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { go } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { postLogout } from '../lib/client'; + import { projectOf, projectInvisible, projectsStore } from '../lib/listsStore.svelte'; + + interface Props { + route: Route; + children?: import('svelte').Snippet; + } + let { route, children }: Props = $props(); + + const topLeft: { name: string; label: string }[] = [ + { name: 'projects', label: '我的项目' }, + { name: 'datasets', label: '数据集' }, + { name: 'runs', label: '实验记录' }, + { name: 'usage', label: 'AI 用量' } + ]; + + const bottom: { name: string; label: string }[] = + session.me?.role === 'admin' + ? [ + { name: 'account', label: '账户与安全' }, + { name: 'admin', label: '管理员' } + ] + : [{ name: 'account', label: '账户与安全' }]; + + async function logout() { + await postLogout().catch(() => undefined); + session.set(null); + // drop cached projects/failure marks: the next session on this browser + // must not see (or be marked invisible by) the previous user's projects + projectsStore.clear(); + go('/login'); + } + + const isProject = $derived(route.name.startsWith('project-')); + const projectTab = $derived(isProject ? route.name.replace('project-', '') : ''); + const projectNav: [string, string][] = [ + ['data', '数据'], + ['strategy', '策略'], + ['backtest', '回测'], + ['results', '结果'] + ]; + const activeProject = $derived(isProject ? projectOf(route.params.id) : null); + const projectHidden = + $derived(isProject && !activeProject && projectInvisible(route.params.id)); + // Deep link / first navigation into a project tab: the sidebar store may not + // contain the project yet (ProjectsPage was never visited). Fetch it once so + // the 当前项目 name resolves without waiting for the tab's own lifecycle. + $effect(() => { + const id = isProject ? route.params.id : null; + if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined); + }); +</script> + +<div class="layout"> + <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> + </a> + + {#if isProject} + <div class="projctx"> + <div class="nav-title">当前项目</div> + <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div> + {#each projectNav as [t, label] (t)} + <a + class="navlink" + href={`#/projects/${route.params.id}/${t}`} + aria-current={projectTab === t ? 'page' : undefined} + >{label}</a> + {/each} + <div class="divider" aria-hidden="true"></div> + </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} + {#if session.me} + <div class="me"> + <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span> + <button class="btn small ghost" onclick={() => logout()}>退出</button> + </div> + {/if} + </aside> + + <main class="content" aria-label="主内容"> + {@render children?.()} + </main> +</div> + +<style> + .layout { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; + } + .sidebar { + background: var(--surface); + border-right: 1px solid var(--border); + padding: 1rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + } + .brand { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.4rem; + color: var(--text); + } + .brand:hover { text-decoration: none; } + .brand-mark { + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--teal-strong); + flex-shrink: 0; + position: relative; + overflow: hidden; + } + .brand-mark::after { + content: ''; + position: absolute; + inset: 5px; + background: linear-gradient( + to top, + transparent 4px, + #f0fdfa 4px, + #f0fdfa 8px, + transparent 8px, + transparent 13px, + #f0fdfa 13px, + #f0fdfa 19px, + transparent 19px + ) left/ 4px 100% no-repeat; + opacity: 0.85; + } + .brand-text { font-weight: 700; font-size: 1.02rem; letter-spacing: 0.02em; } + .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.72rem; display: block; } + .nav-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + padding: 0.4rem 0.5rem 0.1rem; + font-weight: 700; + } + .navlink { + display: block; + padding: 0.4rem 0.6rem; + border-radius: 8px; + color: var(--text-2); + font-size: 0.92rem; + } + .navlink:hover { background: var(--surface-2); text-decoration: none; } + .navlink[aria-current='page'] { + background: var(--teal-soft); + color: var(--teal-strong); + font-weight: 600; + } + .projctx .navlink { font-size: 0.88rem; } + .projname { + font-weight: 600; + padding: 0 0.6rem 0.25rem; + font-size: 0.94rem; + } + .divider { + height: 1px; + background: var(--border); + margin: 0.7rem 0.3rem; + } + .push { flex: 1; min-height: 1rem; } + .me { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + padding: 0.5rem; + background: var(--surface-2); + border-radius: 8px; + font-size: 0.86rem; + } + .me-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); + } + .content { + padding: 1.6rem 2.2rem 3rem; + max-width: 1200px; + 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.3rem; + min-width: 0; + } + .nav-title, .divider, .push { display: none; } + .navlink { padding: 0.35rem 0.7rem; } + /* the project tab row must wrap, not force one long line */ + .projctx { display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 0; } + .projname { max-width: 100%; overflow-wrap: anywhere; } + .content { padding: 1.1rem 1rem 3rem; max-width: 100%; } + } +</style> |
