diff options
Diffstat (limited to 'frontend/src/pages/ProjectsPage.svelte')
| -rw-r--r-- | frontend/src/pages/ProjectsPage.svelte | 149 |
1 files changed, 93 insertions, 56 deletions
diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte index 5cbfecf..65137d2 100644 --- a/frontend/src/pages/ProjectsPage.svelte +++ b/frontend/src/pages/ProjectsPage.svelte @@ -1,20 +1,19 @@ <script lang="ts"> import Page from '../components/Page.svelte'; import Status from '../components/Status.svelte'; - import ProjectMetricRow from '../components/ProjectMetricRow.svelte'; import Sparkline from '../components/Sparkline.svelte'; import { projectsStore } from '../lib/listsStore.svelte'; import { createProject, listDatasets, listRuns, getAIUsage } from '../lib/client'; import { sparkFirstLast } from '../lib/dashboardMetrics'; import { extractStatusMessage } from '../lib/state'; import { go } from '../lib/router'; - import { fmtDateTime } from '../lib/format'; + import { fmtDateTime, fmtPct } 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'; + import { Badge } from '$lib/components/ui/badge'; let busy = $state(false); let error = $state<string | null>(null); @@ -24,21 +23,20 @@ let newDesc = $state(''); let createErr = $state<string | null>(null); - // ---- round2 指标卡行输入 ---- - // projectsStore 持有项目列表;其余三项在本页拉取一次。 - // TODO: AI 用量若 /api/ai/usage 不给 totals.requests,占位 "-"(见 ProjectMetricRow)。 + // ---- 问候行 + 指标摘要(一行小字,不做 4 张大卡)---- let datasets: { id: string }[] | null = $state(null); - let runs: { id: string; status: string; result?: { equity?: { equity: number }[] } | null; project_id?: string }[] | null = $state(null); + let runs: { id: string; status: string; created_at?: string; result?: { equity?: { equity: number }[] } | null; project_id?: string }[] | null = $state(null); let usage: { totals?: { input_tokens?: number; output_tokens?: number; requests?: number } } | null = $state(null); let sparks = $state(new Map<string, { first: number; last: number }>()); + const hours = new Date().getHours(); + const greeting = hours < 12 ? '早上好' : hours < 18 ? '下午好' : '晚上好'; + async function load() { busy = true; error = null; try { await projectsStore.load(); - // 指标卡的三项远程输入并行拉取;任何一项失败不影响项目列表本身。 - // TODO: 用量字段若有专门计数端点(总数/时长),替换 getAIUsage。 const [ds, runList, usageRes] = await Promise.allSettled([ listDatasets(), listRuns(), @@ -55,9 +53,6 @@ } } - // 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(); }); @@ -80,33 +75,61 @@ creating = false; } } + + // 项目卡一句话状态 + const runStatusOf = $derived.by(() => { + const m = new Map<string, { running: number; done: number; lastReturn?: string }>(); + for (const r of runs ?? []) { + const e = m.get(r.project_id ?? '') ?? { running: 0, done: 0 }; + if (r.status === 'running' || r.status === 'queued') e.running++; + if (r.status === 'succeeded') { + e.done++; + const tr = (r.result as { metrics?: { total_return?: number } } | undefined)?.metrics?.total_return; + if (tr !== undefined) e.lastReturn = undefined; + const tr2 = tr; + if (tr2 !== undefined) (e as { lastReturn?: string }).lastReturn = fmtPct(tr2); + } + m.set(r.project_id ?? '', e); + } + return m; + }); </script> -<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录" wide> +<Page title={`${greeting},${projectsStore.all.length} 个进行中的策略研究`} subtitle="从下面的项目继续你的研究" wide> {#snippet actions()} <Button variant={showCreate ? 'outline' : 'default'} onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> {showCreate ? '取消新建' : '新建项目'} </Button> {/snippet} - <div class="flex flex-col gap-3"> - <ProjectMetricRow projects={projectsStore.all} {datasets} {runs} {usage} /> - <Status {busy} {error} busyText="加载项目列表…" empty={null} /> + <div class="flex flex-col gap-4 md:gap-6"> + {#if busy} + <Status {busy} busyText="加载项目列表…" skeletonRows={4} empty={null} /> + {:else if error} + <Status {error} empty={null} /> + {:else} + <p class="text-xs text-muted-foreground" data-testid="metrics-summary"> + {projectsStore.all.length} 个项目 · {(datasets ?? []).length} 个数据集 · + {[...runStatusOf.values()].reduce((a, b) => a + b.running, 0)} 个回测运行中 · + {(runs ?? []).length} 次回测 · AI 请求 {usage?.totals?.requests ?? '—'} + </p> + {/if} + {#if showCreate} <Card.Root class="p-4"> - <form class="flex flex-col gap-3" onsubmit={create}> - <h2 class="text-lg font-semibold">新建项目</h2> + <form class="flex flex-col gap-4" onsubmit={create}> + <h2 class="text-lg font-semibold m-0">新建项目</h2> <div> <Label for="pn">项目名称</Label> - <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" class="mt-1" /> + <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" /> </div> <div> <Label for="pd">简介(可选)</Label> - <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" /> + <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" /> </div> - {#if createErr}<Alert variant="destructive" role="alert">{createErr}</Alert>{/if} + {#if createErr}<Alert variant="destructive">{createErr}</Alert>{/if} <div> - <Button type="submit" disabled={creating || !newName.trim()}> + <Button class="w-fit" type="submit" disabled={creating || !newName.trim()}> {creating ? '创建中…' : '创建项目'} </Button> </div> @@ -114,44 +137,58 @@ </Card.Root> {/if} + {#if !busy && error && projectsStore.all.length === 0} + <Status busy={false} {error} empty={null}> + {#snippet errorCta()} + <Button onclick={load}>重新加载</Button> + {/snippet} + </Status> + {/if} + {#if !busy && !error && projectsStore.all.length === 0} - <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" /> + <Status + busy={false} + empty="还没有项目" + emptyHint="从新建项目开始你的第一个策略研究:命名 → 接入数据 → 编写策略 → 回测" + > + {#snippet emptyCta()} + <Button onclick={() => (showCreate = true)}>新建项目</Button> + {/snippet} + </Status> {/if} {#if projectsStore.all.length > 0} - <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.Head>最近更新</Table.Head> - </Table.Row> - </Table.Header> - <Table.Body> - {#each projectsStore.all as p (p.id)} - {@const sp = sparks.get(p.id)} - <Table.Row> - <Table.Cell> - <a href={`#/projects/${p.id}/data`} data-testid="project-link" class="font-semibold hover:underline">{p.name}</a> - </Table.Cell> - <Table.Cell> - {#if sp} - <a href={`#/projects/${p.id}/results`} aria-label="查看 {p.name} 最近回测曲线" data-testid="sparkline-cell"> - <Sparkline first={sp.first} last={sp.last} up={sp.last >= sp.first} /> - </a> - {/if} - </Table.Cell> - <Table.Cell class="text-muted-foreground text-sm">{p.description ?? '—'}</Table.Cell> - <Table.Cell class="tabular-nums">{p.draft_generation ?? '—'}</Table.Cell> - <Table.Cell>{fmtDateTime(p.updated_at)}</Table.Cell> - </Table.Row> - {/each} - </Table.Body> - </Table.Root> - </Card.Root> + <div class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6" role="list" aria-label="项目列表"> + {#each projectsStore.all as p (p.id)} + {@const sp = sparks.get(p.id)} + {@const st = runStatusOf.get(p.id)} + <Card.Root size="sm" role="listitem"> + <div class="flex justify-between items-start gap-4"> + <div class="min-w-0 flex-1"> + <a href={`#/projects/${p.id}/data`} data-testid="project-link" class="text-lg font-semibold hover:underline"> + {p.name} + </a> + <p class="text-xs text-muted-foreground mt-1 truncate">{p.description ?? '—'}</p> + </div> + <Badge variant={st?.running ? 'outline' : st?.done ? 'secondary' : 'outline'}> + {st?.running ? `运行中 ${st.running}` : st?.done ? `最近 ${st.lastReturn ?? '完成'}` : '未回测'} + </Badge> + </div> + <div class="flex items-center gap-4 mt-4"> + {#if sp} + <a href={`#/projects/${p.id}/results`} aria-label="查看 {p.name} 最近回测曲线" data-testid="sparkline-cell"> + <Sparkline first={sp.first} last={sp.last} up={sp.last >= sp.first} /> + </a> + <span class="text-xs text-muted-foreground">最近 {runStatusOf.get(p.id)?.lastReturn ?? '完成'}</span> + {:else} + <span class="text-xs text-muted-foreground">还没有回测记录</span> + {/if} + <span class="flex-1"></span> + <span class="text-xs text-muted-foreground">更新 {fmtDateTime(p.updated_at)}</span> + </div> + </Card.Root> + {/each} + </div> {/if} </div> </Page> |
