summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
commita7aab01f051487327e5ce52b6a8905208c546e8c (patch)
tree630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src
parent375584174fb7191a57c5423fea45924ad6d812d5 (diff)
downloadstrategy-lab-a7aab01f051487327e5ce52b6a8905208c546e8c.tar.gz
[变更性质] feat(frontend)
[新增功能] 12 页面组件全量 shadcn:Card/Button/Table/Tabs/Select/Alert;375 底部 Tab Bar 改用 Tabs 组件实现 [实现方案] 页面级手写 class 全部移除(.btn/.banner/table.data/.badge/.tabs/.metric-row/.m-tile/.hint 等改为 shadcn + tailwind 工具类);Modal/DatasetWizard/DatasetCard/DiffView/CodeEditor/Status/Layout 同步 [影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/CodeEditor.svelte27
-rw-r--r--frontend/src/components/DatasetCard.svelte60
-rw-r--r--frontend/src/components/DatasetWizard.svelte223
-rw-r--r--frontend/src/components/DiffView.svelte41
-rw-r--r--frontend/src/components/Layout.svelte344
-rw-r--r--frontend/src/components/Loading.svelte28
-rw-r--r--frontend/src/components/Modal.svelte33
-rw-r--r--frontend/src/components/Page.svelte22
-rw-r--r--frontend/src/components/ProjectMetricRow.svelte58
-rw-r--r--frontend/src/components/Status.svelte60
-rw-r--r--frontend/src/pages/AccountPage.svelte24
-rw-r--r--frontend/src/pages/AdminPage.svelte74
-rw-r--r--frontend/src/pages/AuthPages.svelte154
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte136
-rw-r--r--frontend/src/pages/ProjectDataTab.svelte2
-rw-r--r--frontend/src/pages/ProjectPage.svelte35
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte182
-rw-r--r--frontend/src/pages/ProjectsPage.svelte52
-rw-r--r--frontend/src/pages/RunsPage.svelte28
-rw-r--r--frontend/src/pages/UsagePage.svelte22
20 files changed, 606 insertions, 999 deletions
diff --git a/frontend/src/components/CodeEditor.svelte b/frontend/src/components/CodeEditor.svelte
index 544a6a4..da11555 100644
--- a/frontend/src/components/CodeEditor.svelte
+++ b/frontend/src/components/CodeEditor.svelte
@@ -15,6 +15,7 @@
}
let { value, onInput, height = 420, readOnly = false, label }: Props = $props();
+ const hostClass = 'rounded-md border border-border overflow-hidden bg-card [&>.cm-editor]:min-h-[60px]';
let host: HTMLDivElement;
let view: EditorView | null = null;
let applyingExternal = false;
@@ -41,8 +42,8 @@
EditorView.theme({
'&': {
fontSize: '13px',
- background: 'var(--surface)',
- color: 'var(--text)',
+ background: 'var(--card)',
+ color: 'var(--foreground)',
fontFamily:
"ui-monospace, 'SF Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace"
},
@@ -50,12 +51,12 @@
'.cm-scroller': { lineHeight: '1.55' },
'&.cm-focused': { outline: 'none' },
'.cm-gutters': {
- background: 'var(--surface)',
+ background: 'var(--card)',
border: 'none',
- color: 'var(--text-3)'
+ color: 'var(--muted-foreground)'
},
- '.cm-activeLine': { background: 'var(--surface-2)' },
- '.cm-activeLineGutter': { background: 'var(--surface-2)' }
+ '.cm-activeLine': { background: 'var(--secondary)' },
+ '.cm-activeLineGutter': { background: 'var(--secondary)' }
}),
EditorView.updateListener.of((u) => {
if (applyingExternal) return;
@@ -88,17 +89,7 @@
</script>
<div>
- {#if label}<span class="hint editor-label">{label}</span>{/if}
- <div bind:this={host} class="cm-host"></div>
+ {#if label}<span class="block text-sm text-muted-foreground mb-1">{label}</span>{/if}
+ <div bind:this={host} class={hostClass}></div>
</div>
-<style>
- .cm-host {
- border: 1px solid var(--border-strong);
- border-radius: var(--radius);
- overflow: hidden;
- background: var(--surface);
- }
- .cm-host :global(.cm-editor) { min-height: 60px; }
- .editor-label { display: block; margin-bottom: 0.25rem; }
-</style>
diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte
index 69083fb..266c9a4 100644
--- a/frontend/src/components/DatasetCard.svelte
+++ b/frontend/src/components/DatasetCard.svelte
@@ -119,50 +119,49 @@
<Card.Root>
{#if ds}
- <div class="spread">
+ <div class="flex flex-wrap justify-between items-center gap-2">
<div class="min-w-0">
- <strong>{ds.name ?? '未命名数据集'}</strong>
- {#if ds.manifest_hash}<span class="hint mono">manifest {ds.manifest_hash.slice(0, 10)}…</span>{/if}
+ <strong class="font-semibold">{ds.name ?? '未命名数据集'}</strong>
+ {#if ds.manifest_hash}<span class="text-sm text-muted-foreground ml-2 font-mono">manifest {ds.manifest_hash.slice(0, 10)}…</span>{/if}
{#if ds.cache_hit}<Badge variant="secondary">缓存命中 · 复用现有物化对象</Badge>{/if}
</div>
- <Badge variant={ds.status === 'ready' ? 'secondary' : ds.status === 'failed' ? 'destructive' : 'outline'}
- class={ds.status === 'ready' ? 'badge ok' : ds.status === 'failed' ? 'badge danger' : 'badge warn'}>
+ <Badge variant={ds.status === 'ready' ? 'secondary' : ds.status === 'failed' ? 'destructive' : 'outline'}>
{datasetStatusLabel[ds.status]}
</Badge>
</div>
- <div class="table-wrap">
+ <div class="overflow-x-auto min-w-0">
<Table.Root>
<Table.Body>
<Table.Row>
- <Table.Cell class="k">请求标的</Table.Cell>
+ <Table.Cell class="whitespace-nowrap text-muted-foreground font-medium w-24">请求标的</Table.Cell>
<Table.Cell>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</Table.Cell>
</Table.Row>
<Table.Row>
- <Table.Cell class="k">范围</Table.Cell>
+ <Table.Cell class="whitespace-nowrap text-muted-foreground font-medium w-24">范围</Table.Cell>
<Table.Cell>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</Table.Cell>
</Table.Row>
<Table.Row>
- <Table.Cell class="k">字段</Table.Cell>
+ <Table.Cell class="whitespace-nowrap text-muted-foreground font-medium w-24">字段</Table.Cell>
<Table.Cell>{(ds.request.fields ?? []).join(', ')}</Table.Cell>
</Table.Row>
<Table.Row>
- <Table.Cell class="k">创建时间</Table.Cell>
+ <Table.Cell class="whitespace-nowrap text-muted-foreground font-medium w-24">创建时间</Table.Cell>
<Table.Cell>{fmtDate(ds.created_at)}</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</div>
{#if ds.status === 'failed'}
- <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"
+ <Alert variant="destructive"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></Alert>
+ <div class="flex flex-wrap items-center gap-2 min-w-0 mt-1">
+ <Button variant="outline" size="sm" class="w-fit"
disabled={recreateBusy || recreatedId !== null}
onclick={() => void recreateFromRequest()}
>
{recreateBusy ? '重新获取中…' : '使用相同请求重新获取'}
</Button>
{#if recreatedId}
- <span class="hint">
+ <span class="text-sm text-muted-foreground break-all">
已用原请求创建新数据集(编号 {recreatedId.slice(0, 8)}…),新数据集出现在下方列表并自动跟踪进度;本条失败记录原样保留。
</span>
{/if}
@@ -176,22 +175,22 @@
</Button>
</div>
{#if showPreview}
- <div class="preview-box">
+ <div class="mt-2 flex flex-col gap-2">
<Status busy={previewBusy} busyText="加载预览…" error={previewError} empty={null} />
{#if preview}
{@const cov = summarizeCoverage(preview.coverage ?? [])}
{#if cov.warnings.length > 0}
- <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">
+ <Alert variant="default"><span aria-hidden="true">⚠</span><div>
+ <div class="font-semibold">覆盖差异(共有区间 {cov.common.start ?? '—'} ~ {cov.common.end ?? '—'}):</div>
+ <ul class="list-disc pl-5 mt-1">
{#each cov.warnings as w, i (i)}<li>{w}</li>{/each}
</ul>
</div></Alert>
{/if}
{#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)}
- <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">
+ <Alert variant="default" role="alert"><span aria-hidden="true">⚠</span><div>
+ <div class="font-semibold">来源与口径说明:</div>
+ <ul class="list-disc pl-5 mt-1">
{#each preview.coverage.filter((c) => (c.warnings ?? []).length > 0) as c (c.instrument)}
<li>{c.instrument}:{(c.warnings ?? []).join(';')}</li>
{/each}
@@ -199,12 +198,12 @@
</div></Alert>
{/if}
{#if cov.common.start && cov.warnings.length === 0}
- <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
+ <Alert variant="default">
<span aria-hidden="true">✓</span>
<span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span>
</Alert>
{/if}
- <div class="scrollx">
+ <div class="overflow-x-auto min-w-0">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -214,14 +213,14 @@
<Table.Body>
{#each preview.rows as row, i (i)}
<Table.Row>
- {#each preview.columns as c (c)}<Table.Cell class="num">{row[c]}</Table.Cell>{/each}
+ {#each preview.columns as c (c)}<Table.Cell class="tabular-nums">{row[c]}</Table.Cell>{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if preview.truncated}
- <p class="hint">预览仅展示部分行</p>
+ <p class="text-sm text-muted-foreground">预览仅展示部分行</p>
{/if}
{/if}
</div>
@@ -231,14 +230,3 @@
<Status busy={busy} error={error} busyText="载入数据集…" empty={null} />
{/if}
</Card.Root>
-
-<style>
- .table-wrap :global(td:first-child) { width: 92px; min-width: 92px; color: var(--text-2); font-weight: 500; }
- .mono { font-family: var(--mono); margin-left: 0.5rem; }
- .preview-box { margin-top: 0.5rem; display: grid; gap: 0.5rem; }
- .scrollx { display: block; overflow-x: auto; min-width: 0; }
- .cov-caption { font-weight: 600; }
- .cov-list { margin: 0.2rem 0 0 1.1rem; padding: 0; }
- .retry-row { margin-top: 0.4rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; min-width: 0; }
- .retry-row .hint { min-width: 0; overflow-wrap: anywhere; }
-</style>
diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte
index d9e923a..e6ec105 100644
--- a/frontend/src/components/DatasetWizard.svelte
+++ b/frontend/src/components/DatasetWizard.svelte
@@ -3,6 +3,11 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Alert } from '$lib/components/ui/alert';
+ import { Input } from '$lib/components/ui/input';
+ import { Label } from '$lib/components/ui/label';
+ import * as Card from '$lib/components/ui/card';
+ import * as Select from '$lib/components/ui/select';
+ import * as Table from '$lib/components/ui/table';
import { getCapabilities, searchInstruments, createDataset } from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format';
@@ -180,17 +185,16 @@
}
</script>
-<div class="card stack">
+<Card.Root class="p-4 flex flex-col gap-3">
<h2>新建数据集</h2>
<Status busy={!caps && !loadErr} busyText="加载能力配置…" error={loadErr} empty={null} />
{#if caps}
- <section class="stack">
- <h3 class="step-h">1) 选择标的 <span class="hint">最多 {maxSymbols} 个 · 日频 POC</span></h3>
- <div class="row">
- <input
- style="flex:2 1 200px"
+ <section class="flex flex-col gap-2">
+ <h3 class="text-base font-semibold m-0">1) 选择标的 <span class="text-sm text-muted-foreground">最多 {maxSymbols} 个 · 日频 POC</span></h3>
+ <div class="flex items-center gap-2">
+ <Input class="flex-[2_1_200px]"
placeholder="搜索代码或名称,如 600000、沪深300ETF"
bind:value={query}
oninput={onSearchInput}
@@ -208,75 +212,83 @@
<Button variant="outline" size="sm" onclick={() => doSearch()}>重试</Button>
{/if}
{#if searchItems.length > 0}
- <table class="data">
- <thead>
- <tr><th>代码</th><th>类型</th><th>名称</th><th>来源</th><th></th></tr>
- </thead>
- <tbody>
- {#each searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))}
- <tr>
- <td class="num">{inst.market}.<strong>{inst.symbol}</strong></td>
- <td>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</td>
- <td>{inst.name ?? '—'}</td>
- <td class="hint">{itemSourceLabel(inst.source, searchSource)}</td>
- <td>
- <Button variant="outline" size="sm" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}>
- {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'}
- </Button>
- </td>
- </tr>
- {/each}
- </tbody>
- </table>
+ <div class="overflow-x-auto">
+ <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 searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))}
+ <Table.Row>
+ <Table.Cell class="tabular-nums">{inst.market}.<strong>{inst.symbol}</strong></Table.Cell>
+ <Table.Cell>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</Table.Cell>
+ <Table.Cell>{inst.name ?? '—'}</Table.Cell>
+ <Table.Cell class="text-sm text-muted-foreground">{itemSourceLabel(inst.source, searchSource)}</Table.Cell>
+ <Table.Cell>
+ <Button variant="outline" size="sm" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}>
+ {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'}
+ </Button>
+ </Table.Cell>
+ </Table.Row>
+ {/each}
+ </Table.Body>
+ </Table.Root>
+ </div>
{/if}
<details>
<summary>手动录入标的(目录未收录时)</summary>
- <div class="card stack manual">
- <p class="hint">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p>
- <div class="row wrap">
- <label style="flex:1 1 130px">
- 代码
- <input bind:value={symbol} placeholder="600000 / 510300 / 000300" />
- </label>
- <label style="flex:0 0 110px">
- 市场
- <select bind:value={market}>
- {#each ['SH', 'SZ', 'BJ'] as m (m)}
- <option value={m}>{m}</option>
- {/each}
- </select>
- </label>
- <label style="flex:0 0 120px">
- 类型
- <select bind:value={assetType}>
- {#each caps.asset_types as t (t)}
- <option value={t}>{assetTypeLabel[t] ?? t}</option>
- {/each}
- </select>
- </label>
- <label style="flex:1 1 160px">
- 名称(可选)
- <input bind:value={manualName} placeholder="与供应商页面一致" />
- </label>
+ <Card.Root class="p-3 flex flex-col gap-2 my-1">
+ <p class="text-sm text-muted-foreground">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p>
+ <div class="flex flex-wrap items-end gap-2">
+ <div class="flex-1 min-w-[130px]">
+ <Label for="manual-symbol">代码</Label>
+ <Input id="manual-symbol" bind:value={symbol} placeholder="600000 / 510300 / 000300" />
+ </div>
+ <div class="w-[110px]">
+ <Label for="manual-market">市场</Label>
+ <Select.Root type="single" value={market} onValueChange={(v) => (market = v)}>
+ <Select.Trigger id="manual-market">{market}</Select.Trigger>
+ <Select.Content>
+ {#each ['SH', 'SZ', 'BJ'] as m (m)}
+ <Select.Item value={m}>{m}</Select.Item>
+ {/each}
+ </Select.Content>
+ </Select.Root>
+ </div>
+ <div class="w-[120px]">
+ <Label for="manual-atype">类型</Label>
+ <Select.Root type="single" value={assetType} onValueChange={(v) => (assetType = v)}>
+ <Select.Trigger id="manual-atype">{assetTypeLabel[assetType] ?? assetType}</Select.Trigger>
+ <Select.Content>
+ {#each caps.asset_types as t (t)}
+ <Select.Item value={t}>{assetTypeLabel[t] ?? t}</Select.Item>
+ {/each}
+ </Select.Content>
+ </Select.Root>
+ </div>
+ <div class="flex-1 min-w-[160px]">
+ <Label for="manual-name">名称(可选)</Label>
+ <Input id="manual-name" bind:value={manualName} placeholder="与供应商页面一致" />
+ </div>
</div>
- <div class="row">
- <Button onclick={addManual}>加入所选</Button>
+ <div class="flex items-center gap-2">
+ <Button class="w-fit" onclick={addManual}>加入所选</Button>
</div>
- </div>
+ </Card.Root>
</details>
</section>
<section>
- <h3 class="step-h">2) 已选标的 <span class="hint">{selected.length}/{maxSymbols}</span></h3>
+ <h3 class="text-base font-semibold m-0">2) 已选标的 <span class="text-sm text-muted-foreground">{selected.length}/{maxSymbols}</span></h3>
{#if selected.length === 0}
- <p class="hint">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p>
+ <p class="text-sm text-muted-foreground">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p>
{:else}
- <ul class="chip-list">
+ <ul class="flex flex-wrap gap-1.5 p-0 m-0 list-none">
{#each selected as s, i (s.market + '.' + s.symbol + s.asset_type)}
- <li>
- <span class="num">{s.market}.{s.symbol}</span>
- {#if s.name}<span class="hint">{s.name}</span>{/if}
+ <li class="inline-flex items-center gap-1 border border-border rounded-full px-2 py-0.5 bg-secondary">
+ <span class="tabular-nums">{s.market}.{s.symbol}</span>
+ {#if s.name}<span class="text-sm text-muted-foreground">{s.name}</span>{/if}
<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>
@@ -285,67 +297,72 @@
{/if}
</section>
- <section class="grid3 wrap-3">
- <label>
- 起始日期
- <input type="date" bind:value={startDate} />
- </label>
- <label>
- 结束日期
- <input type="date" bind:value={endDate} />
- </label>
- <label style="min-width:160px">
- 复权
- <select bind:value={adjustment}>
- {#each caps.adjustments as a (a.code)}
- <option value={a.code}>{a.label}</option>
- {/each}
- </select>
+ <section class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-start">
+ <div>
+ <Label for="ds-start">起始日期</Label>
+ <Input id="ds-start" type="date" bind:value={startDate} />
+ </div>
+ <div>
+ <Label for="ds-end">结束日期</Label>
+ <Input id="ds-end" type="date" bind:value={endDate} />
+ </div>
+ <div class="min-w-40">
+ <Label for="ds-adj">复权</Label>
+ <Select.Root type="single" value={adjustment} onValueChange={(v) => (adjustment = v as typeof adjustment)}>
+ <Select.Trigger id="ds-adj" class="w-full">
+ {caps.adjustments.find((a) => a.code === adjustment)?.label ?? adjustment}
+ </Select.Trigger>
+ <Select.Content>
+ {#each caps.adjustments as a (a.code)}
+ <Select.Item value={a.code}>{a.label}</Select.Item>
+ {/each}
+ </Select.Content>
+ </Select.Root>
{#if adjustment !== 'none'}
- <p class="hint">{adjustmentLabel[adjustment]}:以复权口径返回</p>
+ <p class="text-sm text-muted-foreground">{adjustmentLabel[adjustment]}:以复权口径返回</p>
{/if}
- <p class="hint">日频 · 周期上限 {maxYears} 年</p>
- </label>
+ <p class="text-sm text-muted-foreground">日频 · 周期上限 {maxYears} 年</p>
+ </div>
</section>
<section>
- <label for="ds-name">数据集名称(可选,留空自动生成)</label>
- <input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" />
+ <Label for="ds-name">数据集名称(可选,留空自动生成)</Label>
+ <Input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" />
</section>
<details>
<summary>附加原始字段(可选)</summary>
- <p class="hint">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p>
- <ul class="field-list">
+ <p class="text-sm text-muted-foreground">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p>
+ <ul class="grid gap-1 p-0 m-0 list-none grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
{#each rawFieldChoices as f (f.code)}
<li>
- <label class="check">
+ <label class="flex items-center gap-1.5 text-sm m-0">
<input type="checkbox" checked={chosenRaw.includes(f.code)} onchange={() => toggleRawField(f.code)} />
- {f.label} <span class="hint">(未请求不返回)</span>
+ {f.label} <span class="text-sm text-muted-foreground">(未请求不返回)</span>
</label>
</li>
{/each}
</ul>
{#if caps.fields.some((f) => !f.raw)}
- <p class="hint">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p>
+ <p class="text-sm text-muted-foreground">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p>
{/if}
</details>
{#if formErr.length > 0}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]" role="alert">
+ <Alert variant="destructive" role="alert">
<span aria-hidden="true">⚠</span>
- <ul class="err-list">
+ <ul class="list-disc pl-5 m-0">
{#each formErr as msg, i (i)}<li>{msg}</li>{/each}
</ul>
</Alert>
{/if}
{#if submitErr}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]" role="alert">
+ <Alert variant="destructive" role="alert">
<span aria-hidden="true">⚠</span>
<span>数据请求提交失败:{submitErr}</span>
</Alert>
{/if}
- <p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p>
+ <p class="text-sm text-muted-foreground">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p>
<div>
<Button disabled={submitting || selected.length === 0} onclick={submit}>
{submitting ? '提交中…' : '提交数据请求'}
@@ -354,24 +371,4 @@
{/if}
<Status busy={false} error={null} empty={formOk} />
-</div>
-
-<style>
- .manual { padding: 0.8rem; margin: 0.4rem 0; }
- .manual input, .manual select { margin-top: 0.25rem; }
- .chip-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; }
- .chip-list li {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- border: 1px solid var(--border-strong);
- padding: 0.25rem 0.5rem;
- border-radius: 999px;
- background: var(--surface-2);
- }
- .field-list { list-style: none; padding: 0; margin: 0.3rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.2rem; }
- .check { font-size: 0.88rem; margin: 0; display: flex; gap: 0.35rem; align-items: center; }
- .step-h { font-size: 0.95rem; }
- .err-list { padding-left: 1.1rem; margin: 0; list-style: disc; }
- .wrap-3 { align-items: start; }
-</style>
+</Card.Root>
diff --git a/frontend/src/components/DiffView.svelte b/frontend/src/components/DiffView.svelte
index cf0c1be..131d2e3 100644
--- a/frontend/src/components/DiffView.svelte
+++ b/frontend/src/components/DiffView.svelte
@@ -26,46 +26,21 @@
});
</script>
-<div class="diff">
- <div class="legend">
- <span><i class="sw a"></i>新增({afterLabel})</span>
- <span><i class="sw d"></i>删除({beforeLabel})</span>
+<div>
+ <div class="text-muted-foreground text-sm mb-1 flex gap-4">
+ <span><i class="inline-block size-2.5 rounded-sm mr-1 bg-emerald-200" aria-hidden="true"></i>新增({afterLabel})</span>
+ <span><i class="inline-block size-2.5 rounded-sm mr-1 bg-red-200" aria-hidden="true"></i>删除({beforeLabel})</span>
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
- <div class="body" style="max-height:{maxHeight}px" tabindex="0" aria-label="代码差异" role="region">
+ <div class="overflow-auto bg-card border border-border rounded-md py-2 px-3" style:max-height="{maxHeight}px" tabindex="0" aria-label="代码差异" role="region">
{#each lines as l, i (i)}
{#if l.t === 'a'}
- <span class="ln add" aria-hidden="true"></span><span class="code add">+{l.text}</span>
+ <span class="select-none" aria-hidden="true"></span><span class="font-mono text-xs inline-block w-full whitespace-pre-wrap break-all bg-emerald-50 text-emerald-900">+{l.text}</span>
{:else if l.t === 'd'}
- <span class="ln del" aria-hidden="true"></span><span class="code del">−{l.text}</span>
+ <span class="select-none" aria-hidden="true"></span><span class="font-mono text-xs inline-block w-full whitespace-pre-wrap break-all bg-red-50 text-red-900">−{l.text}</span>
{:else}
- <span class="ln" aria-hidden="true"> </span><span class="code">{l.text}</span>
+ <span class="select-none" aria-hidden="true"> </span><span class="font-mono text-xs inline-block w-full whitespace-pre-wrap break-all">{l.text}</span>
{/if}
{/each}
</div>
</div>
-
-<style>
- .diff .legend {
- color: var(--text-3);
- font-size: 0.8rem;
- margin-bottom: 0.4rem;
- display: flex;
- gap: 1rem;
- }
- .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 4px; }
- .sw.a { background: #c7f0e6; }
- .sw.d { background: #fbd4d0; }
- .body {
- overflow: auto;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0.5rem 0.7rem;
- }
- .body .code { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-all; display: inline-block; width: 100%; }
- .add { background: #eafaf5; color: #14532d; }
- .del { background: #fdf0ee; color: #7f1d1d; }
- .ln { user-select: none; }
- .code.add::before, .code.del::before { opacity: 0.6; }
-</style>
diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte
index ddd5090..18a79a2 100644
--- a/frontend/src/components/Layout.svelte
+++ b/frontend/src/components/Layout.svelte
@@ -5,6 +5,8 @@
import { postLogout } from '../lib/client';
import { projectsStore, projectOf, projectInvisible } from '../lib/listsStore.svelte';
import { Button } from '$lib/components/ui/button';
+ import * as Tabs from '$lib/components/ui/tabs';
+ import { Separator } from '$lib/components/ui/separator';
interface Props {
route: Route;
@@ -55,9 +57,7 @@
if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined);
});
- // ---- 移动端导航重设计(375 → 底部 Tab Bar)----
- // 小型 matchMedia 反应式:jsdom 没有布局,真实渲染断言
- // (mobileTabbar.test.ts)通过 matchMedia stub 驱动 phone/桌面两种形态。
+ // ---- 移动端导航(≤767px 底部 Tab Bar,shadcn Tabs 组件实现)----
import { onDestroy } from 'svelte';
let phoneMode = $state(false);
let mqListener: ((e: MediaQueryListEvent) => void) | null = null;
@@ -90,301 +90,109 @@
const activeTab = $derived(
isProject || route.name === 'admin' ? 'projects' : route.name === 'account' ? 'account' : route.name
);
+
+ const navLinkBase = 'block rounded-md py-1.5 px-2 text-sm no-underline hover:text-foreground';
</script>
-<div class="layout">
+<div class="grid min-h-screen grid-cols-1 md:grid-cols-[208px_minmax(0,1fr)]">
{#if mobile}
- <header class="topbar" data-testid="mobile-topbar">
- <span class="topbar-brand">策研 <em>Strategy Lab</em></span>
+ <header
+ class="sticky top-0 z-30 flex items-center gap-2 min-h-10 px-2.5 border-b border-border bg-sidebar/90 backdrop-blur font-sm text-[0.85rem]"
+ data-testid="mobile-topbar"
+ >
+ <span class="font-bold text-foreground whitespace-nowrap">策研 <em class="not-italic font-medium text-[0.62rem] text-muted-foreground block">Strategy Lab</em></span>
{#if isProject}
- <span class="topbar-crumb" aria-label="当前项目">{activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''}</span>
+ <span class="flex-1 min-w-0 truncate text-center font-semibold text-foreground" aria-label="当前项目">{activeProject?.name ?? '项目'} · {projectNav.find(([t]) => t === projectTab)?.[1] ?? ''}</span>
{/if}
- <button class="topbar-me" onclick={() => go('/account')} aria-label="账户与安全">
+ <Button variant="ghost" size="icon-sm" onclick={() => go('/account')} aria-label="账户与安全">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
- </button>
+ </Button>
</header>
{/if}
- <aside class="sidebar" aria-label="全局导航">
- <a href="#/projects" class="brand">
- <span class="brand-mark" aria-hidden="true"></span>
- <span class="brand-text">策研 <span class="brand-sub">Strategy Lab</span></span>
+ <aside
+ class="bg-sidebar border-b md:border-b-0 md:border-r border-border md:sticky md:top-0 md:overflow-y-auto md:h-screen max-md:hidden flex flex-row md:flex-col flex-wrap md:flex-nowrap items-center md:items-stretch gap-1 md:gap-2 px-2 md:px-3 py-2 md:py-3"
+ aria-label="全局导航"
+ >
+ <a href="#/projects" class="flex items-center gap-1.5 py-1 px-1 pb-1 border-b border-border mb-1 no-underline w-full text-foreground">
+ <span class="size-5 rounded-lg bg-primary shrink-0" aria-hidden="true"></span>
+ <span class="font-bold text-[0.98rem] tracking-tight">策研 <span class="text-muted-foreground/70 font-medium text-[0.68rem] block">Strategy Lab</span></span>
</a>
{#if isProject}
- <div class="projctx">
- <div class="nav-title">当前项目</div>
- <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div>
+ <div class="flex flex-wrap md:block gap-0.5 w-full min-w-0">
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">当前项目</div>
+ <div class="font-semibold px-1.5 pt-0.5 pb-1 text-[0.9rem] break-all text-foreground md:max-md:w-full">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div>
{#each projectNav as [t, label] (t)}
<a
- class="navlink"
+ class={`${navLinkBase} text-[0.85rem] ${projectTab === t ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`}
href={`#/projects/${route.params.id}/${t}`}
aria-current={projectTab === t ? 'page' : undefined}
>{label}</a>
{/each}
- <div class="divider" aria-hidden="true"></div>
+ <Separator class="hidden md:block mx-2 my-2" />
</div>
{/if}
- <div class="nav-title">工作台</div>
- {#each topLeft as item (item.name)}
- <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
- {item.label}
- </a>
- {/each}
-
- <div class="push"></div>
- <div class="nav-title">账户</div>
- {#each bottom as item (item.name)}
- <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
- {item.label}
- </a>
- {/each}
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">工作台</div>
+ <div class="flex flex-wrap md:flex-col gap-0.5">
+ {#each topLeft as item (item.name)}
+ <a class={`${navLinkBase} ${route.name === item.name ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`} href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+ </div>
+
+ <div class="flex-1 md:min-h-4"></div>
+ <div class="hidden md:block text-[0.68rem] uppercase tracking-widest text-muted-foreground px-1 pt-1 pb-0.5 font-bold">账户</div>
+ <div class="flex flex-wrap md:flex-col gap-0.5">
+ {#each bottom as item (item.name)}
+ <a class={`${navLinkBase} ${route.name === item.name ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-secondary'}`} href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+ </div>
{#if session.me}
- <div class="me">
- <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span>
+ <div class="flex items-center justify-between gap-1 p-1 bg-secondary rounded-md text-[0.84rem] w-full">
+ <span class="truncate text-muted-foreground min-w-0">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span>
<Button variant="ghost" size="sm" onclick={() => logout()}>退出</Button>
</div>
{/if}
</aside>
- <main class="content" aria-label="主内容">
+ <main class="p-4 md:px-8 md:pt-6 max-w-[1360px] w-full min-w-0 pb-16 md:pb-12" aria-label="主内容">
{@render children?.()}
</main>
</div>
{#if mobile}
- <nav class="tabbar" data-testid="mobile-tabbar" aria-label="底部导航">
- {#each tabs as t (t.name)}
- <a class="item" data-tab={t.name} href={`#/${t.name}`} aria-current={activeTab === t.name ? 'page' : undefined}>
- {#if t.icon === 'project'}
- <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M3 10h18" stroke="currentColor" stroke-width="2"/></svg>
- {:else if t.icon === 'dataset'}
- <svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="6" rx="8" ry="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
- {:else if t.icon === 'run'}
- <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20v-8" stroke="currentColor" stroke-width="2"/><path d="M9 20V4" stroke="currentColor" stroke-width="2"/><path d="M14 20v-6" stroke="currentColor" stroke-width="2"/><path d="M19 20V9" stroke="currentColor" stroke-width="2"/></svg>
- {:else}
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
- {/if}
- <span class="tab-label">{t.label}</span>
- </a>
- {/each}
- </nav>
+ <Tabs.Root
+ value={activeTab}
+ onValueChange={(v) => go(`/${v}`)}
+ data-testid="mobile-tabbar"
+ class="fixed left-0 right-0 bottom-0 z-40 flex items-stretch justify-around min-h-14 pb-[env(safe-area-inset-bottom,0)] bg-card/95 border-t border-border backdrop-blur"
+ aria-label="底部导航"
+ >
+ <Tabs.List variant="line" class="w-full max-w-none justify-around p-0 rounded-none h-auto">
+ {#each tabs as t (t.name)}
+ <Tabs.Trigger
+ value={t.name}
+ data-tab={t.name}
+ class="flex-1 min-h-14 flex-col gap-0.5 rounded-none text-[0.66rem] data-active:font-semibold"
+ aria-current={activeTab === t.name ? 'page' : undefined}
+ >
+ {#if t.icon === 'project'}
+ <svg viewBox="0 0 24 24" class="size-5" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M3 10h18" stroke="currentColor" stroke-width="2"/></svg>
+ {:else if t.icon === 'dataset'}
+ <svg viewBox="0 0 24 24" class="size-5" aria-hidden="true"><ellipse cx="12" cy="6" rx="8" ry="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
+ {:else if t.icon === 'run'}
+ <svg viewBox="0 0 24 24" class="size-5" aria-hidden="true"><path d="M4 20v-8" stroke="currentColor" stroke-width="2"/><path d="M9 20V4" stroke="currentColor" stroke-width="2"/><path d="M14 20v-6" stroke="currentColor" stroke-width="2"/><path d="M19 20V9" stroke="currentColor" stroke-width="2"/></svg>
+ {:else}
+ <svg viewBox="0 0 24 24" class="size-5" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
+ {/if}
+ <span>{t.label}</span>
+ </Tabs.Trigger>
+ {/each}
+ </Tabs.List>
+ </Tabs.Root>
{/if}
-
-<style>
- .layout {
- display: grid;
- grid-template-columns: 208px minmax(0, 1fr);
- min-height: 100vh;
- }
- .sidebar {
- background: var(--sidebar);
- border-right: 1px solid var(--border);
- padding: 1rem 0.75rem;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- position: sticky;
- top: 0;
- height: 100vh;
- overflow-y: auto;
- }
- .brand {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- padding: 0.3rem 0.4rem 0.6rem;
- border-bottom: 1px solid var(--border);
- margin-bottom: 0.4rem;
- color: var(--foreground);
- }
- .brand:hover { text-decoration: none; }
- .brand-mark {
- width: 22px;
- height: 22px;
- border-radius: calc(var(--radius) * 1.2);
- background: var(--primary);
- flex-shrink: 0;
- position: relative;
- overflow: hidden;
- }
- .brand-mark::after {
- content: '';
- position: absolute;
- inset: 4px;
- background: linear-gradient(
- to top,
- transparent 4px,
- #f0fdfa 4px,
- #f0fdfa 7px,
- transparent 7px,
- transparent 11px,
- #f0fdfa 11px,
- #f0fdfa 15px,
- transparent 15px
- ) left/ 3px 100% no-repeat;
- opacity: 0.9;
- }
- .brand-text { font-weight: 700; font-size: 0.98rem; letter-spacing: 0.01em; }
- .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.68rem; display: block; }
- .nav-title {
- font-size: 0.68rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-3);
- padding: 0.35rem 0.5rem 0.1rem;
- font-weight: 700;
- }
- .navlink {
- display: block;
- padding: 0.32rem 0.55rem;
- border-radius: var(--radius);
- color: var(--text-2);
- font-size: 0.9rem;
- line-height: 1.5;
- }
- .navlink:hover { background: var(--secondary); text-decoration: none; color: var(--foreground); }
- .navlink[aria-current='page'] {
- background: var(--primary);
- color: var(--primary-foreground);
- font-weight: 600;
- }
- .projctx .navlink { font-size: 0.85rem; }
- .projname {
- font-weight: 600;
- padding: 0 0.6rem 0.25rem;
- font-size: 0.9rem;
- overflow-wrap: anywhere;
- color: var(--foreground);
- }
- .divider {
- height: 1px;
- background: var(--border);
- margin: 0.6rem 0.3rem;
- }
- .push { flex: 1; min-height: 1rem; }
- .me {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 0.4rem;
- padding: 0.4rem;
- background: var(--secondary);
- border-radius: var(--radius);
- font-size: 0.84rem;
- }
- .me-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--text-2);
- min-width: 0;
- }
- .content {
- padding: 1.5rem 2rem 3rem;
- max-width: 1360px;
- width: 100%;
- /* grid/flex children default to min-content sizing; without min-width:0 a
- wide table cell propagates min-content into the column and inflates the
- whole document (375px probe measured 552px). */
- min-width: 0;
- }
- @media (max-width: 900px) {
- .layout { grid-template-columns: minmax(0, 1fr); }
- .sidebar {
- position: static;
- height: auto;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: center;
- border-bottom: 1px solid var(--border);
- border-right: none;
- gap: 0.25rem;
- min-width: 0;
- }
- .nav-title, .divider, .push { display: none; }
- .navlink { padding: 0.45rem 0.7rem; }
- /* the project tab row must wrap, not force one long line */
- .projctx { display: flex; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
- .projname { max-width: 100%; overflow-wrap: anywhere; }
- .content { padding: 1.1rem 1rem 3rem; max-width: 100%; }
- }
-
- /* ---- 移动端形态(≤767px):侧栏隐藏,底部 Tab Bar + 极窄标题栏 ---- */
- @media (max-width: 767px) {
- .layout { grid-template-columns: minmax(0, 1fr); }
- .sidebar { display: none; }
- .content { padding: 0.9rem 0.9rem 5.2rem; max-width: 100%; }
- }
- .topbar {
- position: sticky;
- top: 0;
- z-index: 30;
- display: flex;
- align-items: center;
- gap: 0.6rem;
- min-height: 2.6rem;
- padding: 0 0.9rem;
- background: color-mix(in srgb, var(--sidebar) 92%, transparent);
- backdrop-filter: blur(8px);
- border-bottom: 1px solid var(--border);
- font-size: 0.85rem;
- }
- .topbar-brand { font-weight: 700; color: var(--foreground); white-space: nowrap; }
- .topbar-brand em { font-style: normal; font-weight: 500; font-size: 0.62rem; color: var(--text-3); display: block; }
- .topbar-crumb {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--foreground);
- text-align: center;
- font-weight: 600;
- }
- .topbar-me {
- all: unset;
- cursor: pointer;
- display: inline-flex;
- padding: 0.25rem;
- border-radius: 999px;
- color: var(--text-2);
- transition: background-color 150ms ease, color 150ms ease;
- }
- .topbar-me:hover { background: var(--secondary); color: var(--primary); }
-
- .tabbar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 40;
- display: flex;
- align-items: stretch;
- justify-content: space-around;
- min-height: 3.4rem;
- padding-bottom: env(safe-area-inset-bottom, 0);
- background: color-mix(in srgb, var(--surface) 96%, transparent);
- border-top: 1px solid var(--border);
- backdrop-filter: blur(8px);
- }
- .tabbar .item {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 0.14rem;
- flex: 1;
- min-height: 3.4rem;
- color: var(--text-3);
- text-decoration: none;
- font-size: 0.66rem;
- transition: color 150ms ease, background-color 150ms ease;
- }
- .tabbar .item svg { width: 22px; height: 22px; display: block; }
- .tabbar .item:hover { color: var(--text-2); text-decoration: none; }
- /* 选中态:青绿强调色 */
- .tabbar .item[aria-current='page'] { color: var(--primary); font-weight: 600; }
- .tabbar .item[aria-current='page'] svg { color: var(--primary); }
-</style>
diff --git a/frontend/src/components/Loading.svelte b/frontend/src/components/Loading.svelte
index 6de2ea2..625da96 100644
--- a/frontend/src/components/Loading.svelte
+++ b/frontend/src/components/Loading.svelte
@@ -1,24 +1,6 @@
-<div class="loading-page" role="status" aria-live="polite">
- <span class="spinner" aria-hidden="true"></span>
- <p>加载中…</p>
+<div class="min-h-screen grid place-items-center text-muted-foreground" role="status" aria-live="polite">
+ <div class="text-center">
+ <span class="block mx-auto mb-2 size-5 rounded-full border-2 border-primary border-t-transparent animate-spin" aria-hidden="true"></span>
+ <p class="m-0">加载中…</p>
+ </div>
</div>
-
-<style>
- .loading-page {
- min-height: 100vh;
- display: grid;
- place-items: center;
- color: var(--text-3);
- }
- .spinner {
- width: 22px;
- height: 22px;
- border: 2.5px solid var(--teal);
- border-top-color: transparent;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- }
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
-</style>
diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte
index 209d1ab..efe98da 100644
--- a/frontend/src/components/Modal.svelte
+++ b/frontend/src/components/Modal.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button';
+ import * as Card from '$lib/components/ui/card';
interface Props {
title: string;
@@ -25,40 +26,20 @@
</script>
<div
- class="overlay"
+ class="fixed inset-0 bg-black/40 grid place-items-center z-50 p-4"
onclick={onBackdrop}
role="presentation"
onkeydown={onKeydown}
tabindex="-1"
bind:this={container}
>
- <div class="dialog card" role="dialog" aria-modal="true" aria-label={title}>
- <div class="spread">
- <h2>{title}</h2>
+ <Card.Root class="w-[min(760px,100%)] max-h-[88vh] overflow-auto p-4">
+ <div class="flex justify-between items-center gap-2">
+ <h2 class="text-lg font-semibold">{title}</h2>
<Button variant="ghost" size="icon-sm" onclick={onClose} aria-label="关闭">✕</Button>
</div>
- <div class="content">
+ <div class="mt-2">
{@render children?.()}
</div>
- </div>
+ </Card.Root>
</div>
-
-<style>
- .overlay {
- position: fixed;
- inset: 0;
- background: rgba(15, 30, 35, 0.35);
- display: grid;
- place-items: center;
- z-index: 50;
- padding: 1rem;
- }
- .dialog {
- width: min(760px, 100%);
- max-height: 88vh;
- overflow: auto;
- padding: 1.1rem 1.2rem;
- }
-
- .content { margin-top: 0.6rem; }
-</style>
diff --git a/frontend/src/components/Page.svelte b/frontend/src/components/Page.svelte
index a9549bb..22aa78e 100644
--- a/frontend/src/components/Page.svelte
+++ b/frontend/src/components/Page.svelte
@@ -11,27 +11,13 @@
let { title, subtitle, actions, children, wide }: Props = $props();
</script>
-<header class="page-head">
+<header class="flex flex-wrap justify-between items-start gap-4 mb-4">
<div>
- <h1>{title}</h1>
- {#if subtitle}<p class="hint">{subtitle}</p>{/if}
+ <h1 class="text-xl font-semibold">{title}</h1>
+ {#if subtitle}<p class="text-sm text-muted-foreground mt-0.5">{subtitle}</p>{/if}
</div>
{#if actions}{@render actions?.()}{/if}
</header>
-<div class="page-body" class:wide>
+<div class={wide ? '' : 'max-w-3xl'}>
{@render children?.()}
</div>
-
-<style>
- .page-head {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 1rem;
- margin-bottom: 1.1rem;
- flex-wrap: wrap;
- }
- .page-head .hint { margin-top: 0.2rem; }
- .page-body { max-width: 780px; }
- .page-body.wide { max-width: none; }
-</style>
diff --git a/frontend/src/components/ProjectMetricRow.svelte b/frontend/src/components/ProjectMetricRow.svelte
index 6dd3f0e..66dfb93 100644
--- a/frontend/src/components/ProjectMetricRow.svelte
+++ b/frontend/src/components/ProjectMetricRow.svelte
@@ -1,6 +1,4 @@
<script lang="ts">
- // 首页指标卡行(round2 spec §1;round3 迁移 shadcn-svelte Card)
- // 数据通过 props 注入(ProjectsPage 拉取并共享聚合输入,见 dashboardMetrics.ts)。
import { aggregateDashboard } from '../lib/dashboardMetrics';
import * as Card from '$lib/components/ui/card';
@@ -16,55 +14,31 @@
const metric = $derived(aggregateDashboard({ projects, datasets, runs, usage }));
</script>
- <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>
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-2.5 mb-4" role="list" aria-label="工作台概览">
+ <Card.Root size="sm" data-testid="metric-card" role="listitem" class="gap-1 min-w-0 py-3">
+ <span class="text-xs text-muted-foreground">我的项目</span>
+ <span data-testid="metric-value" class="text-2xl font-bold tabular-nums text-foreground leading-tight break-all">{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 size="sm" data-testid="metric-card" role="listitem" class="gap-1 min-w-0 py-3">
+ <span class="text-xs text-muted-foreground">数据集</span>
+ <span data-testid="metric-value" class="text-2xl font-bold tabular-nums text-foreground leading-tight break-all">{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>
+ <Card.Root size="sm" data-testid="metric-card" role="listitem" class="gap-1 min-w-0 py-3">
+ <span class="text-xs text-muted-foreground">回测</span>
+ <span data-testid="metric-value" class="text-2xl font-bold tabular-nums text-foreground leading-tight break-all">
+ <span class="text-xs text-muted-foreground font-normal">运行中</span> {metric.runningRuns}
+ <span class="text-xs text-muted-foreground font-normal">已完成</span> {metric.doneRuns}
</span>
</Card.Root>
<Card.Root
- class="metric-r3"
size="sm"
data-testid="metric-card"
role="listitem"
+ class="gap-1 min-w-0 py-3"
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}
+ <span class="text-xs text-muted-foreground">AI 用量</span>
+ <span data-testid="metric-value" class="text-2xl font-bold tabular-nums text-foreground leading-tight break-all">{metric.usageLabel}</span>
+ {#if metric.usageTokens}<span class="text-xs text-muted-foreground tabular-nums">{metric.usageTokens}</span>{/if}
</Card.Root>
</div>
-
-<style>
- .metric-row {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
- gap: 0.6rem;
- margin-bottom: 1.1rem;
- }
- .metric-row :global(.metric-r3) {
- gap: 0.12rem;
- min-width: 0;
- }
- .metric-row :global(.metric-r3 .mvalue) {
- font-size: 1.5rem;
- font-weight: 700;
- letter-spacing: -0.01em;
- color: var(--foreground);
- font-variant-numeric: tabular-nums;
- line-height: 1.2;
- overflow-wrap: anywhere;
- }
-
- .mvalue-sub { color: var(--text-3); }
-</style>
diff --git a/frontend/src/components/Status.svelte b/frontend/src/components/Status.svelte
index 6ae2cfb..ce2a753 100644
--- a/frontend/src/components/Status.svelte
+++ b/frontend/src/components/Status.svelte
@@ -1,4 +1,7 @@
<script lang="ts">
+ import { Alert } from '$lib/components/ui/alert';
+ import * as Card from '$lib/components/ui/card';
+
interface Props {
error?: string | null;
busy?: boolean;
@@ -11,60 +14,23 @@
</script>
{#if busy}
- <div class="status" role="status">
- <span class="spinner" aria-hidden="true"></span>
+ <div class="text-muted-foreground flex items-center gap-2 py-3 text-sm" role="status">
+ <span class="inline-block size-4 rounded-full border-2 border-primary border-t-transparent align-[-0.125em] animate-spin" aria-hidden="true"></span>
{busyText}
</div>
{:else if error}
- <Alert variant="destructive" class="bg-[var(--danger-soft)] text-[var(--destructive)] border-[#f2c7c7]">
+ <Alert variant="destructive">
<span aria-hidden="true">⚠</span>
<span>{error}</span>
</Alert>
{:else if empty !== null && empty !== undefined && empty !== false}
- <div class="state-empty">
- <p class="empty-title">{empty}</p>
- {#if emptyHint}<p class="hint">{emptyHint}</p>{/if}
- </div>
+ <Card.Root class="border-dashed text-center py-8 px-4 text-muted-foreground">
+ <p class="font-semibold mb-1">{empty}</p>
+ {#if emptyHint}<p class="text-sm text-muted-foreground">{emptyHint}</p>{/if}
+ </Card.Root>
{/if}
{#if warnings && warnings.length > 0}
- <div class="stack">
- {#each warnings as w, i (i)}
- <Alert variant="default" class="bg-[var(--warn-soft)] text-[var(--warn)] border-[#f0ddb4]"><span aria-hidden="true">△</span><span>{w}</span></Alert>
- {/each}
- </div>
+ {#each warnings as w, i (i)}
+ <Alert variant="default"><span aria-hidden="true">△</span><span>{w}</span></Alert>
+ {/each}
{/if}
-
-<script lang="ts" module>
- import { Alert } from '$lib/components/ui/alert';
-</script>
-
-<style>
- .status {
- color: var(--text-3);
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.9rem 0;
- font-size: 0.9rem;
- }
- .spinner {
- width: 15px;
- height: 15px;
- border: 2px solid var(--teal);
- border-top-color: transparent;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
- }
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
- .state-empty {
- text-align: center;
- padding: 2.2rem 1rem;
- border: 1px dashed var(--border-strong);
- border-radius: var(--radius);
- color: var(--text-2);
- background: var(--surface);
- }
- .empty-title { font-weight: 600; margin-bottom: 0.2rem; }
-</style>
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>