diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
| commit | a7aab01f051487327e5ce52b6a8905208c546e8c (patch) | |
| tree | 630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src | |
| parent | 375584174fb7191a57c5423fea45924ad6d812d5 (diff) | |
| download | strategy-lab-a7aab01f051487327e5ce52b6a8905208c546e8c.tar.gz | |
[变更性质] feat(frontend)
[新增功能] 12 页面组件全量 shadcn:Card/Button/Table/Tabs/Select/Alert;375 底部 Tab Bar 改用 Tabs 组件实现
[实现方案] 页面级手写 class 全部移除(.btn/.banner/table.data/.badge/.tabs/.metric-row/.m-tile/.hint 等改为 shadcn + tailwind 工具类);Modal/DatasetWizard/DatasetCard/DiffView/CodeEditor/Status/Layout 同步
[影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src')
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> |
