summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/DatasetCard.svelte14
-rw-r--r--frontend/src/components/DatasetWizard.svelte31
-rw-r--r--frontend/src/components/Modal.svelte3
-rw-r--r--frontend/src/components/ProjectMetricRow.svelte83
-rw-r--r--frontend/src/components/Status.svelte14
-rw-r--r--frontend/src/pages/AccountPage.svelte112
-rw-r--r--frontend/src/pages/AdminPage.svelte237
-rw-r--r--frontend/src/pages/AuthPages.svelte65
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte113
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte131
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte36
-rw-r--r--frontend/src/pages/ProjectsPage.svelte6
-rw-r--r--frontend/src/pages/RunsPage.svelte104
-rw-r--r--frontend/src/pages/UsagePage.svelte92
14 files changed, 525 insertions, 516 deletions
diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte
index 2d49e65..69083fb 100644
--- a/frontend/src/components/DatasetCard.svelte
+++ b/frontend/src/components/DatasetCard.svelte
@@ -153,7 +153,7 @@
</Table.Root>
</div>
{#if ds.status === 'failed'}
- <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></div>
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></Alert>
<div class="retry-row">
<Button variant="outline" size="sm"
disabled={recreateBusy || recreatedId !== null}
@@ -181,28 +181,28 @@
{#if preview}
{@const cov = summarizeCoverage(preview.coverage ?? [])}
{#if cov.warnings.length > 0}
- <div class="banner warn"><span aria-hidden="true">⚠</span><div>
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]"><span aria-hidden="true">⚠</span><div>
<div class="cov-caption">覆盖差异(共有区间 {cov.common.start ?? '—'} ~ {cov.common.end ?? '—'}):</div>
<ul class="cov-list">
{#each cov.warnings as w, i (i)}<li>{w}</li>{/each}
</ul>
- </div></div>
+ </div></Alert>
{/if}
{#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)}
- <div class="banner warn" role="alert"><span aria-hidden="true">⚠</span><div>
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]" role="alert"><span aria-hidden="true">⚠</span><div>
<div class="cov-caption">来源与口径说明:</div>
<ul class="cov-list">
{#each preview.coverage.filter((c) => (c.warnings ?? []).length > 0) as c (c.instrument)}
<li>{c.instrument}:{(c.warnings ?? []).join(';')}</li>
{/each}
</ul>
- </div></div>
+ </div></Alert>
{/if}
{#if cov.common.start && cov.warnings.length === 0}
- <div class="banner info">
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
<span aria-hidden="true">✓</span>
<span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span>
- </div>
+ </Alert>
{/if}
<div class="scrollx">
<Table.Root>
diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte
index 1e61d92..d9e923a 100644
--- a/frontend/src/components/DatasetWizard.svelte
+++ b/frontend/src/components/DatasetWizard.svelte
@@ -1,5 +1,8 @@
<script lang="ts">
import Status from './Status.svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Alert } from '$lib/components/ui/alert';
import { getCapabilities, searchInstruments, createDataset } from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format';
@@ -196,13 +199,13 @@
}}
aria-label="标的搜索"
/>
- <button class="btn" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}>
+ <Button variant="outline" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}>
{searchBusy ? '搜索中…' : '搜索'}
- </button>
+ </Button>
</div>
<Status busy={searchBusy} busyText="搜索中…" error={searchErr} empty={null} />
{#if searchErr && !searchBusy}
- <button class="btn small" onclick={() => doSearch()}>重试</button>
+ <Button variant="outline" size="sm" onclick={() => doSearch()}>重试</Button>
{/if}
{#if searchItems.length > 0}
<table class="data">
@@ -217,9 +220,9 @@
<td>{inst.name ?? '—'}</td>
<td class="hint">{itemSourceLabel(inst.source, searchSource)}</td>
<td>
- <button class="btn small" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}>
+ <Button variant="outline" size="sm" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}>
{inst.asset_type === 'index' ? '加入(基准研究)' : '加入'}
- </button>
+ </Button>
</td>
</tr>
{/each}
@@ -258,7 +261,7 @@
</label>
</div>
<div class="row">
- <button class="btn" onclick={addManual}>加入所选</button>
+ <Button onclick={addManual}>加入所选</Button>
</div>
</div>
</details>
@@ -274,8 +277,8 @@
<li>
<span class="num">{s.market}.{s.symbol}</span>
{#if s.name}<span class="hint">{s.name}</span>{/if}
- <span class="badge grey">{assetTypeLabel[s.asset_type] ?? s.asset_type}</span>
- <button class="btn small ghost" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</button>
+ <Badge variant="outline">{assetTypeLabel[s.asset_type] ?? s.asset_type}</Badge>
+ <Button variant="ghost" size="icon-sm" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</Button>
</li>
{/each}
</ul>
@@ -329,24 +332,24 @@
</details>
{#if formErr.length > 0}
- <div class="banner error" role="alert">
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]" role="alert">
<span aria-hidden="true">⚠</span>
<ul class="err-list">
{#each formErr as msg, i (i)}<li>{msg}</li>{/each}
</ul>
- </div>
+ </Alert>
{/if}
{#if submitErr}
- <div class="banner error" role="alert">
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]" role="alert">
<span aria-hidden="true">⚠</span>
<span>数据请求提交失败:{submitErr}</span>
- </div>
+ </Alert>
{/if}
<p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p>
<div>
- <button class="btn primary" disabled={submitting || selected.length === 0} onclick={submit}>
+ <Button disabled={submitting || selected.length === 0} onclick={submit}>
{submitting ? '提交中…' : '提交数据请求'}
- </button>
+ </Button>
</div>
{/if}
diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte
index c9f334c..209d1ab 100644
--- a/frontend/src/components/Modal.svelte
+++ b/frontend/src/components/Modal.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
+ import { Button } from '$lib/components/ui/button';
interface Props {
title: string;
@@ -34,7 +35,7 @@
<div class="dialog card" role="dialog" aria-modal="true" aria-label={title}>
<div class="spread">
<h2>{title}</h2>
- <button class="btn ghost" onclick={onClose} aria-label="关闭">✕</button>
+ <Button variant="ghost" size="icon-sm" onclick={onClose} aria-label="关闭">✕</Button>
</div>
<div class="content">
{@render children?.()}
diff --git a/frontend/src/components/ProjectMetricRow.svelte b/frontend/src/components/ProjectMetricRow.svelte
index 7980622..6dd3f0e 100644
--- a/frontend/src/components/ProjectMetricRow.svelte
+++ b/frontend/src/components/ProjectMetricRow.svelte
@@ -1,7 +1,8 @@
<script lang="ts">
- // 首页指标卡行(leader round2 spec §1)
+ // 首页指标卡行(round2 spec §1;round3 迁移 shadcn-svelte Card)
// 数据通过 props 注入(ProjectsPage 拉取并共享聚合输入,见 dashboardMetrics.ts)。
import { aggregateDashboard } from '../lib/dashboardMetrics';
+ import * as Card from '$lib/components/ui/card';
interface Props {
/** 注入的聚合输入;null/undefined 视为"取不到"(占位 -) */
@@ -15,33 +16,34 @@
const metric = $derived(aggregateDashboard({ projects, datasets, runs, usage }));
</script>
-<div class="metric-row" role="list" aria-label="工作台概览">
- <div class="metric-card" data-testid="metric-card" role="listitem">
- <span class="mlabel">我的项目</span>
- <span class="mvalue">{metric.projectCount}</span>
+ <div class="metric-row" role="list" aria-label="工作台概览">
+ <Card.Root class="metric-r3" size="sm" data-testid="metric-card" role="listitem">
+ <span class="mlabel">我的项目</span>
+ <span class="mvalue">{metric.projectCount}</span>
+ </Card.Root>
+ <Card.Root class="metric-r3" size="sm" data-testid="metric-card" role="listitem">
+ <span class="mlabel">数据集</span>
+ <span class="mvalue">{metric.datasetCount}</span>
+ </Card.Root>
+ <Card.Root class="metric-r3" size="sm" data-testid="metric-card" role="listitem">
+ <span class="mlabel">回测</span>
+ <span class="mvalue">
+ <span class="run-split"><i>运行中</i> {metric.runningRuns}</span>
+ <span class="run-split"><i>已完成</i> {metric.doneRuns}</span>
+ </span>
+ </Card.Root>
+ <Card.Root
+ class="metric-r3"
+ size="sm"
+ data-testid="metric-card"
+ role="listitem"
+ data-todo={metric.usageLabel === '-' ? 'TODO: /api/ai/usage 未返回 totals.requests(使用记录为空)时保持占位 -' : undefined}
+ >
+ <span class="mlabel">AI 用量</span>
+ <span class="mvalue">{metric.usageLabel}</span>
+ {#if metric.usageTokens}<span class="mvalue-sub">{metric.usageTokens}</span>{/if}
+ </Card.Root>
</div>
- <div class="metric-card" data-testid="metric-card" role="listitem">
- <span class="mlabel">数据集</span>
- <span class="mvalue">{metric.datasetCount}</span>
- </div>
- <div class="metric-card" data-testid="metric-card" role="listitem">
- <span class="mlabel">回测</span>
- <span class="mvalue">
- <span class="run-split"><i>运行中</i> {metric.runningRuns}</span>
- <span class="run-split"><i>已完成</i> {metric.doneRuns}</span>
- </span>
- </div>
- <div
- class="metric-card"
- data-testid="metric-card"
- role="listitem"
- data-todo={metric.usageLabel === '-' ? 'TODO: /api/ai/usage 未返回 totals.requests(使用记录为空)时保持占位 -' : undefined}
- >
- <span class="mlabel">AI 用量</span>
- <span class="mvalue">{metric.usageLabel}</span>
- {#if metric.usageTokens}<span class="mvalue-sub">{metric.usageTokens}</span>{/if}
- </div>
-</div>
<style>
.metric-row {
@@ -50,26 +52,12 @@
gap: 0.6rem;
margin-bottom: 1.1rem;
}
- .metric-card {
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: calc(var(--radius) * 1.4);
- padding: 0.65rem 0.85rem;
- display: flex;
- flex-direction: column;
- gap: 0.1rem;
+ .metric-row :global(.metric-r3) {
+ gap: 0.12rem;
min-width: 0;
- transition: box-shadow 150ms ease, transform 150ms ease;
}
- /* 桌面端 hover 轻微上浮阴影 */
- @media (hover: hover) and (min-width: 768px) {
- .metric-card:hover {
- box-shadow: var(--shadow);
- transform: translateY(-1px);
- }
- }
- .metric-card .mvalue {
- font-size: 1.35rem;
+ .metric-row :global(.metric-r3 .mvalue) {
+ font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
@@ -77,7 +65,6 @@
line-height: 1.2;
overflow-wrap: anywhere;
}
- .run-split { display: inline-flex; align-items: baseline; gap: 0.3rem; }
- .run-split i { font-style: normal; font-size: 0.68rem; color: var(--text-3); font-weight: 500; }
- .mvalue-sub { font-size: 0.72rem; color: var(--text-3); }
+
+ .mvalue-sub { color: var(--text-3); }
</style>
diff --git a/frontend/src/components/Status.svelte b/frontend/src/components/Status.svelte
index 019c224..6ae2cfb 100644
--- a/frontend/src/components/Status.svelte
+++ b/frontend/src/components/Status.svelte
@@ -16,12 +16,12 @@
{busyText}
</div>
{:else if error}
- <div class="banner error" role="alert">
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] text-[var(--destructive)] border-[#f2c7c7]">
<span aria-hidden="true">⚠</span>
<span>{error}</span>
- </div>
+ </Alert>
{:else if empty !== null && empty !== undefined && empty !== false}
- <div class="state empty">
+ <div class="state-empty">
<p class="empty-title">{empty}</p>
{#if emptyHint}<p class="hint">{emptyHint}</p>{/if}
</div>
@@ -29,11 +29,15 @@
{#if warnings && warnings.length > 0}
<div class="stack">
{#each warnings as w, i (i)}
- <div class="banner warn"><span aria-hidden="true">△</span><span>{w}</span></div>
+ <Alert variant="default" class="bg-[var(--warn-soft)] text-[var(--warn)] border-[#f0ddb4]"><span aria-hidden="true">△</span><span>{w}</span></Alert>
{/each}
</div>
{/if}
+<script lang="ts" module>
+ import { Alert } from '$lib/components/ui/alert';
+</script>
+
<style>
.status {
color: var(--text-3);
@@ -54,7 +58,7 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
- .empty {
+ .state-empty {
text-align: center;
padding: 2.2rem 1rem;
border: 1px dashed var(--border-strong);
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte
index ef6d1a3..9e90e78 100644
--- a/frontend/src/pages/AccountPage.svelte
+++ b/frontend/src/pages/AccountPage.svelte
@@ -6,6 +6,12 @@
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime } from '../lib/format';
import type { SessionInfo } from '../lib/types';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Label } from '$lib/components/ui/label';
+ import * as Card from '$lib/components/ui/card';
+ import { Badge } from '$lib/components/ui/badge';
+ import * as Table from '$lib/components/ui/table';
let nameInput = $state(session.me?.name ?? '');
let profileBusy = $state(false);
@@ -99,91 +105,91 @@
<Page title="账户与安全" subtitle="资料、密码与个人会话管理">
<div class="stack">
- <section class="card stack">
- <h2>个人信息</h2>
+ <Card.Root size="sm" >
+ <Card.Header><Card.Title>个人信息</Card.Title></Card.Header>
<form onsubmit={saveProfile} class="row">
- <label style="flex:1 1 200px">
- 姓名
- <input bind:value={nameInput} autocomplete="name" />
- </label>
+ <div style="flex:1 1 200px">
+ <Label for="acc-name">姓名</Label>
+ <Input id="acc-name" bind:value={nameInput} autocomplete="name" />
+ </div>
<div>
邮箱
- <div class="fixed">{session.me?.email ?? '—'}</div>
+ <div class="text-muted-foreground">{session.me?.email ?? "—"}</div>
</div>
<div>
角色
<div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div>
</div>
- <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}>
+ <Button type="submit" disabled={profileBusy || nameInput.trim().length === 0}>
{profileBusy ? '保存中…' : '保存资料'}
- </button>
+ </Button>
</form>
<Status busy={false} error={profileErr} empty={profileOk} />
- </section>
+ </Card.Root>
- <section class="card stack">
- <h2>修改密码</h2>
+ <Card.Root size="sm" >
+ <Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
<form onsubmit={changePassword} class="stack">
<div class="grid3 wrap-3">
- <label>
- 当前密码
- <input type="password" bind:value={curPw} required autocomplete="current-password" />
- </label>
- <label>
- 新密码
- <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} />
- </label>
- <label>
- 确认新密码
- <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} />
- </label>
+ <div>
+ <Label for="pw-cur">当前密码</Label>
+ <Input id="pw-cur" type="password" bind:value={curPw} required autocomplete="current-password" />
+ </div>
+ <div>
+ <Label for="pw-new">新密码</Label>
+ <Input id="pw-new" type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} />
+ </div>
+ <div>
+ <Label for="pw-new2">确认新密码</Label>
+ <Input id="pw-new2" type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} />
+ </div>
</div>
<div class="row">
- <button class="btn primary" disabled={pwBusy}>
+ <Button type="submit" disabled={pwBusy}>
{pwBusy ? '更新中…' : '更新密码'}
- </button>
+ </Button>
</div>
<Status busy={false} error={pwErr} empty={pwOk} />
</form>
- </section>
+ </Card.Root>
- <section class="card stack">
- <h2>登录会话</h2>
+ <Card.Root size="sm" >
+ <Card.Header><Card.Title>登录会话</Card.Title></Card.Header>
<Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} />
{#if !sessBusy && !sessErr && sessions.length === 0}
<Status busy={false} empty="没有其他会话记录" />
{/if}
{#if sessions.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>创建</th>
- <th>最近活跃</th>
- <th>设备/UA</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>创建</Table.Head>
+ <Table.Head>最近活跃</Table.Head>
+ <Table.Head>设备/UA</Table.Head>
+ <Table.Head></Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each sessions as s (s.id)}
- <tr>
- <td>{fmtDateTime(s.created_at)}</td>
- <td>{fmtDateTime(s.last_seen_at)}</td>
- <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td>
- <td>
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(s.created_at)}</Table.Cell>
+ <Table.Cell>{fmtDateTime(s.last_seen_at)}</Table.Cell>
+ <Table.Cell class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</Table.Cell>
+ <Table.Cell>
<div class="row">
- {#if s.current}<span class="badge teal">当前会话</span>{/if}
- <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}>
+ {#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 ? '注销当前' : '撤销'}
- </button>
+ </Button>
</div>
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<Status busy={false} error={null} empty={sessMsg} />
<p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p>
- </section>
+ </Card.Root>
</div>
-</Page>
+</Page> \ No newline at end of file
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
index 984962d..d9bea83 100644
--- a/frontend/src/pages/AdminPage.svelte
+++ b/frontend/src/pages/AdminPage.svelte
@@ -12,11 +12,20 @@
postAdminUserPassword
} from '../lib/client';
import { extractStatusMessage } from '../lib/state';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Alert } from '$lib/components/ui/alert';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Label } from '$lib/components/ui/label';
+ import * as Tabs from '$lib/components/ui/tabs';
+ import * as Card from '$lib/components/ui/card';
+ import * as Table from '$lib/components/ui/table';
import { fmtDateTime } from '../lib/format';
import type { User, Invitation } from '../lib/types';
let tab = $state<'users' | 'invites' | 'audit'>('users');
+
let users: User[] = $state([]);
let usersBusy = $state(true);
let usersErr = $state<string | null>(null);
@@ -167,39 +176,41 @@
<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码">
{#snippet actions()}
- <div class="tabs" role="tablist">
- <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button>
- <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button>
- <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button>
- </div>
+ <Tabs.Root value={tab} onValueChange={(v) => (tab = v as 'users' | 'invites' | 'audit')}>
+ <Tabs.List variant="line">
+ <Tabs.Trigger value="users">用户</Tabs.Trigger>
+ <Tabs.Trigger value="invites">邀请与恢复</Tabs.Trigger>
+ <Tabs.Trigger value="audit">审计日志</Tabs.Trigger>
+ </Tabs.List>
+ </Tabs.Root>
{/snippet}
<div class="stack">
<Status busy={false} error={adminOpsErr} empty={null} />
{#if tab === 'users'}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>用户</h2>
<Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
{#if !usersBusy && !usersErr && users.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>邮箱</th>
- <th>姓名</th>
- <th>角色</th>
- <th>状态</th>
- <th>AI 权限</th>
- <th>日回测上限</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>邮箱</Table.Head>
+ <Table.Head>姓名</Table.Head>
+ <Table.Head>角色</Table.Head>
+ <Table.Head>状态</Table.Head>
+ <Table.Head>AI 权限</Table.Head>
+ <Table.Head>日回测上限</Table.Head>
+ <Table.Head>操作</Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each users as u (u.id)}
- <tr>
- <td>{u.email}</td>
- <td>{u.name}</td>
- <td>
+ <Table.Row>
+ <Table.Cell>{u.email}</Table.Cell>
+ <Table.Cell>{u.name}</Table.Cell>
+ <Table.Cell>
<select
value={u.role}
aria-label={`角色 ${u.email}`}
@@ -208,162 +219,158 @@
<option value="member" selected={u.role === 'member'}>成员</option>
<option value="admin" selected={u.role === 'admin'}>管理员</option>
</select>
- </td>
- <td>
- <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span>
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
+ <Badge variant={u.active ? 'secondary' : 'destructive'}>{u.active ? '启用' : '停用'}</Badge>
+ </Table.Cell>
+ <Table.Cell>
<label class="checkline">
<input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} />
</label>
- </td>
- <td>
- <input
- class="num"
- style="width:90px"
+ </Table.Cell>
+ <Table.Cell>
+ <Input
+ class="num w-[90px]"
type="number"
min="0"
max="200"
value={u.daily_run_limit ?? 5}
onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })}
/>
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
<div class="row">
- <button
- class="btn small {u.active ? 'danger' : ''}"
- onclick={() => void patchUser(u, { active: !u.active })}
- >
+ <Button variant={u.active ? 'destructive' : 'outline'} size="sm" onclick={() => void patchUser(u, { active: !u.active })}>
{u.active ? '停用' : '启用'}
- </button>
- <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button>
+ </Button>
+ <Button variant="outline" size="sm" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</Button>
</div>
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
- </section>
+ </Card.Root>
{:else if tab === 'invites'}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>签发邀请</h2>
<form onsubmit={makeInvite} class="row">
- <label style="flex:1 1 220px">
- 邮箱(可选备注)
- <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
- </label>
- <label style="flex:0 0 130px">
- 有效小时
- <input type="number" bind:value={inviteHours} min="1" max="720" />
- </label>
- <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button>
+ <div style="flex:1 1 220px">
+ <Label for="inv-em">邮箱(可选备注)</Label>
+ <Input id="inv-em" bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
+ </div>
+ <div style="flex:0 0 130px">
+ <Label for="inv-h">有效小时</Label>
+ <Input id="inv-h" type="number" bind:value={inviteHours} min="1" max="720" />
+ </div>
+ <Button disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</Button>
</form>
<Status busy={false} error={inviteErr} empty={null} />
{#if newInvite}
- <div class="banner info">
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
<div>
<div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div>
<div class="mono-invite">{newInvite.token}</div>
<div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div>
<div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div>
- <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button>
+ <Button variant="outline" size="sm" class="mt-1" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</Button>
</div>
- </div>
+ </Alert>
{/if}
- </section>
+ </Card.Root>
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>历史邀请</h2>
<Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
{#if !inviteBusy && !inviteErr && invites.length === 0}
<Status busy={false} empty="还没有邀请记录" />
{/if}
{#if !inviteBusy && invites.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>邮箱备注</th>
- <th>角色</th>
- <th>创建时间</th>
- <th>过期</th>
- <th>状态</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
+ <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.Head></Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each invites as inv (inv.id)}
- <tr>
- <td>{inv.email ?? '—'}</td>
- <td>{inv.role === 'admin' ? '管理员' : '成员'}</td>
- <td>{fmtDateTime(inv.created_at)}</td>
- <td>{fmtDateTime(inv.expires_at)}</td>
- <td>
- {#if inv.used_at}<span class="badge grey">已使用</span>
- {:else if inv.revoked}<span class="badge grey">已撤销</span>
- {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span>
- {:else}<span class="badge teal">有效</span>
+ <Table.Row>
+ <Table.Cell>{inv.email ?? '—'}</Table.Cell>
+ <Table.Cell>{inv.role === 'admin' ? '管理员' : '成员'}</Table.Cell>
+ <Table.Cell>{fmtDateTime(inv.created_at)}</Table.Cell>
+ <Table.Cell>{fmtDateTime(inv.expires_at)}</Table.Cell>
+ <Table.Cell>
+ {#if inv.used_at}<Badge variant="outline">已使用</Badge>
+ {:else if inv.revoked}<Badge variant="outline">已撤销</Badge>
+ {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<Badge variant="outline">已过期</Badge>
+ {:else}<Badge variant="secondary">有效</Badge>
{/if}
- </td>
- <td>
+ </Table.Cell>
+ <Table.Cell>
{#if !inv.used_at && !inv.revoked}
- <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button>
+ <Button variant="destructive" size="sm" onclick={() => void revokeInvite(inv)}>撤销</Button>
{/if}
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
- </section>
+ </Card.Root>
{:else}
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>安全审计(收尾尾部)</h2>
<Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
{#if !auditBusy && !auditErr && audit.length === 0}
<Status busy={false} empty="暂无审计事件" />
{/if}
{#if !auditBusy && audit.length > 0}
- <table class="data">
- <thead>
- <tr>
- <th>时间</th>
- <th>行为者</th>
- <th>事件</th>
- <th>对象</th>
- <th>结果</th>
- </tr>
- </thead>
- <tbody>
+ <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 audit as e, i (i)}
- <tr>
- <td>{fmtDateTime(e.time)}</td>
- <td>{e.actor ?? '—'}</td>
- <td>{e.action}</td>
- <td class="num">{e.target ?? '—'}</td>
- <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td>
- </tr>
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(e.time)}</Table.Cell>
+ <Table.Cell>{e.actor ?? '—'}</Table.Cell>
+ <Table.Cell>{e.action}</Table.Cell>
+ <Table.Cell class="num">{e.target ?? '—'}</Table.Cell>
+ <Table.Cell><Badge variant={e.status === 'ok' || e.status === 'success' ? 'secondary' : 'destructive'}>{e.status}</Badge></Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
{/if}
<p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
- </section>
+ </Card.Root>
{/if}
</div>
{#if resetFor}
<Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
<div class="stack">
- <div class="banner info">
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
<div>
<div>恢复链接(管理员线下告知对方,只出现一次):</div>
<div class="mono-invite">{resetUrl}</div>
<div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
</div>
- </div>
+ </Alert>
<p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p>
</div>
</Modal>
diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte
index 7495d62..88ca07a 100644
--- a/frontend/src/pages/AuthPages.svelte
+++ b/frontend/src/pages/AuthPages.svelte
@@ -1,5 +1,12 @@
<script lang="ts">
import type { Route } from '../lib/router';
+ import { Button } from '$lib/components/ui/button';
+ import { Input } from '$lib/components/ui/input';
+ import { Label } from '$lib/components/ui/label';
+ import { Alert } from '$lib/components/ui/alert';
+ import * as Card from '$lib/components/ui/card';
+ import { Separator } from '$lib/components/ui/separator';
+
import { session } from '../lib/session.svelte';
import { go } from '../lib/router';
import { postLogin, postRegister, postResetPassword } from '../lib/client';
@@ -86,7 +93,7 @@
</script>
<section class="auth-wrap">
- <div class="auth-card card" role="main">
+ <Card.Root class="auth-card" role="main">
<div class="auth-brand">
<span class="mark" aria-hidden="true"></span>
<h1>策研 <span class="sub">Strategy Lab</span></h1>
@@ -96,63 +103,63 @@
{#if mode === 'register'}
<form onsubmit={register}>
<div>
- <label for="inv">邀请令牌</label>
- <input id="inv" bind:value={inviteToken} required autocomplete="off" />
+ <Label for="inv">邀请令牌</Label>
+ <Input id="inv" bind:value={inviteToken} required autocomplete="off" />
<p class="hint">单次有效,来自管理员邀请</p>
</div>
<div>
- <label for="nm">姓名</label>
- <input id="nm" bind:value={name} required autocomplete="name" />
+ <Label for="nm">姓名</Label>
+ <Input id="nm" bind:value={name} required autocomplete="name" />
</div>
<div>
- <label for="em">邮箱</label>
- <input id="em" type="email" bind:value={email} required autocomplete="email" />
+ <Label for="em">邮箱</Label>
+ <Input id="em" type="email" bind:value={email} required autocomplete="email" />
</div>
<div>
- <label for="pw">密码</label>
- <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} />
+ <Label for="pw">密码</Label>
+ <Input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} />
<div class="hint">至少 8 位</div>
</div>
- {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
- <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button>
+ {#if resultError}<Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">{resultError}</Alert>{/if}
+ <Button type="submit" disabled={busy}>{busy ? '创建中…' : '开通账户'}</Button>
<p class="hint">已有账户? <a href="#/login">登录</a></p>
</form>
{:else if mode === 'reset'}
{#if done}
- <div class="banner info">{done}</div>
+ <Alert class="bg-[var(--brand-soft)] border-[#bfe9db]">{done}</Alert>
<p><a href="#/login">前往登录</a></p>
{:else}
<form onsubmit={reset}>
<div>
- <label for="rt">重置令牌</label>
- <input id="rt" bind:value={resetToken} autocomplete="off" />
+ <Label for="rt">重置令牌</Label>
+ <Input id="rt" bind:value={resetToken} autocomplete="off" />
<p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
</div>
<div>
- <label for="np">新密码</label>
- <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} />
+ <Label for="np">新密码</Label>
+ <Input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} />
</div>
<div>
- <label for="np2">再输入一次新密码</label>
- <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} />
+ <Label for="np2">再输入一次新密码</Label>
+ <Input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} />
</div>
- {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
- <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button>
+ {#if resultError}<Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">{resultError}</Alert>{/if}
+ <Button type="submit" disabled={busy}>{busy ? '重置中…' : '重置密码'}</Button>
<p class="hint">想起密码了? <a href="#/login">直接登录</a></p>
</form>
{/if}
{:else}
<form onsubmit={login}>
<div>
- <label for="le">邮箱</label>
- <input id="le" type="email" bind:value={email} required autocomplete="email" />
+ <Label for="le">邮箱</Label>
+ <Input id="le" type="email" bind:value={email} required autocomplete="email" />
</div>
<div>
- <label for="lp">密码</label>
- <input id="lp" type="password" bind:value={password} required autocomplete="current-password" />
+ <Label for="lp">密码</Label>
+ <Input id="lp" type="password" bind:value={password} required autocomplete="current-password" />
</div>
- {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
- <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
+ {#if resultError}<Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">{resultError}</Alert>{/if}
+ <Button type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</Button>
<p class="hint">
开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a>
{#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if}
@@ -160,7 +167,7 @@
<p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p>
</form>
{/if}
- </div>
+ </Card.Root>
</section>
<style>
@@ -178,10 +185,10 @@
radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent),
var(--bg);
}
- .auth-card { width: min(400px, 100%); padding: 2rem 1.75rem 1.8rem; }
+ :global(.auth-card) { width: min(400px, 100%); padding: 2rem 1.75rem 1.8rem; }
/* round2 登录页留白重排:标题区与表单拉开,卡片下缘留呼吸感 */
@media (min-width: 768px) {
- .auth-card { padding: 2.4rem 2.2rem 2.1rem; }
+ :global(.auth-card) { padding: 2.4rem 2.2rem 2.1rem; }
}
.auth-brand { margin-bottom: 1.35rem; }
.auth-brand h1 { margin-bottom: 0.3rem; }
diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte
index baa9e5f..a9e7799 100644
--- a/frontend/src/pages/ProjectBacktestTab.svelte
+++ b/frontend/src/pages/ProjectBacktestTab.svelte
@@ -12,6 +12,11 @@
} from '../lib/format';
import { summarizeCoverage } from '../lib/coverage';
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 Table from '$lib/components/ui/table';
interface Props {
projectId: string;
@@ -219,7 +224,7 @@
</script>
<div class="stack">
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<h2>发起回测</h2>
<Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} />
{#if !busyDatasets && datasetsReady.length === 0 && !cfgErr}
@@ -262,7 +267,7 @@
</label>
</div>
{#if needAck}
- <div class="banner warn" role="alert">
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
<span aria-hidden="true">⚠</span>
<div>
该数据集存在覆盖/预警信息,需要明确知晓后才能启动:
@@ -274,25 +279,25 @@
我已知晓并用当前数据继续
</label>
</div>
- </div>
+ </Alert>
{/if}
<div class="row">
- <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button>
+ <Button onclick={startRun} disabled={!datasetId}>开始回测</Button>
{#if selectedDataset}
<span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
{/if}
</div>
<Status busy={false} error={cfgErr} empty={actionMsg} />
{/if}
- </section>
+ </Card.Root>
- <section class="card stack">
+ <Card.Root size="sm" class="stack-sec">
<div class="spread">
<h2>最近一次回测</h2>
{#if latest}
<span class="row">
- <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span>
- <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span>
+ <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>
{/if}
</div>
@@ -305,80 +310,78 @@
error={latestErr}
/>
<div class="rows-grid">
- <table class="data kv">
- <tbody>
- <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr>
- {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if}
- {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if}
- <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr>
- <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr>
- <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr>
- </tbody>
- </table>
+ <Table.Root>
+ <Table.Body>
+ <Table.Row><Table.Head>启动时间</Table.Head><Table.Cell>{fmtDateTime(latest.created_at)}</Table.Cell></Table.Row>
+ {#if latest.started_at}<Table.Row><Table.Head>开始执行</Table.Head><Table.Cell>{fmtDateTime(latest.started_at)}</Table.Cell></Table.Row>{/if}
+ {#if latest.finished_at}<Table.Row><Table.Head>结束时间</Table.Head><Table.Cell>{fmtDateTime(latest.finished_at)}</Table.Cell></Table.Row>{/if}
+ <Table.Row><Table.Head>代码版本</Table.Head><Table.Cell><span class="num">{latest.version_id.slice(0, 10)}</span></Table.Cell></Table.Row>
+ <Table.Row><Table.Head>数据集</Table.Head><Table.Cell><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></Table.Cell></Table.Row>
+ <Table.Row><Table.Head>资金</Table.Head><Table.Cell>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</Table.Cell></Table.Row>
+ </Table.Body>
+ </Table.Root>
</div>
{#if latest.error}
- <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div>
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{latest.error}</span></Alert>
{/if}
{#if latest.result?.warnings?.length}
- <div class="banner warn" role="alert">
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
<span aria-hidden="true">⚠</span>
<ul class="plain-list">
{#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each}
</ul>
- </div>
+ </Alert>
{/if}
{#if latest.status === 'succeeded' && latest.result}
- <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a>
+ <Button variant="outline" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</Button>
{/if}
<div class="row">
{#if canCancel}
- <button class="btn danger" onclick={onCancel} disabled={cancelBusy}>
+ <Button variant="destructive" onclick={onCancel} disabled={cancelBusy}>
{cancelBusy ? '取消中…' : '取消回测'}
- </button>
+ </Button>
{/if}
{#if canRerun}
- <button class="btn" onclick={onRerun} disabled={rerunBusy}>
+ <Button variant="outline" onclick={onRerun} disabled={rerunBusy}>
{rerunBusy ? '重新提交…' : '按原始代码&数据重跑'}
- </button>
+ </Button>
{/if}
</div>
{:else}
<Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" />
{/if}
- </section>
+ </Card.Root>
- <section class="stack">
+ <div class="stack">
<h2>回测历史</h2>
<Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} />
{#if history && history.length > 0}
- <div class="card" style="padding:0.4rem 0.6rem">
- <table class="data">
- <thead>
- <tr>
- <th>启动时间</th>
- <th>状态</th>
- <th>资金/佣金/滑点</th>
- <th>数据集</th>
- <th>结果</th>
- </tr>
- </thead>
- <tbody>
+ <div class="px-3 py-2 rounded-[var(--radius-lg)] border border-border bg-card">
+ <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 history as h (h.id)}
- <tr>
- <td>{fmtDateTime(h.created_at)}</td>
- <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td>
- <td>
- {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)}
- </td>
- <td class="num">{h.dataset_id?.slice(0, 8)}…</td>
- <td>
- <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a>
- </td>
- </tr>
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(h.created_at)}</Table.Cell>
+ <Table.Cell>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</Table.Cell>
+ <Table.Cell>{formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)}</Table.Cell>
+ <Table.Cell class="num">{h.dataset_id?.slice(0, 8)}…</Table.Cell>
+ <Table.Cell>
+ <Button variant="outline" size="sm" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</Button>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
</div>
{/if}
- </section>
-</div>
+ </div>
+ </div>
diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte
index c08bf3f..82a7009 100644
--- a/frontend/src/pages/ProjectResultsTab.svelte
+++ b/frontend/src/pages/ProjectResultsTab.svelte
@@ -15,6 +15,10 @@
metricsRow
} from '../lib/format';
import { capitalReporting } from '../lib/capitalMetrics';
+ import { Alert } from '$lib/components/ui/alert';
+ import { Badge } from '$lib/components/ui/badge';
+ import * as Card from '$lib/components/ui/card';
+ import * as Table from '$lib/components/ui/table';
import type { Run, BacktestResult } from '../lib/types';
interface Props {
@@ -117,7 +121,7 @@
{#if run}
<span class="row">
<span class="hint">Run {run.id.slice(0, 8)}…</span>
- <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span>
+ <Badge variant={runStatusBadge[run.status] === 'ok' ? 'secondary' : runStatusBadge[run.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[run.status]}</Badge>
</span>
{/if}
</div>
@@ -141,67 +145,38 @@
{/if}
{#if run.error}
- <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div>
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{run.error}</span></Alert>
{/if}
{#if !result}
<Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} />
{:else}
{#if result.warnings?.length}
- <div class="banner warn" role="alert">
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
<span aria-hidden="true">⚠</span>
- <div>
- <ul class="ul-plain">
- {#each result.warnings as w, i (i)}<li>{w}</li>{/each}
- </ul>
- </div>
- </div>
+ <ul class="ul-plain">
+ {#each result.warnings as w, i (i)}<li>{w}</li>{/each}
+ </ul>
+ </Alert>
{/if}
- <div class="card">
+ <Card.Root size="sm">
{#if result.metrics}
{@const mrow = result.metrics}
<div class="metric-grid">
- <div class="metric">
- <div class="mlabel">总收益率</div>
- <div class="mvalue">{fmtPct(mrow.total_return)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">年化收益率</div>
- <div class="mvalue">{fmtPct(mrow.annual_return)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">最大回撤</div>
- <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">夏普比率</div>
- <div class="mvalue">{fmtNum(mrow.sharpe)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">平仓回合数</div>
- <div class="mvalue">{mrow.trade_count ?? "—"}</div>
- </div>
- <div class="metric">
- <div class="mlabel">期末权益</div>
- <div class="mvalue">{formatMoney(mrow.final_equity)}</div>
- </div>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">总收益率</div><div class="mvalue">{fmtPct(mrow.total_return)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">年化收益率</div><div class="mvalue">{fmtPct(mrow.annual_return)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">最大回撤</div><div class="mvalue">{fmtPct(mrow.max_drawdown)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">夏普比率</div><div class="mvalue">{fmtNum(mrow.sharpe)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">平仓回合数</div><div class="mvalue">{mrow.trade_count ?? "—"}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">期末权益</div><div class="mvalue">{formatMoney(mrow.final_equity)}</div></Card.Root>
</div>
{/if}
{#if cap}
<div class="metric-grid" data-testid="capital-reporting">
- <div class="metric">
- <div class="mlabel">累计投入</div>
- <div class="mvalue">{formatMoney(cap.invested)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">最终持仓估值</div>
- <div class="mvalue">{formatMoney(cap.holdingValue)}</div>
- </div>
- <div class="metric">
- <div class="mlabel">投入回报率</div>
- <div class="mvalue">{cap.returnPct ?? '—'}</div>
- </div>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">累计投入</div><div class="mvalue">{formatMoney(cap.invested)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">最终持仓估值</div><div class="mvalue">{formatMoney(cap.holdingValue)}</div></Card.Root>
+ <Card.Root size="sm" class="m-tile"><div class="mlabel">投入回报率</div><div class="mvalue">{cap.returnPct ?? '—'}</div></Card.Root>
</div>
<p class="hint">投入口径不含初始账户闲置现金:累计投入 = 首日现金 − 末日现金;持仓估值 = 末日持仓 × 最新收盘价。</p>
{/if}
@@ -211,9 +186,9 @@
{#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if}
{#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if}
</p>
- </div>
+ </Card.Root>
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<div class="row">
<Button variant={chartMode === "equity" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</Button>
<Button variant={chartMode === "drawdown" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</Button>
@@ -223,42 +198,40 @@
{:else}
<Chart data={drawdownData} series={drawdownSeries} ariaLabel="回撤曲线" height={300} valueFormatter={(v) => (typeof v === 'number' ? `${v.toFixed(2)}%` : String(v))} />
{/if}
- </div>
+ </Card.Root>
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<h2>订单与持仓记录</h2>
<div class="cols-2">
<div>
<h3>成交记录 ({result.trades.length})</h3>
{#if result.trades.length > 0}
- <div class="table-wrap">
- <table class="data">
- <thead>
- <tr>
- <th>日期</th>
- <th>标的</th>
- <th>方向</th>
- <th>数量</th>
- <th>价格</th>
- <th>金额</th>
- <th>佣金</th>
- </tr>
- </thead>
- <tbody>
- {#each result.trades.slice(0, 80) as t, i (i)}
- <tr>
- <td>{t.date}</td>
- <td class="num">{t.symbol}</td>
- <td>{t.side === "buy" ? "买入" : "卖出"}</td>
- <td class="num">{t.quantity}</td>
- <td class="num">{fmtNum(t.price, 3)}</td>
- <td class="num">{fmtNum(t.value)}</td>
- <td class="num">{fmtNum(t.commission, 4)}</td>
- </tr>
- {/each}
- </tbody>
- </table>
- </div>
+ <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.Head>金额</Table.Head>
+ <Table.Head>佣金</Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ {#each result.trades.slice(0, 80) as t, i (i)}
+ <Table.Row>
+ <Table.Cell>{t.date}</Table.Cell>
+ <Table.Cell class="num">{t.symbol}</Table.Cell>
+ <Table.Cell>{t.side === "buy" ? "买入" : "卖出"}</Table.Cell>
+ <Table.Cell class="num">{t.quantity}</Table.Cell>
+ <Table.Cell class="num">{fmtNum(t.price, 3)}</Table.Cell>
+ <Table.Cell class="num">{fmtNum(t.value)}</Table.Cell>
+ <Table.Cell class="num">{fmtNum(t.commission, 4)}</Table.Cell>
+ </Table.Row>
+ {/each}
+ </Table.Body>
+ </Table.Root>
{#if result.trades.length > 80}
<p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p>
{/if}
@@ -283,7 +256,7 @@
结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。
</p>
</div>
- </div>
+ </Card.Root>
{/if}
{/if}
diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte
index 0df9f1a..8614cc0 100644
--- a/frontend/src/pages/ProjectStrategyTab.svelte
+++ b/frontend/src/pages/ProjectStrategyTab.svelte
@@ -5,6 +5,10 @@
import Modal from '../components/Modal.svelte';
import { Button } from '$lib/components/ui/button';
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';
+ import * as Card from '$lib/components/ui/card';
import { projectsStore } from '../lib/listsStore.svelte';
import {
getProject,
@@ -261,14 +265,14 @@
<Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} />
{#if !initBusy && !initErr}
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<div class="spread">
<h2>策略代码(Backtrader Python)</h2>
<span class="save-status" role="status">
{saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`}
- {#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if}
- {#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if}
- {#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if}
+ {#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="hint">
@@ -298,23 +302,23 @@
<Status busy={false} error={snapshotErr} empty={snapshotMsg} />
{#if conflictServer}
- <div class="banner warn" role="alert">
+ <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
<span aria-hidden="true">⚠</span>
<span>
服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。
</span>
- </div>
+ </Alert>
<DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" />
<div class="row">
<Button onclick={keepMine}>保留本机修改(覆盖服务器)</Button>
<Button variant="outline" onclick={loadServer}>采用服务器版本</Button>
</div>
{/if}
- </div>
+ </Card.Root>
<div class="stack">
<h2>AI 助手</h2>
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label>
<textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea>
<div class="row">
@@ -323,10 +327,10 @@
</Button>
</div>
{#if aiErr}
- <div class="banner error" role="alert">
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">
<span aria-hidden="true">⚠</span>
<span>{aiErr}</span>
- </div>
+ </Alert>
{/if}
{#if aiResult}
@@ -352,18 +356,18 @@
</Button>
<Button variant="ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</Button>
{#if aiResult.base_generation !== baseGeneration}
- <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
+ <span class="text-[var(--warn)]">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
{/if}
{#if aiAcceptErr}
- <div class="banner error" role="alert">{aiAcceptErr}</div>
+ <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">{aiAcceptErr}</Alert>
{/if}
</div>
</div>
{/if}
{#if aiNote}
- <div class="banner info">{aiNote}</div>
+ <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">{aiNote}</Alert>
{/if}
- </div>
+ </Card.Root>
</div>
<div class="stack">
@@ -373,7 +377,7 @@
<Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" />
{/if}
{#if !versionsBusy && versions.length > 0}
- <div class="card" style="padding:0.4rem 0.6rem">
+ <div class="px-3 py-2 rounded-[var(--radius-lg)] border border-border bg-card">
<table class="data">
<thead>
<tr>
@@ -393,7 +397,7 @@
<span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span>
</td>
<td class="num">{v.hash?.slice(0, 12)}</td>
- <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td>
+ <td><Badge variant="outline">{versionSourceLabel[v.source]}</Badge></td>
<td>
<div class="row">
<Button variant="outline" size="sm" onclick={() => void compareWithDraft(v)}>对比当前</Button>
diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte
index c3d7e5e..d72f905 100644
--- a/frontend/src/pages/ProjectsPage.svelte
+++ b/frontend/src/pages/ProjectsPage.svelte
@@ -93,7 +93,7 @@
<ProjectMetricRow projects={projectsStore.all} {datasets} {runs} {usage} />
<Status {busy} {error} busyText="加载项目列表…" empty={null} />
{#if showCreate}
- <form class="card stack" onsubmit={create}>
+ <form class="stack bg-card border border-border rounded-[var(--radius)] shadow-sm p-4 text-card-foreground" onsubmit={create}>
<h2>新建项目</h2>
<div>
<Label for="pn">项目名称</Label>
@@ -103,7 +103,7 @@
<Label for="pd">简介(可选)</Label>
<Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" />
</div>
- {#if createErr}<Alert variant="destructive" class="banner error" role="alert">{createErr}</Alert>{/if}
+ {#if createErr}<Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]" role="alert">{createErr}</Alert>{/if}
<div class="row">
<Button type="submit" disabled={creating || !newName.trim()}>
{creating ? '创建中…' : '创建项目'}
@@ -157,5 +157,5 @@
<style>
.p-name { font-weight: 600; color: var(--teal-strong); }
.p-name:hover { text-decoration: underline; }
- form.card h2 { margin-bottom: 0.2rem; }
+
</style>
diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte
index ad72b5d..911da2a 100644
--- a/frontend/src/pages/RunsPage.svelte
+++ b/frontend/src/pages/RunsPage.svelte
@@ -7,6 +7,10 @@
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format';
import { capitalReporting } from '../lib/capitalMetrics';
+ import { Alert } from '$lib/components/ui/alert';
+ import { Badge } from '$lib/components/ui/badge';
+ import * as Card from '$lib/components/ui/card';
+ import * as Table from '$lib/components/ui/table';
import type { Run } from '../lib/types';
let runs: Run[] = $state([]);
@@ -107,25 +111,25 @@ import { capitalReporting } from '../lib/capitalMetrics';
{/if}
{#if !busy && !error && runs.length > 0}
- <div class="card" style="padding:0.4rem 0.6rem">
- <table class="data">
- <thead>
- <tr>
- <th></th>
- <th>时间</th>
- <th>状态</th>
- <th>项目</th>
- <th>数据集</th>
- <th>资金/佣金/滑点</th>
- <th>收益</th>
- <th>最大回撤</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
+ <Card.Root size="sm" class="px-3 py-2 [&>*:[data-slot=card-content]]:p-0">
+ <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.Head>资金/佣金/滑点</Table.Head>
+ <Table.Head>收益</Table.Head>
+ <Table.Head>最大回撤</Table.Head>
+ <Table.Head>操作</Table.Head>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each runs as r (r.id)}
- <tr>
- <td>
+ <Table.Row>
+ <Table.Cell>
<input
type="checkbox"
checked={chosenIds.includes(r.id)}
@@ -133,53 +137,53 @@ import { capitalReporting } from '../lib/capitalMetrics';
aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`}
disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)}
/>
- </td>
- <td>{fmtDateTime(r.created_at)}</td>
- <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td>
- <td class="num">{r.project_id?.slice(0, 8)}…</td>
- <td class="num">{r.dataset_id?.slice(0, 8)}…</td>
- <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td>
- <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td>
- <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td>
- <td>
+ </Table.Cell>
+ <Table.Cell>{fmtDateTime(r.created_at)}</Table.Cell>
+ <Table.Cell><Badge variant={runStatusBadge[r.status] === 'ok' ? 'secondary' : runStatusBadge[r.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[r.status]}</Badge></Table.Cell>
+ <Table.Cell class="num">{r.project_id?.slice(0, 8)}…</Table.Cell>
+ <Table.Cell class="num">{r.dataset_id?.slice(0, 8)}…</Table.Cell>
+ <Table.Cell>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</Table.Cell>
+ <Table.Cell>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</Table.Cell>
+ <Table.Cell>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</Table.Cell>
+ <Table.Cell>
<Button size="sm" variant="outline" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</Button>
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
<p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p>
- </div>
+ </Card.Root>
{#if selected.length > 0}
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<h2>条件差异</h2>
- <table class="data">
- <thead>
- <tr>
- <th>维度</th>
- {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each}
- </tr>
- </thead>
- <tbody>
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ <Table.Head>维度</Table.Head>
+ {#each selected as r (r.id)}<Table.Head>Run {r.id.slice(0, 6)}.</Table.Head>{/each}
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
{#each configDiffRows as row, i (i)}
- <tr class:diffrow={row.differing}>
- <th>{row.label}</th>
+ <Table.Row class={row.differing ? 'diffrow' : ''}>
+ <Table.Head>{row.label}</Table.Head>
{#each row.vals as v, j (j)}
- <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td>
+ <Table.Cell class={row.differing ? 'num-diff num' : 'num'}>{v}</Table.Cell>
{/each}
- </tr>
+ </Table.Row>
{/each}
- </tbody>
- </table>
- </div>
+ </Table.Body>
+ </Table.Root>
+ </Card.Root>
{#if comparable.length > 1}
- <div class="card stack">
+ <Card.Root size="sm" class="stack">
<h2>收益曲线对齐比较(首日归一)</h2>
<Chart data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} />
<p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
- </div>
+ </Card.Root>
{:else}
<Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" />
{/if}
diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte
index 88dd8a5..cfd69ce 100644
--- a/frontend/src/pages/UsagePage.svelte
+++ b/frontend/src/pages/UsagePage.svelte
@@ -5,6 +5,10 @@
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime, fmtNum } from '../lib/format';
import type { AIUsage } from '../lib/types';
+ import * as Card from '$lib/components/ui/card';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Separator } from '$lib/components/ui/separator';
+ import * as Table from '$lib/components/ui/table';
let usage = $state<AIUsage | null>(null);
let busy = $state(true);
@@ -31,51 +35,57 @@
<div class="stack">
<Status busy={busy} error={error} busyText="正在载入用量…" empty={null} />
{#if usage}
- <div class="card">
- <div class="row spaced">
- <div>
- <div class="mlabel">模型请求</div>
- <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div>
+ <Card.Root size="sm" class="p-0 [&>[data-slot=card-content]]:px-4 [&>[data-slot=card-content]]:py-3">
+ <Card.Content>
+ <div class="row" style="gap:0.4rem 0.8rem">
+ <div>
+ <div class="mlabel">模型请求</div>
+ <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div>
+ </div>
+ <Separator orientation="vertical" class="!h-8 self-center" />
+ <div>
+ <div class="mlabel">输入 tokens</div>
+ <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div>
+ </div>
+ <Separator orientation="vertical" class="!h-8 self-center" />
+ <div>
+ <div class="mlabel">输出 tokens</div>
+ <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div>
+ </div>
</div>
- <div>
- <div class="mlabel">输入 tokens</div>
- <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div>
- </div>
- <div>
- <div class="mlabel">输出 tokens</div>
- <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div>
- </div>
- </div>
- {#if usage.internal_poc}
- <p class="hint">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p>
- {/if}
- </div>
+ {#if usage.internal_poc}
+ <p class="hint mt-1">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p>
+ {/if}
+ </Card.Content>
+ </Card.Root>
{#if usage.items.length > 0}
- <div class="card" style="padding:0.4rem 0.6rem">
- <table class="data">
- <thead>
- <tr>
- <th>时间</th>
- <th>模型</th>
- <th>输入</th>
- <th>输出</th>
- <th>状态</th>
- </tr>
- </thead>
- <tbody>
- {#each usage.items as it, i (it.id)}
- <tr>
- <td>{fmtDateTime(it.created_at)}</td>
- <td>{it.model}</td>
- <td class="num">{it.input_tokens ?? '—'}</td>
- <td class="num">{it.output_tokens ?? '—'}</td>
- <td class="badge">{it.status ?? '—'}</td>
- </tr>
+ <Card.Root size="sm" class="px-3 py-2">
+ <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 usage.items as it (it.id)}
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(it.created_at)}</Table.Cell>
+ <Table.Cell>{it.model}</Table.Cell>
+ <Table.Cell class="num">{it.input_tokens ?? '—'}</Table.Cell>
+ <Table.Cell class="num">{it.output_tokens ?? '—'}</Table.Cell>
+ <Table.Cell>
+ <Badge variant={it.status === 'succeeded' ? 'secondary' : 'outline'}>{it.status ?? '—'}</Badge>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
- </div>
+ </Table.Body>
+ </Table.Root>
+ </Card.Root>
{:else}
<Status busy={false} empty="尚未使用 AI 建议" emptyHint="在项目「策略」标签发起一次 AI 修改,记录会出现在这里" />
{/if}