diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
| commit | a7aab01f051487327e5ce52b6a8905208c546e8c (patch) | |
| tree | 630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src/pages | |
| parent | 375584174fb7191a57c5423fea45924ad6d812d5 (diff) | |
| download | strategy-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.svelte | 24 | ||||
| -rw-r--r-- | frontend/src/pages/AdminPage.svelte | 74 | ||||
| -rw-r--r-- | frontend/src/pages/AuthPages.svelte | 154 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectBacktestTab.svelte | 136 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectDataTab.svelte | 2 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectPage.svelte | 35 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectStrategyTab.svelte | 182 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectsPage.svelte | 52 | ||||
| -rw-r--r-- | frontend/src/pages/RunsPage.svelte | 28 | ||||
| -rw-r--r-- | frontend/src/pages/UsagePage.svelte | 22 |
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> |
