summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 08:26:57 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 08:26:57 +0800
commit45ecb4e9dfe225bc982e9af9315590bcb0783df7 (patch)
treee046f42ace89c09d60aa4f07db8450507364fdb5 /frontend/src/components
parent33cac3eef7d9962d8803cd812553549d6f555516 (diff)
downloadstrategy-lab-45ecb4e9dfe225bc982e9af9315590bcb0783df7.tar.gz
feat(frontend): 结果/实验/策略/数据页迁移 shadcn 与新 Chart API
[变更性质] 四个业务标签页与数据集卡片迁移 shadcn 组件,图表消费切换到 layerchart 长格式 API,功能与数据语义不变。 [新增功能] 结果页权益曲线(策略权益/现金/基准,基准可缺省)与回撤曲线(desc 面积)改由 Chart.svelte data/series 渲染;实验记录页多回测归一对比 曲线改为快速长格式行构造。空数据行允许 undefined 列(基准缺口), Chart data 类型放宽 [key:string]: string|number|undefined。 [实现方案] RunsPage:查看入口改 shadcn Button(href 语义保留 hash 路由), 对比图数据由 compareOption(echarts option)化为 compare。 ProjectResultsTab:图表开关、日志折叠用 Button;equityData/ drawdownData $derived 计算峰值回撤。ProjectStrategyTab:快照输入、 冲突/AI/版本操作迁移 Button/Input。ProjectDataTab 与 DatasetCard: shadcn 组件 + 保留 banner error/warn/info、retry-row 等 legacy class, "使用相同请求重新获取"重试入口与 onRecreated 逻辑不变,配套 datasetCardRetry/datasetCardWarnings/projectDataTabRecreated 回归 测试。resultsAccounting.test.ts:layerchart 在 jsdom 挂载约 3 秒, suite 全量下易撞默认 5s 超时,首测提高至 20s。 [影响范围] npm run check 0 error、58 tests 全绿、npm run build 通过;真浏览器 375/768/1440 图表页零溢出(artifacts/ui-shadcn 截图与探针 JSON)。
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/DatasetCard.svelte169
1 files changed, 128 insertions, 41 deletions
diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte
index 7b38874..2d49e65 100644
--- a/frontend/src/components/DatasetCard.svelte
+++ b/frontend/src/components/DatasetCard.svelte
@@ -1,15 +1,24 @@
<script lang="ts">
import Status from './Status.svelte';
- import { getDataset, getDatasetPreview } from '../lib/client';
+ import { getDataset, getDatasetPreview, createDataset } from '../lib/client';
import { extractStatusMessage } from '../lib/state';
import { adjustmentLabel, assetTypeLabel, instrumentLabel, datasetStatusLabel, fmtDate } from '../lib/format';
import { summarizeCoverage } from '../lib/coverage';
import type { Dataset, DatasetPreview } from '../lib/types';
+ import { Button } from '$lib/components/ui/button';
+ import * as Card from '$lib/components/ui/card';
+ import * as Table from '$lib/components/ui/table';
+ import { Badge } from '$lib/components/ui/badge';
+ import { Alert, AlertTitle, AlertDescription } from '$lib/components/ui/alert';
interface Props {
dsId: string;
+ /** called after a failed dataset is re-requested so the parent list can
+ * reload and show the new dataset (which then self-tracks pending→ready|
+ * failed via the parent's polling). */
+ onRecreated?: (d: Dataset) => void;
}
- let { dsId }: Props = $props();
+ let { dsId, onRecreated }: Props = $props();
let ds = $state<Dataset | null>(null);
let error: string | null = $state(null);
@@ -27,6 +36,46 @@
}
}
+ let recreateBusy = $state(false);
+ let recreateErr: string | null = $state(null);
+ let recreatedId: string | null = $state(null);
+
+ /// Failed datasets are terminal (no retry endpoint exists): recreate via the
+ /// normal create API using the exact stored request, while the original
+ /// failed record stays untouched and visible.
+ async function recreateFromRequest() {
+ // ds.request is the canonical request WITHOUT a dataset name: preserve the
+ // user-visible name from ds.name instead (leader review 2026-09-17).
+ if (!ds?.request || recreateBusy || recreatedId) return;
+ recreateBusy = true;
+ recreateErr = null;
+ recreatedId = null;
+ try {
+ const body = {
+ name: ds.name ?? undefined,
+ instruments: (ds.request.instruments ?? []).map((i) => ({
+ symbol: i.symbol,
+ market: i.market,
+ asset_type: i.asset_type,
+ name: i.name ?? null,
+ currency: null
+ })),
+ start_date: ds.request.start_date,
+ end_date: ds.request.end_date,
+ frequency: ds.request.frequency,
+ adjustment: ds.request.adjustment,
+ fields: [...(ds.request.fields ?? [])]
+ };
+ const created = await createDataset(body);
+ recreatedId = created.id;
+ onRecreated?.(created);
+ } catch (e) {
+ recreateErr = `重新获取失败:${extractStatusMessage(e)}`;
+ } finally {
+ recreateBusy = false;
+ }
+ }
+
$effect(() => {
void load();
return stopPolling;
@@ -68,37 +117,63 @@
}
</script>
-<div class="card">
+<Card.Root>
{#if ds}
<div class="spread">
- <div>
+ <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}
- {#if ds.cache_hit}<span class="badge teal">缓存命中 · 复用现有物化对象</span>{/if}
+ {#if ds.cache_hit}<Badge variant="secondary">缓存命中 · 复用现有物化对象</Badge>{/if}
</div>
- <span class="badge {ds.status === 'ready' ? 'ok' : ds.status === 'failed' ? 'danger' : 'warn'}">
+ <Badge variant={ds.status === 'ready' ? 'secondary' : ds.status === 'failed' ? 'destructive' : 'outline'}
+ class={ds.status === 'ready' ? 'badge ok' : ds.status === 'failed' ? 'badge danger' : 'badge warn'}>
{datasetStatusLabel[ds.status]}
- </span>
+ </Badge>
+ </div>
+ <div class="table-wrap">
+ <Table.Root>
+ <Table.Body>
+ <Table.Row>
+ <Table.Cell class="k">请求标的</Table.Cell>
+ <Table.Cell>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</Table.Cell>
+ </Table.Row>
+ <Table.Row>
+ <Table.Cell class="k">范围</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>{(ds.request.fields ?? []).join(', ')}</Table.Cell>
+ </Table.Row>
+ <Table.Row>
+ <Table.Cell class="k">创建时间</Table.Cell>
+ <Table.Cell>{fmtDate(ds.created_at)}</Table.Cell>
+ </Table.Row>
+ </Table.Body>
+ </Table.Root>
</div>
- <table class="data kv">
- <tbody>
- <tr><th>请求标的</th><td>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</td></tr>
- <tr><th>范围</th><td>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</td></tr>
- <tr><th>字段</th><td>{(ds.request.fields ?? []).join(', ')}</td></tr>
- <tr><th>创建时间</th><td>{fmtDate(ds.created_at)}</td></tr>
- </tbody>
- </table>
{#if ds.status === 'failed'}
<div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></div>
- {/if}
- {#if ds.status !== 'ready'}
- <p class="hint">{ds.status === 'pending' || ds.status === 'running' ? '获取中,完成后可预览覆盖情况' : ''}</p>
+ <div class="retry-row">
+ <Button variant="outline" size="sm"
+ disabled={recreateBusy || recreatedId !== null}
+ onclick={() => void recreateFromRequest()}
+ >
+ {recreateBusy ? '重新获取中…' : '使用相同请求重新获取'}
+ </Button>
+ {#if recreatedId}
+ <span class="hint">
+ 已用原请求创建新数据集(编号 {recreatedId.slice(0, 8)}…),新数据集出现在下方列表并自动跟踪进度;本条失败记录原样保留。
+ </span>
+ {/if}
+ {#if recreateErr}<span role="alert">{recreateErr}</span>{/if}
+ </div>
{/if}
{#if ds.status === 'ready'}
<div>
- <button class="btn small" aria-expanded={showPreview} onclick={togglePreview}>
+ <Button variant="outline" size="sm" aria-expanded={showPreview} onclick={togglePreview}>
{showPreview ? '收起预览' : '查看数据覆盖预览'}
- </button>
+ </Button>
</div>
{#if showPreview}
<div class="preview-box">
@@ -111,30 +186,40 @@
<ul class="cov-list">
{#each cov.warnings as w, i (i)}<li>{w}</li>{/each}
</ul>
- {#if (preview.coverage ?? []).some((c) => c.warnings)}
- <ul class="cov-list">
- {#each preview.coverage.filter((c) => c.warnings) as c (c.instrument)}
- <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li>
- {/each}
- </ul>
- {/if}
</div></div>
- {:else if cov.common.start}
+ {/if}
+ {#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)}
+ <div class="banner warn" role="alert"><span aria-hidden="true">⚠</span><div>
+ <div class="cov-caption">来源与口径说明:</div>
+ <ul class="cov-list">
+ {#each preview.coverage.filter((c) => (c.warnings ?? []).length > 0) as c (c.instrument)}
+ <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li>
+ {/each}
+ </ul>
+ </div></div>
+ {/if}
+ {#if cov.common.start && cov.warnings.length === 0}
<div class="banner info">
<span aria-hidden="true">✓</span>
<span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span>
</div>
{/if}
- <table class="data scrollx">
- <thead>
- <tr>{#each preview.columns as c (c)}<th>{c}</th>{/each}</tr>
- </thead>
- <tbody>
- {#each preview.rows as row, i (i)}
- <tr>{#each preview.columns as c (c)}<td class="num">{row[c]}</td>{/each}</tr>
- {/each}
- </tbody>
- </table>
+ <div class="scrollx">
+ <Table.Root>
+ <Table.Header>
+ <Table.Row>
+ {#each preview.columns as c (c)}<Table.Head>{c}</Table.Head>{/each}
+ </Table.Row>
+ </Table.Header>
+ <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}
+ </Table.Row>
+ {/each}
+ </Table.Body>
+ </Table.Root>
+ </div>
{#if preview.truncated}
<p class="hint">预览仅展示部分行</p>
{/if}
@@ -145,13 +230,15 @@
{:else}
<Status busy={busy} error={error} busyText="载入数据集…" empty={null} />
{/if}
-</div>
+</Card.Root>
<style>
- .kv th { width: 110px; color: var(--text-2); font-weight: 500; min-width: 110px; }
+ .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; }
+ .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>