diff options
Diffstat (limited to 'frontend/src/components/DatasetCard.svelte')
| -rw-r--r-- | frontend/src/components/DatasetCard.svelte | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte new file mode 100644 index 0000000..7b38874 --- /dev/null +++ b/frontend/src/components/DatasetCard.svelte @@ -0,0 +1,157 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getDataset, getDatasetPreview } 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'; + + interface Props { + dsId: string; + } + let { dsId }: Props = $props(); + + let ds = $state<Dataset | null>(null); + let error: string | null = $state(null); + let busy = $state(true); + let showPreview = $state(false); + let preview = $state<DatasetPreview | null>(null); + let previewBusy = $state(false); + let previewError: string | null = $state(null); + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + $effect(() => { + void load(); + return stopPolling; + }); + + async function load() { + error = null; + try { + ds = await getDataset(dsId); + if (ds.status === 'pending' || ds.status === 'running') { + if (!pollTimer) { + pollTimer = setInterval(() => { + void load(); + }, 2000); + } + } else { + stopPolling(); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function togglePreview() { + showPreview = !showPreview; + if (showPreview && !preview && ds?.status === 'ready') { + previewBusy = true; + previewError = null; + try { + preview = await getDatasetPreview(dsId); + } catch (e) { + previewError = `预览加载失败:${extractStatusMessage(e)}`; + } finally { + previewBusy = false; + } + } + } +</script> + +<div class="card"> + {#if ds} + <div class="spread"> + <div> + <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} + </div> + <span class="badge {ds.status === 'ready' ? 'ok' : ds.status === 'failed' ? 'danger' : 'warn'}"> + {datasetStatusLabel[ds.status]} + </span> + </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> + {/if} + {#if ds.status === 'ready'} + <div> + <button class="btn small" aria-expanded={showPreview} onclick={togglePreview}> + {showPreview ? '收起预览' : '查看数据覆盖预览'} + </button> + </div> + {#if showPreview} + <div class="preview-box"> + <Status busy={previewBusy} busyText="加载预览…" error={previewError} empty={null} /> + {#if preview} + {@const cov = summarizeCoverage(preview.coverage ?? [])} + {#if cov.warnings.length > 0} + <div class="banner warn"><span aria-hidden="true">⚠</span><div> + <div class="cov-caption">覆盖差异(共有区间 {cov.common.start ?? '—'} ~ {cov.common.end ?? '—'}):</div> + <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} + <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> + {#if preview.truncated} + <p class="hint">预览仅展示部分行</p> + {/if} + {/if} + </div> + {/if} + {/if} + {:else} + <Status busy={busy} error={error} busyText="载入数据集…" empty={null} /> + {/if} +</div> + +<style> + .kv th { width: 110px; color: var(--text-2); font-weight: 500; min-width: 110px; } + .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; } + .cov-caption { font-weight: 600; } + .cov-list { margin: 0.2rem 0 0 1.1rem; padding: 0; } +</style> |
