summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectsPage.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/pages/ProjectsPage.svelte')
-rw-r--r--frontend/src/pages/ProjectsPage.svelte149
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>