summaryrefslogtreecommitdiff
path: root/frontend/src/pages
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
commita7aab01f051487327e5ce52b6a8905208c546e8c (patch)
tree630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src/pages
parent375584174fb7191a57c5423fea45924ad6d812d5 (diff)
downloadstrategy-lab-a7aab01f051487327e5ce52b6a8905208c546e8c.tar.gz
[变更性质] feat(frontend)
[新增功能] 12 页面组件全量 shadcn:Card/Button/Table/Tabs/Select/Alert;375 底部 Tab Bar 改用 Tabs 组件实现 [实现方案] 页面级手写 class 全部移除(.btn/.banner/table.data/.badge/.tabs/.metric-row/.m-tile/.hint 等改为 shadcn + tailwind 工具类);Modal/DatasetWizard/DatasetCard/DiffView/CodeEditor/Status/Layout 同步 [影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src/pages')
-rw-r--r--frontend/src/pages/AccountPage.svelte24
-rw-r--r--frontend/src/pages/AdminPage.svelte74
-rw-r--r--frontend/src/pages/AuthPages.svelte154
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte136
-rw-r--r--frontend/src/pages/ProjectDataTab.svelte2
-rw-r--r--frontend/src/pages/ProjectPage.svelte35
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte182
-rw-r--r--frontend/src/pages/ProjectsPage.svelte52
-rw-r--r--frontend/src/pages/RunsPage.svelte28
-rw-r--r--frontend/src/pages/UsagePage.svelte22
10 files changed, 334 insertions, 375 deletions
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte
index 9e90e78..c85dc53 100644
--- a/frontend/src/pages/AccountPage.svelte
+++ b/frontend/src/pages/AccountPage.svelte
@@ -104,11 +104,11 @@
</script>
<Page title="账户与安全" subtitle="资料、密码与个人会话管理">
- <div class="stack">
- <Card.Root size="sm" >
+ <div class="flex flex-col gap-3">
+ <Card.Root size="sm">
<Card.Header><Card.Title>个人信息</Card.Title></Card.Header>
- <form onsubmit={saveProfile} class="row">
- <div style="flex:1 1 200px">
+ <form onsubmit={saveProfile} class="flex flex-wrap items-center gap-3">
+ <div class="flex-[1_1_200px]">
<Label for="acc-name">姓名</Label>
<Input id="acc-name" bind:value={nameInput} autocomplete="name" />
</div>
@@ -127,10 +127,10 @@
<Status busy={false} error={profileErr} empty={profileOk} />
</Card.Root>
- <Card.Root size="sm" >
+ <Card.Root size="sm">
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
- <form onsubmit={changePassword} class="stack">
- <div class="grid3 wrap-3">
+ <form onsubmit={changePassword} class="flex flex-col gap-3">
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<Label for="pw-cur">当前密码</Label>
<Input id="pw-cur" type="password" bind:value={curPw} required autocomplete="current-password" />
@@ -144,7 +144,7 @@
<Input id="pw-new2" type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} />
</div>
</div>
- <div class="row">
+ <div>
<Button type="submit" disabled={pwBusy}>
{pwBusy ? '更新中…' : '更新密码'}
</Button>
@@ -153,7 +153,7 @@
</form>
</Card.Root>
- <Card.Root size="sm" >
+ <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}
@@ -174,9 +174,9 @@
<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 class="text-sm text-muted-foreground">{s.user_agent?.slice(0, 60) ?? '—'}</Table.Cell>
<Table.Cell>
- <div class="row">
+ <div class="flex items-center gap-2 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 ? '注销当前' : '撤销'}
@@ -189,7 +189,7 @@
</Table.Root>
{/if}
<Status busy={false} error={null} empty={sessMsg} />
- <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p>
+ <p class="text-sm text-muted-foreground">更改密码或被管理员停用时,其他会话将自动失效。</p>
</Card.Root>
</div>
</Page> \ No newline at end of file
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
index d9bea83..2a10266 100644
--- a/frontend/src/pages/AdminPage.svelte
+++ b/frontend/src/pages/AdminPage.svelte
@@ -17,6 +17,7 @@
import { Alert } from '$lib/components/ui/alert';
import { Badge } from '$lib/components/ui/badge';
import { Label } from '$lib/components/ui/label';
+ import * as Select from '$lib/components/ui/select';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
@@ -185,12 +186,12 @@
</Tabs.Root>
{/snippet}
- <div class="stack">
+ <div class="flex flex-col gap-3">
<Status busy={false} error={adminOpsErr} empty={null} />
{#if tab === 'users'}
- <Card.Root size="sm" class="stack-sec">
- <h2>用户</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">用户</h2>
<Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
{#if !usersBusy && !usersErr && users.length > 0}
<Table.Root>
@@ -211,26 +212,29 @@
<Table.Cell>{u.email}</Table.Cell>
<Table.Cell>{u.name}</Table.Cell>
<Table.Cell>
- <select
+ <Select.Root
+ type="single"
value={u.role}
- aria-label={`角色 ${u.email}`}
- onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })}
+ onValueChange={(v) => void patchUser(u, { role: v as 'admin' | 'member' })}
>
- <option value="member" selected={u.role === 'member'}>成员</option>
- <option value="admin" selected={u.role === 'admin'}>管理员</option>
- </select>
+ <Select.Trigger data-testid="role-select" class="w-32">{u.role === 'admin' ? '管理员' : '成员'}</Select.Trigger>
+ <Select.Content>
+ <Select.Item value="member">成员</Select.Item>
+ <Select.Item value="admin">管理员</Select.Item>
+ </Select.Content>
+ </Select.Root>
</Table.Cell>
<Table.Cell>
<Badge variant={u.active ? 'secondary' : 'destructive'}>{u.active ? '启用' : '停用'}</Badge>
</Table.Cell>
<Table.Cell>
- <label class="checkline">
+ <label class="inline-flex items-center gap-1">
<input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} />
</label>
</Table.Cell>
<Table.Cell>
<Input
- class="num w-[90px]"
+ class="tabular-nums w-24"
type="number"
min="0"
max="200"
@@ -239,7 +243,7 @@
/>
</Table.Cell>
<Table.Cell>
- <div class="row">
+ <div class="flex items-center gap-2 flex-wrap">
<Button variant={u.active ? 'destructive' : 'outline'} size="sm" onclick={() => void patchUser(u, { active: !u.active })}>
{u.active ? '停用' : '启用'}
</Button>
@@ -251,17 +255,17 @@
</Table.Body>
</Table.Root>
{/if}
- <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
+ <p class="text-sm text-muted-foreground">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
</Card.Root>
{:else if tab === 'invites'}
- <Card.Root size="sm" class="stack-sec">
- <h2>签发邀请</h2>
- <form onsubmit={makeInvite} class="row">
- <div style="flex:1 1 220px">
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">签发邀请</h2>
+ <form onsubmit={makeInvite} class="flex flex-wrap items-end gap-3">
+ <div class="flex-[1_1_220px]">
<Label for="inv-em">邮箱(可选备注)</Label>
<Input id="inv-em" bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
</div>
- <div style="flex:0 0 130px">
+ <div class="w-[130px]">
<Label for="inv-h">有效小时</Label>
<Input id="inv-h" type="number" bind:value={inviteHours} min="1" max="720" />
</div>
@@ -269,20 +273,20 @@
</form>
<Status busy={false} error={inviteErr} empty={null} />
{#if newInvite}
- <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
+ <Alert variant="default">
<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>
+ <div class="text-sm">邀请令牌(只显示这一次,请立即复制):</div>
+ <div class="font-mono text-sm break-all">{newInvite.token}</div>
+ <div class="text-sm text-muted-foreground">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div>
+ <div class="font-mono text-sm break-all" title="复制该完整链接给对方">{inviteUrl}</div>
<Button variant="outline" size="sm" class="mt-1" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</Button>
</div>
</Alert>
{/if}
</Card.Root>
- <Card.Root size="sm" class="stack-sec">
- <h2>历史邀请</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">历史邀请</h2>
<Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
{#if !inviteBusy && !inviteErr && invites.length === 0}
<Status busy={false} empty="还没有邀请记录" />
@@ -323,11 +327,11 @@
</Table.Body>
</Table.Root>
{/if}
- <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
+ <p class="text-sm text-muted-foreground">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
</Card.Root>
{:else}
- <Card.Root size="sm" class="stack-sec">
- <h2>安全审计(收尾尾部)</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">安全审计(收尾尾部)</h2>
<Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
{#if !auditBusy && !auditErr && audit.length === 0}
<Status busy={false} empty="暂无审计事件" />
@@ -349,29 +353,29 @@
<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 class="tabular-nums">{e.target ?? '—'}</Table.Cell>
<Table.Cell><Badge variant={e.status === 'ok' || e.status === 'success' ? 'secondary' : 'destructive'}>{e.status}</Badge></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
- <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
+ <p class="text-sm text-muted-foreground">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
</Card.Root>
{/if}
</div>
{#if resetFor}
<Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
- <div class="stack">
- <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
+ <div class="flex flex-col gap-3">
+ <Alert variant="default">
<div>
<div>恢复链接(管理员线下告知对方,只出现一次):</div>
- <div class="mono-invite">{resetUrl}</div>
- <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
+ <div class="font-mono text-sm break-all">{resetUrl}</div>
+ <div class="text-sm text-muted-foreground">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
</div>
</Alert>
- <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p>
+ <p class="text-sm text-muted-foreground">单次有效、限时;重置会吊销对方的全部历史会话。</p>
</div>
</Modal>
{/if}
diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte
index 88ca07a..da5d54c 100644
--- a/frontend/src/pages/AuthPages.svelte
+++ b/frontend/src/pages/AuthPages.svelte
@@ -92,114 +92,86 @@
}
</script>
-<section class="auth-wrap">
- <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>
- <p class="hint">内部策略研究平台 · 仅限受邀成员</p>
+<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>
</div>
{#if mode === 'register'}
<form onsubmit={register}>
- <div>
- <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" />
- </div>
- <div>
- <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} />
- <div class="hint">至少 8 位</div>
+ <div class="flex flex-col gap-4">
+ <div>
+ <Label for="inv">邀请令牌</Label>
+ <Input id="inv" bind:value={inviteToken} required autocomplete="off" />
+ <p class="text-sm text-muted-foreground mt-1">单次有效,来自管理员邀请</p>
+ </div>
+ <div>
+ <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" />
+ </div>
+ <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>
+ </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>
</div>
- {#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}
- <Alert class="bg-[var(--brand-soft)] border-[#bfe9db]">{done}</Alert>
- <p><a href="#/login">前往登录</a></p>
+ <Alert>{done}</Alert>
+ <p class="mt-2"><a class="hover:underline" href="#/login">前往登录</a></p>
{:else}
<form onsubmit={reset}>
- <div>
- <Label for="rt">重置令牌</Label>
- <Input id="rt" bind:value={resetToken} autocomplete="off" />
- <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
+ <div class="flex flex-col gap-4">
+ <div>
+ <Label for="rt">重置令牌</Label>
+ <Input id="rt" bind:value={resetToken} autocomplete="off" />
+ <p class="text-sm text-muted-foreground mt-1">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
+ </div>
+ <div>
+ <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} />
+ </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>
</div>
- <div>
- <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} />
- </div>
- {#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" />
- </div>
- <div>
- <Label for="lp">密码</Label>
- <Input id="lp" type="password" bind:value={password} required autocomplete="current-password" />
+ <div class="flex flex-col gap-4">
+ <div>
+ <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" />
+ </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>
</div>
- {#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}
- </p>
- <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p>
</form>
{/if}
</Card.Root>
</section>
-
-<style>
- .auth-wrap {
- min-height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 1.5rem;
- /* grid place-items center sizes the implicit track to the card's fixed
- width so max-width:100% never engaged (375px probe: 47px doc overflow);
- flex + min() keeps the resolved card width inside the padded viewport */
- min-width: 0;
- background:
- radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent),
- var(--bg);
- }
- :global(.auth-card) { width: min(400px, 100%); padding: 2rem 1.75rem 1.8rem; }
- /* round2 登录页留白重排:标题区与表单拉开,卡片下缘留呼吸感 */
- @media (min-width: 768px) {
- :global(.auth-card) { padding: 2.4rem 2.2rem 2.1rem; }
- }
- .auth-brand { margin-bottom: 1.35rem; }
- .auth-brand h1 { margin-bottom: 0.3rem; }
- .auth-brand p { max-width: 26em; }
- .mark {
- display: inline-block;
- width: 34px;
- height: 34px;
- border-radius: 9px;
- background: var(--teal-strong);
- margin-bottom: 0.6rem;
- }
- .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; }
-</style>
diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte
index a9e7799..b640189 100644
--- a/frontend/src/pages/ProjectBacktestTab.svelte
+++ b/frontend/src/pages/ProjectBacktestTab.svelte
@@ -17,6 +17,9 @@
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
+ import * as Select from '$lib/components/ui/select';
+ import { Input } from '$lib/components/ui/input';
+ import { Label } from '$lib/components/ui/label';
interface Props {
projectId: string;
@@ -189,8 +192,6 @@
}
}
-
-
async function onCancel() {
if (!latest) return;
cancelBusy = true;
@@ -223,79 +224,92 @@
const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled');
</script>
-<div class="stack">
- <Card.Root size="sm" class="stack-sec">
- <h2>发起回测</h2>
+<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} />
{#if !busyDatasets && datasetsReady.length === 0 && !cfgErr}
<Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" />
{/if}
{#if datasetsReady.length > 0}
- <div class="grid3 wrap-3">
- <label>
- 数据集
- <select bind:value={datasetId}>
- <option value="" disabled>选择数据集…</option>
- {#each datasetsReady as d (d.id)}
- <option value={d.id}>
- {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')}
- </option>
- {/each}
- </select>
- </label>
- <label>
- 起始资金
- <input class="num" type="number" bind:value={capital} min="1" step="1000" />
- </label>
- <div class="row2">
- <label>
- 佣金率
- <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" />
- </label>
- <label>
- 滑点率
- <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" />
- </label>
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+ <div>
+ <Label for="bt-dataset">数据集</Label>
+ <Select.Root
+ type="single"
+ value={datasetId}
+ onValueChange={(v) => (datasetId = v)}
+ >
+ <Select.Trigger data-testid="dataset-select" id="bt-dataset" class="w-full">
+ {datasetId
+ ? (() => {
+ const d = datasetsReady.find((x) => x.id === datasetId);
+ return d ? `${d.name ?? d.id.slice(0, 8)} · ${d.request.start_date}~${d.request.end_date}` : '选择数据集…';
+ })()
+ : '选择数据集…'}
+ </Select.Trigger>
+ <Select.Content>
+ {#each datasetsReady as d (d.id)}
+ <Select.Item value={d.id}>
+ {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')}
+ </Select.Item>
+ {/each}
+ </Select.Content>
+ </Select.Root>
+ </div>
+ <div>
+ <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>
+ <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" />
+ </div>
+ <div>
+ <Label for="bt-slippage">滑点率</Label>
+ <Input id="bt-slippage" class="tabular-nums" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" />
+ </div>
+ </div>
+ <div>
+ <Label for="bt-benchmark">基准代码(可选)</Label>
+ <Input id="bt-benchmark" bind:value={benchmark} placeholder="例如 000300.SH 指数代码" />
+ </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>
</div>
- <label>
- 基准代码(可选)
- <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" />
- </label>
- <label>
- 策略参数(JSON,可选)
- <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea>
- </label>
</div>
{#if needAck}
- <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
+ <Alert variant="default">
<span aria-hidden="true">⚠</span>
<div>
该数据集存在覆盖/预警信息,需要明确知晓后才能启动:
- <ul>
+ <ul class="list-disc pl-5 mt-1">
{#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each}
</ul>
- <label class="checkline">
+ <label class="inline-flex items-center gap-2 mt-1">
<input type="checkbox" bind:checked={acknowledged} />
我已知晓并用当前数据继续
</label>
</div>
</Alert>
{/if}
- <div class="row">
+ <div class="flex items-center gap-2 flex-wrap">
<Button onclick={startRun} disabled={!datasetId}>开始回测</Button>
{#if selectedDataset}
- <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
+ <span class="text-sm text-muted-foreground">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
{/if}
</div>
<Status busy={false} error={cfgErr} empty={actionMsg} />
{/if}
</Card.Root>
- <Card.Root size="sm" class="stack-sec">
- <div class="spread">
- <h2>最近一次回测</h2>
+ <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>
{#if latest}
- <span class="row">
+ <span class="flex items-center gap-2">
<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>
@@ -309,33 +323,33 @@
empty={null}
error={latestErr}
/>
- <div class="rows-grid">
+ <div class="overflow-x-auto">
<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><span class="tabular-nums">{latest.version_id.slice(0, 10)}</span></Table.Cell></Table.Row>
+ <Table.Row><Table.Head>数据集</Table.Head><Table.Cell><span class="tabular-nums">{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}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{latest.error}</span></Alert>
+ <Alert variant="destructive"><span aria-hidden="true">✕</span><span>{latest.error}</span></Alert>
{/if}
{#if latest.result?.warnings?.length}
- <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
+ <Alert variant="default">
<span aria-hidden="true">⚠</span>
- <ul class="plain-list">
+ <ul class="list-disc pl-5 mt-0.5">
{#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each}
</ul>
</Alert>
{/if}
{#if latest.status === 'succeeded' && latest.result}
- <Button variant="outline" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</Button>
+ <Button variant="outline" href={`#/projects/${projectId}/results?run=${latest.id}`} class="w-fit">查看完整结果与图表</Button>
{/if}
- <div class="row">
+ <div class="flex items-center gap-2 flex-wrap">
{#if canCancel}
<Button variant="destructive" onclick={onCancel} disabled={cancelBusy}>
{cancelBusy ? '取消中…' : '取消回测'}
@@ -352,11 +366,11 @@
{/if}
</Card.Root>
- <div class="stack">
- <h2>回测历史</h2>
+ <div class="flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">回测历史</h2>
<Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} />
{#if history && history.length > 0}
- <div class="px-3 py-2 rounded-[var(--radius-lg)] border border-border bg-card">
+ <Card.Root size="sm">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -373,7 +387,7 @@
<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 class="tabular-nums">{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>
@@ -381,7 +395,7 @@
{/each}
</Table.Body>
</Table.Root>
- </div>
+ </Card.Root>
{/if}
</div>
- </div>
+</div>
diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte
index 284aec7..7b9f8ea 100644
--- a/frontend/src/pages/ProjectDataTab.svelte
+++ b/frontend/src/pages/ProjectDataTab.svelte
@@ -59,7 +59,7 @@
}
</script>
-<div class="stack">
+<div class="flex flex-col gap-3">
{#if wizardOpen}
<DatasetWizard onCreated={() => void load()} />
<Button variant="ghost" size="sm" onclick={() => (wizardOpen = false)}>收起新建面板</Button>
diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte
index fe9be7f..61db874 100644
--- a/frontend/src/pages/ProjectPage.svelte
+++ b/frontend/src/pages/ProjectPage.svelte
@@ -24,12 +24,12 @@
});
</script>
-<nav class="crumbs" aria-label="面包屑">
- <a href="#/projects">我的项目</a>
+<nav class="flex items-center gap-1.5 py-1 pb-2 text-sm text-muted-foreground min-w-0" aria-label="面包屑">
+ <a class="text-muted-foreground hover:text-primary hover:no-underline" href="#/projects">我的项目</a>
<span aria-hidden="true">/</span>
- <span class="crumb-name">{projectName}</span>
+ <span class="font-semibold text-foreground truncate max-w-[45vw]">{projectName}</span>
<span aria-hidden="true">/</span>
- <span class="crumb-tab">{tabLabel}</span>
+ <span>{tabLabel}</span>
</nav>
<!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. -->
@@ -42,30 +42,3 @@
{:else if route.name === 'project-results'}
<ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} />
{/if}
-
-<style>
- /* 极简面包屑(桌面 + 移动端顶部共用) */
- .crumbs {
- display: flex;
- align-items: center;
- gap: 0.45rem;
- padding: 0.35rem 0 0.7rem;
- font-size: 0.82rem;
- color: var(--text-3);
- min-width: 0;
- }
- .crumbs a {
- color: var(--text-3);
- transition: color 150ms ease;
- }
- .crumbs a:hover { color: var(--primary); text-decoration: none; }
- .crumb-name {
- color: var(--foreground);
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 45vw;
- }
- .crumb-tab { color: var(--text-2); }
-</style>
diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte
index 8614cc0..e4269e3 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 Table from '$lib/components/ui/table';
import { projectsStore } from '../lib/listsStore.svelte';
import {
getProject,
@@ -185,7 +186,7 @@
try {
const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]);
comparing = {
- label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`,
+ label: `${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`,
before: b.code,
after: a.code
};
@@ -261,29 +262,29 @@
}
</script>
-<div class="stack">
+<div class="flex flex-col gap-3">
<Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} />
{#if !initBusy && !initErr}
- <Card.Root size="sm" class="stack">
- <div class="spread">
- <h2>策略代码(Backtrader Python)</h2>
- <span class="save-status" role="status">
+ <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">
{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="hint">
+ <p class="text-sm text-muted-foreground">
编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照
</p>
<CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} />
- <div class="spread wrap">
+ <div class="flex flex-wrap justify-between items-center gap-2">
<Input
bind:value={snapshotMessage}
placeholder="快照说明,例如:加入RSI过滤器(可选)"
- style="flex:2 1 240px"
+ class="flex-[2_1_240px] min-w-[200px]"
onkeydown={(ev) => {
if (ev.key === 'Enter') void snapshot();
}}
@@ -302,119 +303,118 @@
<Status busy={false} error={snapshotErr} empty={snapshotMsg} />
{#if conflictServer}
- <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]">
+ <Alert variant="default">
<span aria-hidden="true">⚠</span>
<span>
服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。
</span>
</Alert>
<DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" />
- <div class="row">
+ <div class="flex items-center gap-2 flex-wrap">
<Button onclick={keepMine}>保留本机修改(覆盖服务器)</Button>
<Button variant="outline" onclick={loadServer}>采用服务器版本</Button>
</div>
{/if}
</Card.Root>
- <div class="stack">
- <h2>AI 助手</h2>
- <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">
- <Button disabled={aiBusy || !instruction.trim()} onclick={askAI}>
- {aiBusy ? 'AI 处理中…' : '获取建议'}
- </Button>
- </div>
- {#if aiErr}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">
- <span aria-hidden="true">⚠</span>
- <span>{aiErr}</span>
- </Alert>
- {/if}
+ <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">
+ <Button class="w-fit" disabled={aiBusy || !instruction.trim()} onclick={askAI}>
+ {aiBusy ? 'AI 处理中…' : '获取建议'}
+ </Button>
+ </div>
+ {#if aiErr}
+ <Alert variant="destructive">
+ <span aria-hidden="true">⚠</span>
+ <span>{aiErr}</span>
+ </Alert>
+ {/if}
- {#if aiResult}
- <div class="stack">
- <div class="spread">
- <h3>建议解释 <span class="hint">{aiResult.model}</span></h3>
- <span class="hint">
- tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出
- </span>
- </div>
- <p>{aiResult.explanation}</p>
- <h3>完整建议源码</h3>
- <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} />
- <h3>与当前草稿的差异</h3>
- <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" />
- <div class="row">
- <Button
- disabled={aiAcceptBusy}
- onclick={acceptAI}
- title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''}
- >
- {aiAcceptBusy ? '接受中…' : '接受为新版本'}
- </Button>
- <Button variant="ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</Button>
- {#if aiResult.base_generation !== baseGeneration}
- <span class="text-[var(--warn)]">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
- {/if}
- {#if aiAcceptErr}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]">{aiAcceptErr}</Alert>
- {/if}
- </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">
+ tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出
+ </span>
</div>
- {/if}
- {#if aiNote}
- <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">{aiNote}</Alert>
- {/if}
- </Card.Root>
- </div>
+ <p class="m-0">{aiResult.explanation}</p>
+ <h3 class="text-base font-semibold">完整建议源码</h3>
+ <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} />
+ <h3 class="text-base font-semibold">与当前草稿的差异</h3>
+ <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" />
+ <div class="flex items-center gap-2 flex-wrap">
+ <Button
+ class="w-fit"
+ disabled={aiAcceptBusy}
+ onclick={acceptAI}
+ title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''}
+ >
+ {aiAcceptBusy ? '接受中…' : '接受为新版本'}
+ </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>
+ {/if}
+ {#if aiAcceptErr}
+ <Alert variant="destructive">{aiAcceptErr}</Alert>
+ {/if}
+ </div>
+ </div>
+ {/if}
+ {#if aiNote}
+ <Alert variant="default">{aiNote}</Alert>
+ {/if}
+ </Card.Root>
- <div class="stack">
- <h2>版本历史</h2>
+ <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}
<Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" />
{/if}
{#if !versionsBusy && versions.length > 0}
- <div class="px-3 py-2 rounded-[var(--radius-lg)] border border-border bg-card">
- <table class="data">
- <thead>
- <tr>
- <th>时间</th>
- <th>摘要</th>
- <th>哈希</th>
- <th>来源</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
+ <div class="overflow-x-auto rounded-lg border border-border">
+ <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 versions as v, i (v.id)}
- <tr>
- <td>{fmtDateTime(v.created_at)}</td>
- <td>
+ <Table.Row>
+ <Table.Cell>{fmtDateTime(v.created_at)}</Table.Cell>
+ <Table.Cell>
{v.message?.trim() || '(无说明)'}
- <span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span>
- </td>
- <td class="num">{v.hash?.slice(0, 12)}</td>
- <td><Badge variant="outline">{versionSourceLabel[v.source]}</Badge></td>
- <td>
- <div class="row">
+ <span class="text-sm 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>
+ <Table.Cell>
+ <div class="flex items-center gap-1 flex-wrap">
<Button variant="outline" size="sm" onclick={() => void compareWithDraft(v)}>对比当前</Button>
{#if i + 1 < versions.length}
<Button variant="outline" size="sm" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</Button>
{/if}
<Button size="sm" onclick={() => void restore(v)}>恢复为新草稿</Button>
</div>
- </td>
- </tr>
+ </Table.Cell>
+ </Table.Row>
{/each}
- </tbody>
- </table>
+ </Table.Body>
+ </Table.Root>
</div>
{/if}
- <p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p>
- </div>
+ <p class="text-sm text-muted-foreground">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p>
+ </Card.Root>
{#if comparing}
<Modal title={comparing.label} onClose={() => (comparing = null)}>
diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte
index d72f905..5cbfecf 100644
--- a/frontend/src/pages/ProjectsPage.svelte
+++ b/frontend/src/pages/ProjectsPage.svelte
@@ -82,34 +82,36 @@
}
</script>
-<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录">
+<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录" wide>
{#snippet actions()}
<Button variant={showCreate ? 'outline' : 'default'} onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}>
{showCreate ? '取消新建' : '新建项目'}
</Button>
{/snippet}
- <div class="stack">
+ <div class="flex flex-col gap-3">
<ProjectMetricRow projects={projectsStore.all} {datasets} {runs} {usage} />
<Status {busy} {error} busyText="加载项目列表…" empty={null} />
{#if showCreate}
- <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>
- <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" class="mt-1" />
- </div>
- <div>
- <Label for="pd">简介(可选)</Label>
- <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" />
- </div>
- {#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 ? '创建中…' : '创建项目'}
- </Button>
- </div>
- </form>
+ <Card.Root class="p-4">
+ <form class="flex flex-col gap-3" onsubmit={create}>
+ <h2 class="text-lg font-semibold">新建项目</h2>
+ <div>
+ <Label for="pn">项目名称</Label>
+ <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" class="mt-1" />
+ </div>
+ <div>
+ <Label for="pd">简介(可选)</Label>
+ <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" />
+ </div>
+ {#if createErr}<Alert variant="destructive" role="alert">{createErr}</Alert>{/if}
+ <div>
+ <Button type="submit" disabled={creating || !newName.trim()}>
+ {creating ? '创建中…' : '创建项目'}
+ </Button>
+ </div>
+ </form>
+ </Card.Root>
{/if}
{#if !busy && !error && projectsStore.all.length === 0}
@@ -133,7 +135,7 @@
{@const sp = sparks.get(p.id)}
<Table.Row>
<Table.Cell>
- <a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a>
+ <a href={`#/projects/${p.id}/data`} data-testid="project-link" class="font-semibold hover:underline">{p.name}</a>
</Table.Cell>
<Table.Cell>
{#if sp}
@@ -142,8 +144,8 @@
</a>
{/if}
</Table.Cell>
- <Table.Cell class="hint">{p.description ?? '—'}</Table.Cell>
- <Table.Cell class="num">{p.draft_generation ?? '—'}</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}
@@ -153,9 +155,3 @@
{/if}
</div>
</Page>
-
-<style>
- .p-name { font-weight: 600; color: var(--teal-strong); }
- .p-name:hover { text-decoration: underline; }
-
-</style>
diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte
index 911da2a..77626b5 100644
--- a/frontend/src/pages/RunsPage.svelte
+++ b/frontend/src/pages/RunsPage.svelte
@@ -6,7 +6,7 @@
import { listRuns } from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format';
-import { capitalReporting } from '../lib/capitalMetrics';
+ 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';
@@ -103,7 +103,7 @@ import { capitalReporting } from '../lib/capitalMetrics';
</script>
<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide>
- <div class="stack">
+ <div class="flex flex-col gap-3">
<Status busy={busy} error={error} busyText="正在加载实验…" empty={null} />
{#if !busy && !error && runs.length === 0}
@@ -111,7 +111,7 @@ import { capitalReporting } from '../lib/capitalMetrics';
{/if}
{#if !busy && !error && runs.length > 0}
- <Card.Root size="sm" class="px-3 py-2 [&>*:[data-slot=card-content]]:p-0">
+ <Card.Root size="sm" class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -140,8 +140,8 @@ import { capitalReporting } from '../lib/capitalMetrics';
</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 class="tabular-nums">{r.project_id?.slice(0, 8)}…</Table.Cell>
+ <Table.Cell class="tabular-nums">{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>
@@ -152,12 +152,12 @@ import { capitalReporting } from '../lib/capitalMetrics';
{/each}
</Table.Body>
</Table.Root>
- <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p>
+ <p class="text-sm text-muted-foreground p-2">最多同时选中 4 个已完成的回测用于比较。</p>
</Card.Root>
{#if selected.length > 0}
- <Card.Root size="sm" class="stack">
- <h2>条件差异</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3 overflow-x-auto">
+ <h2 class="text-lg font-semibold">条件差异</h2>
<Table.Root>
<Table.Header>
<Table.Row>
@@ -167,22 +167,22 @@ import { capitalReporting } from '../lib/capitalMetrics';
</Table.Header>
<Table.Body>
{#each configDiffRows as row, i (i)}
- <Table.Row class={row.differing ? 'diffrow' : ''}>
+ <Table.Row class={row.differing ? 'bg-primary/5' : ''}>
<Table.Head>{row.label}</Table.Head>
{#each row.vals as v, j (j)}
- <Table.Cell class={row.differing ? 'num-diff num' : 'num'}>{v}</Table.Cell>
+ <Table.Cell class="tabular-nums">{v}</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
- </Card.Root>
+ </Card.Root>
{#if comparable.length > 1}
- <Card.Root size="sm" class="stack">
- <h2>收益曲线对齐比较(首日归一)</h2>
+ <Card.Root size="sm" class="p-4 flex flex-col gap-3">
+ <h2 class="text-lg font-semibold">收益曲线对齐比较(首日归一)</h2>
<Chart data={compare.data} series={compare.series} ariaLabel="多回测对比曲线" height={340} />
- <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
+ <p class="text-sm 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 cfd69ce..018b4a0 100644
--- a/frontend/src/pages/UsagePage.svelte
+++ b/frontend/src/pages/UsagePage.svelte
@@ -32,29 +32,29 @@
</script>
<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费">
- <div class="stack">
+ <div class="flex flex-col gap-3">
<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.Content>
- <div class="row" style="gap:0.4rem 0.8rem">
+ <div class="flex flex-wrap gap-x-3 gap-y-1">
<div>
- <div class="mlabel">模型请求</div>
- <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div>
+ <div class="text-xs text-muted-foreground">模型请求</div>
+ <div class="text-2xl font-bold tabular-nums text-foreground">{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 class="text-xs text-muted-foreground">输入 tokens</div>
+ <div class="text-2xl font-bold tabular-nums text-foreground">{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 class="text-xs text-muted-foreground">输出 tokens</div>
+ <div class="text-2xl font-bold tabular-nums text-foreground">{usage.totals?.output_tokens ?? '—'}</div>
</div>
</div>
{#if usage.internal_poc}
- <p class="hint mt-1">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p>
+ <p class="text-sm text-muted-foreground mt-1">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p>
{/if}
</Card.Content>
</Card.Root>
@@ -76,8 +76,8 @@
<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 class="tabular-nums">{it.input_tokens ?? '—'}</Table.Cell>
+ <Table.Cell class="tabular-nums">{it.output_tokens ?? '—'}</Table.Cell>
<Table.Cell>
<Badge variant={it.status === 'succeeded' ? 'secondary' : 'outline'}>{it.status ?? '—'}</Badge>
</Table.Cell>