summaryrefslogtreecommitdiff
path: root/frontend/src/components/Layout.svelte
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/components/Layout.svelte
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/components/Layout.svelte')
-rw-r--r--frontend/src/components/Layout.svelte241
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>