summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 15:49:44 +0800
commita7aab01f051487327e5ce52b6a8905208c546e8c (patch)
tree630a624523bc15d1a9f59ab20693385eaf55befb /frontend/src/components
parent375584174fb7191a57c5423fea45924ad6d812d5 (diff)
downloadstrategy-lab-a7aab01f051487327e5ce52b6a8905208c546e8c.tar.gz
[变更性质] feat(frontend)
[新增功能] 12 页面组件全量 shadcn:Card/Button/Table/Tabs/Select/Alert;375 底部 Tab Bar 改用 Tabs 组件实现 [实现方案] 页面级手写 class 全部移除(.btn/.banner/table.data/.badge/.tabs/.metric-row/.m-tile/.hint 等改为 shadcn + tailwind 工具类);Modal/DatasetWizard/DatasetCard/DiffView/CodeEditor/Status/Layout 同步 [影响范围] src/pages/*.svelte、src/components/*.svelte
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/CodeEditor.svelte27
-rw-r--r--frontend/src/components/DatasetCard.svelte60
-rw-r--r--frontend/src/components/DatasetWizard.svelte223
-rw-r--r--frontend/src/components/DiffView.svelte41
-rw-r--r--frontend/src/components/Layout.svelte344
-rw-r--r--frontend/src/components/Loading.svelte28
-rw-r--r--frontend/src/components/Modal.svelte33
-rw-r--r--frontend/src/components/Page.svelte22
-rw-r--r--frontend/src/components/ProjectMetricRow.svelte58
-rw-r--r--frontend/src/components/Status.svelte60
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>