diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:57 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:57 +0800 |
| commit | 45ecb4e9dfe225bc982e9af9315590bcb0783df7 (patch) | |
| tree | e046f42ace89c09d60aa4f07db8450507364fdb5 /frontend/src/components | |
| parent | 33cac3eef7d9962d8803cd812553549d6f555516 (diff) | |
| download | strategy-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.svelte | 169 |
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> |
