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