summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
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>