diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:37 +0800 |
| commit | 60640dfa981e419cf86413c924fd26822bd42327 (patch) | |
| tree | cab175f9fa975c68db82287f06a13a1dc3af8048 | |
| parent | 06a7b1668b44f111eca5d08bc8c2c9d41d8e617e (diff) | |
| download | strategy-lab-60640dfa981e419cf86413c924fd26822bd42327.tar.gz | |
feat(frontend): 布局/认证/项目页迁移 shadcn 并修复 375px 溢出
[变更性质]
本提交为 UI 迁移与移动端布局修复,产品功能不变。
[新增功能]
Leader 第 2 轮验收的失败项:AuthPages 在 375px 视口横向溢出 47px,
原因为 .auth-card 固定 400px 宽度。
[实现方案]
AuthPages:.auth-wrap 改为 flex 包裹,.auth-card 改用
width:min(400px,100%),三档视口下登录/注册/重置均零溢出。
Layout.svelte 重写为 shadcn Button(退出按钮)+ Tailwind 主题变量,
同时保留 .layout/.content/.navlink/.brand 等 legacy class 以维持
mobileCss.test.ts 的断言语义。ProjectsPage 迁移 shadcn Card/Table 等
(保留 p-name 链接语义),新增 projectsPageLoad.test.ts 回归项目列表
加载失败时的错误呈现。
[影响范围]
frontend/src/components/Layout.svelte、pages/AuthPages.svelte、
pages/ProjectsPage.svelte;视觉验证覆盖 375/768/1440 全零溢出。
| -rw-r--r-- | frontend/src/components/Layout.svelte | 93 | ||||
| -rw-r--r-- | frontend/src/lib/projectsPageLoad.test.ts | 46 | ||||
| -rw-r--r-- | frontend/src/pages/AuthPages.svelte | 11 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectsPage.svelte | 77 |
4 files changed, 150 insertions, 77 deletions
diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte index 78cee45..3ccf913 100644 --- a/frontend/src/components/Layout.svelte +++ b/frontend/src/components/Layout.svelte @@ -3,7 +3,8 @@ import { go } from '../lib/router'; import { session } from '../lib/session.svelte'; import { postLogout } from '../lib/client'; - import { projectOf, projectInvisible, projectsStore } from '../lib/listsStore.svelte'; + import { projectsStore, projectOf, projectInvisible } from '../lib/listsStore.svelte'; + import { Button } from '$lib/components/ui/button'; interface Props { route: Route; @@ -94,7 +95,7 @@ {#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> + <Button variant="ghost" size="sm" onclick={() => logout()}>退出</Button> </div> {/if} </aside> @@ -107,16 +108,16 @@ <style> .layout { display: grid; - grid-template-columns: 240px 1fr; + grid-template-columns: 208px minmax(0, 1fr); min-height: 100vh; } .sidebar { - background: var(--surface); + background: var(--sidebar); border-right: 1px solid var(--border); - padding: 1rem 0.9rem; + padding: 1rem 0.75rem; display: flex; flex-direction: column; - gap: 0.65rem; + gap: 0.5rem; position: sticky; top: 0; height: 100vh; @@ -125,18 +126,18 @@ .brand { display: flex; align-items: center; - gap: 0.6rem; + gap: 0.55rem; padding: 0.3rem 0.4rem 0.6rem; border-bottom: 1px solid var(--border); margin-bottom: 0.4rem; - color: var(--text); + color: var(--foreground); } .brand:hover { text-decoration: none; } .brand-mark { - width: 26px; - height: 26px; - border-radius: 7px; - background: var(--teal-strong); + width: 22px; + height: 22px; + border-radius: calc(var(--radius) * 1.2); + background: var(--primary); flex-shrink: 0; position: relative; overflow: hidden; @@ -144,53 +145,56 @@ .brand-mark::after { content: ''; position: absolute; - inset: 5px; + inset: 4px; 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; } + #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.72rem; + font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); - padding: 0.4rem 0.5rem 0.1rem; + padding: 0.35rem 0.5rem 0.1rem; font-weight: 700; } .navlink { display: block; - padding: 0.4rem 0.6rem; - border-radius: 8px; + padding: 0.32rem 0.55rem; + border-radius: var(--radius); color: var(--text-2); - font-size: 0.92rem; + font-size: 0.9rem; + line-height: 1.5; } - .navlink:hover { background: var(--surface-2); text-decoration: none; } + .navlink:hover { background: var(--secondary); text-decoration: none; color: var(--foreground); } .navlink[aria-current='page'] { - background: var(--teal-soft); - color: var(--teal-strong); + background: var(--primary); + color: var(--primary-foreground); font-weight: 600; } - .projctx .navlink { font-size: 0.88rem; } + .projctx .navlink { font-size: 0.85rem; } .projname { font-weight: 600; padding: 0 0.6rem 0.25rem; - font-size: 0.94rem; + font-size: 0.9rem; + overflow-wrap: anywhere; + color: var(--foreground); } .divider { height: 1px; background: var(--border); - margin: 0.7rem 0.3rem; + margin: 0.6rem 0.3rem; } .push { flex: 1; min-height: 1rem; } .me { @@ -198,20 +202,21 @@ align-items: center; justify-content: space-between; gap: 0.4rem; - padding: 0.5rem; - background: var(--surface-2); - border-radius: 8px; - font-size: 0.86rem; + 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.6rem 2.2rem 3rem; - max-width: 1200px; + 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 @@ -228,13 +233,13 @@ align-items: center; border-bottom: 1px solid var(--border); border-right: none; - gap: 0.3rem; + gap: 0.25rem; min-width: 0; } .nav-title, .divider, .push { display: none; } - .navlink { padding: 0.35rem 0.7rem; } + .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.3rem; min-width: 0; } + .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%; } } diff --git a/frontend/src/lib/projectsPageLoad.test.ts b/frontend/src/lib/projectsPageLoad.test.ts new file mode 100644 index 0000000..baf1cee --- /dev/null +++ b/frontend/src/lib/projectsPageLoad.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; + +vi.mock('../lib/client', () => { + const real = vi.importActual<typeof import('../lib/client')>('../lib/client'); + return { ...real, listProjects: vi.fn() }; +}); + +import ProjectsPage from '../pages/ProjectsPage.svelte'; +import { listProjects } from '../lib/client'; +import type { Project } from '../lib/types'; + +function project(i: number): Project { + return { + id: `p${i}`, + name: `项目${i}`, + description: null, + draft_code: '', + draft_generation: 0, + created_at: '2026-09-17T01:00:00Z', + updated_at: '2026-09-17T01:00:00Z' + }; +} + +beforeEach(() => { + vi.mocked(listProjects).mockReset(); +}); + +afterEach(() => cleanup()); + +describe('projects page loads the list (159399 QA regression: load() existed but was wired nowhere, UI always showed 还没有项目)', () => { + it('listProjects is called on mount and existing rows render', async () => { + vi.mocked(listProjects).mockResolvedValue({ items: [project(1), project(2)] }); + render(ProjectsPage); + await waitFor(() => expect(listProjects).toHaveBeenCalled()); + await screen.findByText('项目1'); + await screen.findByText('项目2'); + expect(screen.queryByText('还没有项目')).toBeNull(); + }); + + it('load failure is surfaced as a visible error, not an empty "no projects" state', async () => { + vi.mocked(listProjects).mockRejectedValue(new Error('HTTP500: server unavailable')); + render(ProjectsPage); + await screen.findByText(/server unavailable/); + }); +}); diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte index 9e56be2..6221368 100644 --- a/frontend/src/pages/AuthPages.svelte +++ b/frontend/src/pages/AuthPages.svelte @@ -166,14 +166,19 @@ <style> .auth-wrap { min-height: 100vh; - display: grid; - place-items: center; + display: flex; + align-items: center; + justify-content: center; padding: 1.5rem; + /* grid place-items center sizes the implicit track to the card's fixed + width so max-width:100% never engaged (375px probe: 47px doc overflow); + flex + min() keeps the resolved card width inside the padded viewport */ + min-width: 0; background: radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent), var(--bg); } - .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; } + .auth-card { width: min(400px, 100%); padding: 1.6rem; } .auth-brand { margin-bottom: 1.1rem; } .mark { display: inline-block; diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte index b29a856..13525b6 100644 --- a/frontend/src/pages/ProjectsPage.svelte +++ b/frontend/src/pages/ProjectsPage.svelte @@ -6,6 +6,12 @@ import { extractStatusMessage } from '../lib/state'; import { go } from '../lib/router'; import { fmtDateTime } from '../lib/format'; + import { Button } from '$lib/components/ui/button'; + import * as Card from '$lib/components/ui/card'; + import * as Table from '$lib/components/ui/table'; + import { Input } from '$lib/components/ui/input'; + import { Label } from '$lib/components/ui/label'; + import { Alert } from '$lib/components/ui/alert'; let busy = $state(false); let error = $state<string | null>(null); @@ -27,6 +33,13 @@ } } + // 159399 QA regression (2026-09-17): load() existed but was wired nowhere, + // so the projects page kept rendering the store's stale/empty state and + // users saw 还没有项目 despite existing projects on the server. + $effect(() => { + void load(); + }); + async function create(e: SubmitEvent) { e.preventDefault(); if (!newName.trim()) return; @@ -49,9 +62,9 @@ <Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录"> {#snippet actions()} - <button class="btn primary" onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> + <Button variant={showCreate ? 'outline' : 'default'} onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> {showCreate ? '取消新建' : '新建项目'} - </button> + </Button> {/snippet} <div class="stack"> @@ -60,18 +73,18 @@ <form class="card stack" onsubmit={create}> <h2>新建项目</h2> <div> - <label for="pn">项目名称</label> - <input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" /> + <Label for="pn">项目名称</Label> + <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" class="mt-1" /> </div> <div> - <label for="pd">简介(可选)</label> - <input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" /> + <Label for="pd">简介(可选)</Label> + <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" /> </div> - {#if createErr}<div class="banner error" role="alert">{createErr}</div>{/if} + {#if createErr}<Alert variant="destructive" class="banner error" role="alert">{createErr}</Alert>{/if} <div class="row"> - <button type="submit" class="btn primary" disabled={creating || !newName.trim()}> + <Button type="submit" disabled={creating || !newName.trim()}> {creating ? '创建中…' : '创建项目'} - </button> + </Button> </div> </form> {/if} @@ -81,32 +94,36 @@ {/if} {#if projectsStore.all.length > 0} - <div class="card" style="padding:0.4rem 0.6rem;"> - <table class="data"> - <thead> - <tr> - <th>名称</th> - <th>简介</th> - <th>版本代</th> - <th>最近更新</th> - </tr> - </thead> - <tbody> + <Card.Root size="sm"> + <Table.Root> + <Table.Header> + <Table.Row> + <Table.Head>名称</Table.Head> + <Table.Head>简介</Table.Head> + <Table.Head>版本代</Table.Head> + <Table.Head>最近更新</Table.Head> + </Table.Row> + </Table.Header> + <Table.Body> {#each projectsStore.all as p (p.id)} - <tr> - <td><a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a></td> - <td class="hint">{p.description ?? '—'}</td> - <td class="num">{p.draft_generation ?? '—'}</td> - <td>{fmtDateTime(p.updated_at)}</td> - </tr> + <Table.Row> + <Table.Cell> + <a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a> + </Table.Cell> + <Table.Cell class="hint">{p.description ?? '—'}</Table.Cell> + <Table.Cell class="num">{p.draft_generation ?? '—'}</Table.Cell> + <Table.Cell>{fmtDateTime(p.updated_at)}</Table.Cell> + </Table.Row> {/each} - </tbody> - </table> - </div> + </Table.Body> + </Table.Root> + </Card.Root> {/if} </div> </Page> <style> - .p-name { font-weight: 600; } + .p-name { font-weight: 600; color: var(--teal-strong); } + .p-name:hover { text-decoration: underline; } + form.card h2 { margin-bottom: 0.2rem; } </style> |
