diff options
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/CodeEditor.svelte | 27 | ||||
| -rw-r--r-- | frontend/src/components/DatasetCard.svelte | 60 | ||||
| -rw-r--r-- | frontend/src/components/DatasetWizard.svelte | 223 | ||||
| -rw-r--r-- | frontend/src/components/DiffView.svelte | 41 | ||||
| -rw-r--r-- | frontend/src/components/Layout.svelte | 344 | ||||
| -rw-r--r-- | frontend/src/components/Loading.svelte | 28 | ||||
| -rw-r--r-- | frontend/src/components/Modal.svelte | 33 | ||||
| -rw-r--r-- | frontend/src/components/Page.svelte | 22 | ||||
| -rw-r--r-- | frontend/src/components/ProjectMetricRow.svelte | 58 | ||||
| -rw-r--r-- | frontend/src/components/Status.svelte | 60 |
10 files changed, 272 insertions, 624 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> |
