summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/pages/AccountPage.svelte10
-rw-r--r--frontend/src/pages/AdminPage.svelte16
-rw-r--r--frontend/src/pages/AuthPages.svelte24
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte56
-rw-r--r--frontend/src/pages/ProjectDataTab.svelte25
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte82
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte94
-rw-r--r--frontend/src/pages/ProjectsPage.svelte149
-rw-r--r--frontend/src/pages/RunsPage.svelte26
-rw-r--r--frontend/src/pages/UsagePage.svelte6
10 files changed, 297 insertions, 191 deletions
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte
index c85dc53..360b95c 100644
--- a/frontend/src/pages/AccountPage.svelte
+++ b/frontend/src/pages/AccountPage.svelte
@@ -104,10 +104,10 @@
</script>
<Page title="账户与安全" subtitle="资料、密码与个人会话管理">
- <div class="flex flex-col gap-3">
+ <div class="flex flex-col gap-4 md:gap-6">
<Card.Root size="sm">
<Card.Header><Card.Title>个人信息</Card.Title></Card.Header>
- <form onsubmit={saveProfile} class="flex flex-wrap items-center gap-3">
+ <form onsubmit={saveProfile} class="flex flex-wrap items-center gap-4">
<div class="flex-[1_1_200px]">
<Label for="acc-name">姓名</Label>
<Input id="acc-name" bind:value={nameInput} autocomplete="name" />
@@ -129,8 +129,8 @@
<Card.Root size="sm">
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
- <form onsubmit={changePassword} class="flex flex-col gap-3">
- <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
+ <form onsubmit={changePassword} class="flex flex-col gap-4">
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label for="pw-cur">当前密码</Label>
<Input id="pw-cur" type="password" bind:value={curPw} required autocomplete="current-password" />
@@ -176,7 +176,7 @@
<Table.Cell>{fmtDateTime(s.last_seen_at)}</Table.Cell>
<Table.Cell class="text-sm text-muted-foreground">{s.user_agent?.slice(0, 60) ?? '—'}</Table.Cell>
<Table.Cell>
- <div class="flex items-center gap-2 flex-wrap">
+ <div class="flex items-center gap-4 flex-wrap">
{#if s.current}<Badge variant="secondary">当前会话</Badge>{/if}
<Button variant="outline" size="sm" class="text-destructive" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}>
{s.current ? '注销当前' : '撤销'}
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
index 2a10266..8f6aded 100644
--- a/frontend/src/pages/AdminPage.svelte
+++ b/frontend/src/pages/AdminPage.svelte
@@ -186,11 +186,11 @@
</Tabs.Root>
{/snippet}
- <div class="flex flex-col gap-3">
+ <div class="flex flex-col gap-4">
<Status busy={false} error={adminOpsErr} empty={null} />
{#if tab === 'users'}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
<h2 class="text-lg font-semibold">用户</h2>
<Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
{#if !usersBusy && !usersErr && users.length > 0}
@@ -243,7 +243,7 @@
/>
</Table.Cell>
<Table.Cell>
- <div class="flex items-center gap-2 flex-wrap">
+ <div class="flex items-center gap-4 flex-wrap">
<Button variant={u.active ? 'destructive' : 'outline'} size="sm" onclick={() => void patchUser(u, { active: !u.active })}>
{u.active ? '停用' : '启用'}
</Button>
@@ -258,9 +258,9 @@
<p class="text-sm text-muted-foreground">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
</Card.Root>
{:else if tab === 'invites'}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
<h2 class="text-lg font-semibold">签发邀请</h2>
- <form onsubmit={makeInvite} class="flex flex-wrap items-end gap-3">
+ <form onsubmit={makeInvite} class="flex flex-wrap items-end gap-4">
<div class="flex-[1_1_220px]">
<Label for="inv-em">邮箱(可选备注)</Label>
<Input id="inv-em" bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
@@ -285,7 +285,7 @@
{/if}
</Card.Root>
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
<h2 class="text-lg font-semibold">历史邀请</h2>
<Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
{#if !inviteBusy && !inviteErr && invites.length === 0}
@@ -330,7 +330,7 @@
<p class="text-sm text-muted-foreground">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
</Card.Root>
{:else}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
<h2 class="text-lg font-semibold">安全审计(收尾尾部)</h2>
<Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
{#if !auditBusy && !auditErr && audit.length === 0}
@@ -367,7 +367,7 @@
{#if resetFor}
<Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
- <div class="flex flex-col gap-3">
+ <div class="flex flex-col gap-4">
<Alert variant="default">
<div>
<div>恢复链接(管理员线下告知对方,只出现一次):</div>
diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte
index da5d54c..f466cd0 100644
--- a/frontend/src/pages/AuthPages.svelte
+++ b/frontend/src/pages/AuthPages.svelte
@@ -95,9 +95,9 @@
<section class="min-h-screen flex items-center justify-center p-6 min-w-0">
<Card.Root class="w-[min(400px,100%)] p-8 max-[768px]:p-7" role="main">
<div class="mb-4">
- <span class="inline-block w-8 h-8 rounded-lg bg-primary mb-2" aria-hidden="true"></span>
- <h1 class="text-xl font-semibold mb-1">策研 <span class="text-muted-foreground/70 font-medium text-[0.8rem]">Strategy Lab</span></h1>
- <p class="text-sm text-muted-foreground max-w-[26em]">内部策略研究平台 · 仅限受邀成员</p>
+ <span class="inline-block w-8 h-8 bg-primary mb-2" aria-hidden="true"></span>
+ <h1 class="text-lg font-semibold mb-1">策研 <span class="text-xs text-muted-foreground font-medium">Strategy Lab</span></h1>
+ <p class="text-xs text-muted-foreground">内部策略研究平台 · 仅限受邀成员</p>
</div>
{#if mode === 'register'}
@@ -106,7 +106,7 @@
<div>
<Label for="inv">邀请令牌</Label>
<Input id="inv" bind:value={inviteToken} required autocomplete="off" />
- <p class="text-sm text-muted-foreground mt-1">单次有效,来自管理员邀请</p>
+ <p class="text-xs text-muted-foreground mt-1">单次有效,来自管理员邀请</p>
</div>
<div>
<Label for="nm">姓名</Label>
@@ -119,11 +119,11 @@
<div>
<Label for="pw">密码</Label>
<Input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} />
- <p class="text-sm text-muted-foreground mt-1">至少 8 位</p>
+ <p class="text-xs text-muted-foreground mt-1">至少 8 位</p>
</div>
{#if resultError}<Alert variant="destructive">{resultError}</Alert>{/if}
<Button type="submit" disabled={busy}>{busy ? '创建中…' : '开通账户'}</Button>
- <p class="text-sm text-muted-foreground">已有账户? <a class="hover:underline" href="#/login">登录</a></p>
+ <p class="text-xs text-muted-foreground">已有账户? <a class="hover:underline" href="#/login">登录</a></p>
</div>
</form>
{:else if mode === 'reset'}
@@ -136,7 +136,7 @@
<div>
<Label for="rt">重置令牌</Label>
<Input id="rt" bind:value={resetToken} autocomplete="off" />
- <p class="text-sm text-muted-foreground mt-1">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
+ <p class="text-xs text-muted-foreground mt-1">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
</div>
<div>
<Label for="np">新密码</Label>
@@ -148,7 +148,7 @@
</div>
{#if resultError}<Alert variant="destructive">{resultError}</Alert>{/if}
<Button type="submit" disabled={busy}>{busy ? '重置中…' : '重置密码'}</Button>
- <p class="text-sm text-muted-foreground">想起密码了? <a class="hover:underline" href="#/login">直接登录</a></p>
+ <p class="text-xs text-muted-foreground">想起密码了? <a class="hover:underline" href="#/login">直接登录</a></p>
</div>
</form>
{/if}
@@ -165,11 +165,9 @@
</div>
{#if resultError}<Alert variant="destructive">{resultError}</Alert>{/if}
<Button type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</Button>
- <p class="text-sm text-muted-foreground">
- 开通新账户需要管理员邀请: <a class="hover:underline" href="#/register">用邀请令牌注册</a>
- {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if}
- </p>
- <p class="text-sm text-muted-foreground">忘记密码?请联系管理员获取 <a class="hover:underline" href="#/reset">恢复链接</a></p>
+ {#if route.query.get('invite')}
+ <p class="text-xs text-muted-foreground">邀请令牌已自动填入,可直接用邀请注册。</p>
+ {/if}
</div>
</form>
{/if}
diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte
index b640189..7d5b172 100644
--- a/frontend/src/pages/ProjectBacktestTab.svelte
+++ b/frontend/src/pages/ProjectBacktestTab.svelte
@@ -11,11 +11,13 @@
runStatusBadge
} from '../lib/format';
import { summarizeCoverage } from '../lib/coverage';
- import type { Run, Dataset } from '../lib/types';
+ import { go } from '../lib/router';
+import type { Run, Dataset } from '../lib/types';
import { Button } from '$lib/components/ui/button';
import { Alert } from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
+import * as Textarea from '$lib/components/ui/textarea';
import * as Table from '$lib/components/ui/table';
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input';
@@ -224,15 +226,26 @@
const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled');
</script>
-<div class="flex flex-col gap-3">
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <h2 class="text-lg font-semibold">发起回测</h2>
- <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} />
+<div class="flex flex-col gap-4 md:gap-6">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <div class="flex justify-between items-center gap-4">
+ <h2 class="text-lg font-semibold m-0">发起回测</h2>
+ <Button class="w-fit" onclick={startRun} disabled={!datasetId}>开始回测</Button>
+ </div>
+ {#if busyDatasets}
+ <Status busy skeletonRows={3} busyText="检查就绪的数据集…" empty={null} />
+ {:else if cfgErr}
+ <Status busy={false} error={cfgErr} empty={null}/>
+ {/if}
{#if !busyDatasets && datasetsReady.length === 0 && !cfgErr}
- <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" />
+ <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测">
+ {#snippet emptyCta()}
+ <Button onclick={() => go(`/projects/${projectId}/data`)}>去接入数据</Button>
+ {/snippet}
+ </Status>
{/if}
{#if datasetsReady.length > 0}
- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<Label for="bt-dataset">数据集</Label>
<Select.Root
@@ -261,7 +274,7 @@
<Label for="bt-capital">起始资金</Label>
<Input id="bt-capital" class="tabular-nums" type="number" bind:value={capital} min="1" step="1000" />
</div>
- <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label for="bt-commission">佣金率</Label>
<Input id="bt-commission" class="tabular-nums" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" />
@@ -277,7 +290,7 @@
</div>
<div class="sm:col-span-2 lg:col-span-3">
<Label for="bt-params">策略参数(JSON,可选)</Label>
- <textarea id="bt-params" class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" rows="1" bind:value={paramsText} placeholder="输入 JSON 参数,例如 fast/slow 周期"></textarea>
+ <Textarea.Root id="bt-params" class="w-full" rows={1} bind:value={paramsText} placeholder="输入 JSON 参数,例如 fast/slow 周期" />
</div>
</div>
{#if needAck}
@@ -288,28 +301,31 @@
<ul class="list-disc pl-5 mt-1">
{#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each}
</ul>
- <label class="inline-flex items-center gap-2 mt-1">
+ <label class="inline-flex items-center gap-4 mt-1">
<input type="checkbox" bind:checked={acknowledged} />
我已知晓并用当前数据继续
</label>
</div>
</Alert>
{/if}
- <div class="flex items-center gap-2 flex-wrap">
- <Button onclick={startRun} disabled={!datasetId}>开始回测</Button>
+ <div class="flex items-center gap-4 flex-wrap">
{#if selectedDataset}
- <span class="text-sm text-muted-foreground">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
+ <span class="text-xs text-muted-foreground">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
{/if}
</div>
- <Status busy={false} error={cfgErr} empty={actionMsg} />
+ {#if cfgErr}
+ <Status busy={false} error={cfgErr} empty={null} />
+ {:else if actionMsg}
+ <Status busy={false} empty={actionMsg} />
+ {/if}
{/if}
</Card.Root>
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <div class="flex flex-wrap justify-between items-center gap-2">
- <h2 class="text-lg font-semibold">最近一次回测</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <div class="flex flex-wrap justify-between items-center gap-4">
+ <h2 class="text-lg font-semibold m-0">最近一次回测</h2>
{#if latest}
- <span class="flex items-center gap-2">
+ <span class="flex items-center gap-4">
<Badge variant="outline">Run 分支 {latest.version_id?.slice(0,8) ?? '—'}</Badge>
<Badge variant={runStatusBadge[latest.status] === 'ok' ? 'secondary' : runStatusBadge[latest.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[latest.status]}</Badge>
</span>
@@ -349,7 +365,7 @@
{#if latest.status === 'succeeded' && latest.result}
<Button variant="outline" href={`#/projects/${projectId}/results?run=${latest.id}`} class="w-fit">查看完整结果与图表</Button>
{/if}
- <div class="flex items-center gap-2 flex-wrap">
+ <div class="flex items-center gap-4 flex-wrap">
{#if canCancel}
<Button variant="destructive" onclick={onCancel} disabled={cancelBusy}>
{cancelBusy ? '取消中…' : '取消回测'}
@@ -366,7 +382,7 @@
{/if}
</Card.Root>
- <div class="flex flex-col gap-3">
+ <div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold">回测历史</h2>
<Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} />
{#if history && history.length > 0}
diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte
index 7b9f8ea..b7555ad 100644
--- a/frontend/src/pages/ProjectDataTab.svelte
+++ b/frontend/src/pages/ProjectDataTab.svelte
@@ -59,26 +59,31 @@
}
</script>
-<div class="flex flex-col gap-3">
+<div class="flex flex-col gap-4 md:gap-6">
{#if wizardOpen}
<DatasetWizard onCreated={() => void load()} />
- <Button variant="ghost" size="sm" onclick={() => (wizardOpen = false)}>收起新建面板</Button>
+ <Button variant="ghost" class="w-fit" onclick={() => (wizardOpen = false)}>收起新建面板</Button>
{:else}
- <Button size="sm" class="w-fit" onclick={() => (wizardOpen = true)}>新建数据集</Button>
+ <Button class="w-fit" onclick={() => (wizardOpen = true)}>接入数据</Button>
{/if}
- <h2>我的数据集</h2>
- <Status busy={busy} error={error} busyText="加载数据集列表…" empty={null} />
+ <h2 class="text-lg font-semibold m-0">我的数据集</h2>
- {#if !busy && !error && items.length === 0}
+ {#if busy}
+ <Status busy={false} busyText="加载数据集列表…" skeletonRows={3} empty={null} />
+ {:else if error}
+ <Status busy={false} {error} empty={null}>
+ {#snippet errorCta()}
+ <Button onclick={load}>重新加载</Button>
+ {/snippet}
+ </Status>
+ {:else if items.length === 0}
<Status
busy={false}
empty="还没有数据集"
- emptyHint="先创建数据集:选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览"
+ emptyHint="选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览"
/>
- {/if}
-
- {#if !busy && !error && items.length > 0}
+ {:else}
{#each items as ds (ds.id)}
<DatasetCard dsId={ds.id} onRecreated={() => void load()} />
{/each}
diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte
index 181d6a4..860fab2 100644
--- a/frontend/src/pages/ProjectResultsTab.svelte
+++ b/frontend/src/pages/ProjectResultsTab.svelte
@@ -21,7 +21,8 @@
import * as Table from '$lib/components/ui/table';
import * as Select from '$lib/components/ui/select';
import * as Tabs from '$lib/components/ui/tabs';
- import type { Run, BacktestResult } from '../lib/types';
+ import { go } from '../lib/router';
+import type { Run, BacktestResult } from '../lib/types';
interface Props {
projectId: string;
@@ -116,33 +117,49 @@
// 投入口径 reporting(任务 A):投入/持仓估值/回报,口径注明不含闲置现金
const cap = $derived(result ? capitalReporting(result) : null);
- const tile = 'gap-1 min-w-0';
+ const tile = 'p-4 flex flex-col gap-1 min-w-0';
const tileLabel = 'text-xs text-muted-foreground';
const tileValue = 'text-2xl font-bold tabular-nums text-foreground leading-tight break-all';
- const metricValueProps = 'data-testid="metric-value"';
+
+ let tradePage = $state(0);
+ const TRADE_PER_PAGE = 20;
+ const tradePageCount = $derived(result ? Math.max(1, Math.ceil(result.trades.length / TRADE_PER_PAGE)) : 1);
+ const tradePageRows = $derived(result ? result.trades.slice(tradePage * TRADE_PER_PAGE, (tradePage + 1) * TRADE_PER_PAGE) : []);
</script>
-<div class="flex flex-col gap-3">
- <div class="flex flex-wrap justify-between items-center gap-2">
+<div class="flex flex-col gap-4 md:gap-6">
+ <div class="flex flex-wrap justify-between items-center gap-4">
<h2 class="text-lg font-semibold">回测结果</h2>
{#if run}
- <span class="flex items-center gap-2">
- <span class="text-sm text-muted-foreground">Run {run.id.slice(0, 8)}…</span>
+ <span class="flex items-center gap-4">
+ <span class="text-xs text-muted-foreground">Run {run.id.slice(0, 8)}…</span>
<Badge variant={runStatusBadge[run.status] === 'ok' ? 'secondary' : runStatusBadge[run.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[run.status]}</Badge>
</span>
{/if}
</div>
- <Status busy={busy} error={error} busyText="加载结果…" empty={null} />
+ {#if busy}
+ <Status busy skeletonRows={4} busyText="加载结果…" empty={null} />
+ {:else if error}
+ <Status busy={false} {error} empty={null}>
+ {#snippet errorCta()}
+ <Button onclick={() => void load()}>重新加载</Button>
+ {/snippet}
+ </Status>
+ {/if}
{#if !busy && !error && !run}
- <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" />
+ <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测">
+ {#snippet emptyCta()}
+ <Button onclick={() => go(`/projects/${projectId}/backtest`)}>去发起回测</Button>
+ {/snippet}
+ </Status>
{/if}
{#if run && !busy && !error}
{#if history.length > 1}
- <div class="flex items-center gap-2">
- <span class="text-sm text-muted-foreground">查看其他回测:</span>
+ <div class="flex items-center gap-4">
+ <span class="text-xs text-muted-foreground">查看其他回测:</span>
<Select.Root
type="single"
value={run?.id ?? ''}
@@ -180,10 +197,10 @@
</Alert>
{/if}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
{#if result.metrics}
{@const mrow = result.metrics}
- <div class="grid grid-cols-2 md:grid-cols-3 gap-2.5">
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card.Root size="sm" class={tile}><div class={tileLabel}>总收益率</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.total_return)}</div></Card.Root>
<Card.Root size="sm" class={tile}><div class={tileLabel}>年化收益率</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.annual_return)}</div></Card.Root>
<Card.Root size="sm" class={tile}><div class={tileLabel}>最大回撤</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.max_drawdown)}</div></Card.Root>
@@ -193,14 +210,14 @@
</div>
{/if}
{#if cap}
- <div class="grid grid-cols-2 md:grid-cols-3 gap-2.5" data-testid="capital-reporting">
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4" data-testid="capital-reporting">
<Card.Root size="sm" class={tile}><div class={tileLabel}>累计投入</div><div class={tileValue} data-testid="metric-value">{formatMoney(cap.invested)}</div></Card.Root>
<Card.Root size="sm" class={tile}><div class={tileLabel}>最终持仓估值</div><div class={tileValue} data-testid="metric-value">{formatMoney(cap.holdingValue)}</div></Card.Root>
<Card.Root size="sm" class={tile}><div class={tileLabel}>投入回报率</div><div class={tileValue} data-testid="metric-value">{cap.returnPct ?? '—'}</div></Card.Root>
</div>
- <p class="text-sm text-muted-foreground">投入口径不含初始账户闲置现金:累计投入 = 首日现金 − 末日现金;持仓估值 = 末日持仓 × 最新收盘价。</p>
+ <p class="text-xs text-muted-foreground">投入口径不含初始账户闲置现金:累计投入 = 首日现金 − 末日现金;持仓估值 = 末日持仓 × 最新收盘价。</p>
{/if}
- <p class="text-sm text-muted-foreground">
+ <p class="text-xs text-muted-foreground">
平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。
引擎 {result.engine?.name} {result.engine?.version}
{#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if}
@@ -208,7 +225,7 @@
</p>
</Card.Root>
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
<Tabs.Root value={chartMode} onValueChange={(v) => (chartMode = v as 'equity' | 'drawdown')} class="w-fit">
<Tabs.List variant="line">
<Tabs.Trigger value="equity">权益曲线({result.equity.length} 点)</Tabs.Trigger>
@@ -226,7 +243,7 @@
<h2 class="text-lg font-semibold m-0">订单与持仓记录</h2>
<div class="grid md:grid-cols-[1.4fr_1fr] gap-4 items-start">
<div>
- <h3 class="text-base font-semibold">成交记录 ({result.trades.length})</h3>
+ <h3 class="text-sm font-medium">成交记录 ({result.trades.length})</h3>
{#if result.trades.length > 0}
<div class="overflow-x-auto">
<Table.Root>
@@ -242,8 +259,8 @@
</Table.Row>
</Table.Header>
<Table.Body>
- {#each result.trades.slice(0, 80) as t, i (i)}
- <Table.Row>
+ {#each tradePageRows as t, i (tradePage * TRADE_PER_PAGE + i)}
+ <Table.Row data-testid="trade-row">
<Table.Cell>{t.date}</Table.Cell>
<Table.Cell class="tabular-nums">{t.symbol}</Table.Cell>
<Table.Cell>{t.side === "buy" ? "买入" : "卖出"}</Table.Cell>
@@ -256,28 +273,35 @@
</Table.Body>
</Table.Root>
</div>
- {#if result.trades.length > 80}
- <p class="text-sm text-muted-foreground">仅显示前 80 条,共 {result.trades.length} 条</p>
+ {#if result.trades.length > TRADE_PER_PAGE}
+ <div class="flex items-center gap-4 mt-2">
+ <span class="text-xs text-muted-foreground">
+ 第 {tradePage + 1} / {tradePageCount} 页 · 共 {result.trades.length} 条
+ </span>
+ <span class="flex-1"></span>
+ <Button variant="outline" size="sm" disabled={tradePage === 0} onclick={() => tradePage--}>上一页</Button>
+ <Button variant="outline" size="sm" disabled={tradePage >= tradePageCount - 1} onclick={() => tradePage++}>下一页</Button>
+ </div>
{/if}
{:else}
- <p class="text-sm text-muted-foreground">本区间未发生成交</p>
+ <p class="text-xs text-muted-foreground">本区间未发生成交</p>
{/if}
</div>
<div>
- <h3 class="text-base font-semibold">策略日志</h3>
- <Button variant="outline" size="sm" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}>
+ <h3 class="text-sm font-medium">策略日志</h3>
+ <Button variant="outline" size="sm" class="w-fit" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}>
{logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`}
</Button> {#if logsOpen}
- <pre class="font-mono text-sm p-2 bg-muted rounded-md overflow-x-auto whitespace-pre-wrap break-all mt-2">{result.logs.join("\n")}</pre>
+ <pre class="font-mono text-sm p-4 bg-card overflow-x-auto whitespace-pre-wrap break-all mt-2">{result.logs.join("\n")}</pre>
{/if}
</div>
</div>
<div>
- <h3 class="text-base font-semibold">
+ <h3 class="text-sm font-medium">
数据与引擎
- <span class="text-sm text-muted-foreground font-normal">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span>
+ <span class="text-xs text-muted-foreground font-normal">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span>
</h3>
- <p class="text-sm text-muted-foreground">
+ <p class="text-xs text-muted-foreground">
结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。
</p>
</div>
diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte
index e4269e3..53c8bea 100644
--- a/frontend/src/pages/ProjectStrategyTab.svelte
+++ b/frontend/src/pages/ProjectStrategyTab.svelte
@@ -9,6 +9,7 @@
import { Alert } from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
+import * as Textarea from '$lib/components/ui/textarea';
import * as Table from '$lib/components/ui/table';
import { projectsStore } from '../lib/listsStore.svelte';
import {
@@ -262,28 +263,34 @@
}
</script>
-<div class="flex flex-col gap-3">
- <Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} />
-
- {#if !initBusy && !initErr}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <div class="flex flex-wrap justify-between items-center gap-2">
- <h2 class="text-lg font-semibold">策略代码(Backtrader Python)</h2>
- <span class="text-sm text-muted-foreground" role="status">
+<div class="flex flex-col gap-4 md:gap-6">
+ {#if initBusy}
+ <Status busy skeletonRows={5} busyText="加载策略草稿…" empty={null} />
+ {:else if initErr}
+ <Status busy={false} error={initErr} empty={null}>
+ {#snippet errorCta()}
+ <Button onclick={() => void init()}>重新加载</Button>
+ {/snippet}
+ </Status>
+ {:else}
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <div class="flex flex-wrap justify-between items-center gap-4">
+ <h2 class="text-lg font-semibold m-0">策略代码(Backtrader Python)</h2>
+ <span class="text-xs text-muted-foreground" role="status" data-testid="save-status">
{saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`}
{#if saveStatus === 'dirty'}<Badge variant="outline">有未保存修改</Badge>{/if}
{#if saveStatus === 'saving'}<Badge variant="secondary">保存中…</Badge>{/if}
{#if saveStatus === 'conflict'}<Badge variant="destructive">冲突</Badge>{/if}
</span>
</div>
- <p class="text-sm text-muted-foreground">
- 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照
+ <p class="text-xs text-muted-foreground m-0">
+ 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与 AI 使用当前草稿快照
</p>
<CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} />
- <div class="flex flex-wrap justify-between items-center gap-2">
+ <div class="flex flex-wrap justify-between items-center gap-4">
<Input
bind:value={snapshotMessage}
- placeholder="快照说明,例如:加入RSI过滤器(可选)"
+ placeholder="快照说明(可选),例如:加入RSI过滤器"
class="flex-[2_1_240px] min-w-[200px]"
onkeydown={(ev) => {
if (ev.key === 'Enter') void snapshot();
@@ -300,7 +307,11 @@
草稿与上一快照对比
</Button>
</div>
- <Status busy={false} error={snapshotErr} empty={snapshotMsg} />
+ {#if snapshotErr}
+ <Status busy={false} error={snapshotErr} empty={null} />
+ {:else if snapshotMsg}
+ <Status busy={false} empty={snapshotMsg} />
+ {/if}
{#if conflictServer}
<Alert variant="default">
@@ -310,20 +321,18 @@
</span>
</Alert>
<DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" />
- <div class="flex items-center gap-2 flex-wrap">
+ <div class="flex items-center gap-4 flex-wrap">
<Button onclick={keepMine}>保留本机修改(覆盖服务器)</Button>
<Button variant="outline" onclick={loadServer}>采用服务器版本</Button>
</div>
{/if}
</Card.Root>
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <h2 class="text-lg font-semibold">AI 助手</h2>
- <Label for="ai-instruction">希望 AI 帮你做什么?<span class="text-sm text-muted-foreground">例如:把均线周期改为两个可调参数并解释影响</span></Label>
- <textarea id="ai-instruction" class="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea>
- <div class="flex items-center gap-2 flex-wrap">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <div class="flex justify-between items-center gap-4">
+ <h2 class="text-lg font-semibold m-0">AI 助手</h2>
<Button class="w-fit" disabled={aiBusy || !instruction.trim()} onclick={askAI}>
- {aiBusy ? 'AI 处理中…' : '获取建议'}
+ {aiBusy ? 'AI 处理中…' : 'AI 改进'}
</Button>
</div>
{#if aiErr}
@@ -333,20 +342,25 @@
</Alert>
{/if}
+ <div>
+ <Label for="ai-instruction">希望 AI 帮你做什么?<span class="text-xs text-muted-foreground">例如:把均线周期改为两个可调参数并解释影响</span></Label>
+ <Textarea.Root id="ai-instruction" class="w-full" rows={3} bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览" />
+ </div>
+
{#if aiResult}
- <div class="flex flex-col gap-2">
- <div class="flex flex-wrap justify-between items-center gap-2">
- <h3 class="text-base font-semibold">建议解释 <span class="text-sm text-muted-foreground">{aiResult.model}</span></h3>
- <span class="text-sm text-muted-foreground">
+ <div class="flex flex-col gap-4">
+ <div class="flex flex-wrap justify-between items-center gap-4">
+ <h3 class="text-sm font-medium m-0">建议解释 <span class="text-xs text-muted-foreground">{aiResult.model}</span></h3>
+ <span class="text-xs text-muted-foreground">
tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出
</span>
</div>
<p class="m-0">{aiResult.explanation}</p>
- <h3 class="text-base font-semibold">完整建议源码</h3>
+ <h3 class="text-sm font-medium m-0">完整建议源码</h3>
<CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} />
- <h3 class="text-base font-semibold">与当前草稿的差异</h3>
+ <h3 class="text-sm font-medium m-0">与当前草稿的差异</h3>
<DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" />
- <div class="flex items-center gap-2 flex-wrap">
+ <div class="flex items-center gap-4 flex-wrap">
<Button
class="w-fit"
disabled={aiAcceptBusy}
@@ -357,7 +371,7 @@
</Button>
<Button variant="ghost" class="w-fit" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</Button>
{#if aiResult.base_generation !== baseGeneration}
- <span class="text-destructive">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
+ <span class="text-destructive text-xs">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
{/if}
{#if aiAcceptErr}
<Alert variant="destructive">{aiAcceptErr}</Alert>
@@ -370,14 +384,20 @@
{/if}
</Card.Root>
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <h2 class="text-lg font-semibold">版本历史</h2>
- <Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} />
- {#if !versionsBusy && versions.length === 0}
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <h2 class="text-lg font-semibold m-0">版本历史</h2>
+ {#if versionsBusy}
+ <Status busy skeletonRows={3} busyText="载入版本…" empty={null} />
+ {:else if versionsErr}
+ <Status busy={false} error={versionsErr} empty={null}>
+ {#snippet errorCta()}
+ <Button onclick={() => void refreshVersions()}>重新载入</Button>
+ {/snippet}
+ </Status>
+ {:else if versions.length === 0}
<Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" />
- {/if}
- {#if !versionsBusy && versions.length > 0}
- <div class="overflow-x-auto rounded-lg border border-border">
+ {:else}
+ <div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -394,7 +414,7 @@
<Table.Cell>{fmtDateTime(v.created_at)}</Table.Cell>
<Table.Cell>
{v.message?.trim() || '(无说明)'}
- <span class="text-sm text-muted-foreground ml-1">版本代 {v.hash?.slice(0, 8) || '—'}</span>
+ <span class="text-xs text-muted-foreground ml-1">版本代 {v.hash?.slice(0, 8) || '—'}</span>
</Table.Cell>
<Table.Cell class="tabular-nums">{v.hash?.slice(0, 12)}</Table.Cell>
<Table.Cell><Badge variant="outline">{versionSourceLabel[v.source]}</Badge></Table.Cell>
@@ -413,7 +433,7 @@
</Table.Root>
</div>
{/if}
- <p class="text-sm text-muted-foreground">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p>
+ <p class="text-xs text-muted-foreground m-0">版本为不可变记录,恢复均创建新草稿,从不改写历史。</p>
</Card.Root>
{#if comparing}
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>
diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte
index 77626b5..ce66a25 100644
--- a/frontend/src/pages/RunsPage.svelte
+++ b/frontend/src/pages/RunsPage.svelte
@@ -103,10 +103,16 @@
</script>
<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide>
- <div class="flex flex-col gap-3">
- <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} />
-
- {#if !busy && !error && runs.length === 0}
+ <div class="flex flex-col gap-4 md:gap-6">
+ {#if busy}
+ <Status busy skeletonRows={4} busyText="正在加载实验…" empty={null} />
+ {:else if error}
+ <Status busy={false} {error} empty={null}>
+ {#snippet errorCta()}
+ <Button onclick={load}>重新加载</Button>
+ {/snippet}
+ </Status>
+ {:else if runs.length === 0}
<Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" />
{/if}
@@ -152,12 +158,12 @@
{/each}
</Table.Body>
</Table.Root>
- <p class="text-sm text-muted-foreground p-2">最多同时选中 4 个已完成的回测用于比较。</p>
+ <p class="text-xs text-muted-foreground p-4">最多同时选中 4 个已完成的回测用于比较。</p>
</Card.Root>
{#if selected.length > 0}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3 overflow-x-auto">
- <h2 class="text-lg font-semibold">条件差异</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4 overflow-x-auto">
+ <h2 class="text-lg font-semibold m-0">条件差异</h2>
<Table.Root>
<Table.Header>
<Table.Row>
@@ -179,10 +185,10 @@
</Card.Root>
{#if comparable.length > 1}
- <Card.Root size="sm" class="p-4 flex flex-col gap-3">
- <h2 class="text-lg font-semibold">收益曲线对齐比较(首日归一)</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-4">
+ <h2 class="text-lg font-semibold m-0">收益曲线对齐比较(首日归一)</h2>
<Chart data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} />
- <p class="text-sm text-muted-foreground">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
+ <p class="text-xs text-muted-foreground">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
</Card.Root>
{:else}
<Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" />
diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte
index 018b4a0..00b5917 100644
--- a/frontend/src/pages/UsagePage.svelte
+++ b/frontend/src/pages/UsagePage.svelte
@@ -32,10 +32,10 @@
</script>
<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费">
- <div class="flex flex-col gap-3">
+ <div class="flex flex-col gap-4 md:gap-6">
<Status busy={busy} error={error} busyText="正在载入用量…" empty={null} />
{#if usage}
- <Card.Root size="sm" class="p-0 [&>[data-slot=card-content]]:px-4 [&>[data-slot=card-content]]:py-3">
+ <Card.Root size="sm" class="p-4">
<Card.Content>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<div>
@@ -60,7 +60,7 @@
</Card.Root>
{#if usage.items.length > 0}
- <Card.Root size="sm" class="px-3 py-2">
+ <Card.Root size="sm" class="p-4">
<Table.Root>
<Table.Header>
<Table.Row>