1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
|
<script lang="ts">
import Status from './Status.svelte';
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, onRecreated }: 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;
}
}
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;
});
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>
<Card.Root>
{#if ds}
<div class="flex flex-wrap justify-between items-center gap-2">
<div class="min-w-0">
<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'}>
{datasetStatusLabel[ds.status]}
</Badge>
</div>
<div class="overflow-x-auto min-w-0">
<Table.Root>
<Table.Body>
<Table.Row>
<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="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="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="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"><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="text-sm text-muted-foreground break-all">
已用原请求创建新数据集(编号 {recreatedId.slice(0, 8)}…),新数据集出现在下方列表并自动跟踪进度;本条失败记录原样保留。
</span>
{/if}
{#if recreateErr}<span role="alert">{recreateErr}</span>{/if}
</div>
{/if}
{#if ds.status === 'ready'}
<div>
<Button variant="outline" size="sm" aria-expanded={showPreview} onclick={togglePreview}>
{showPreview ? '收起预览' : '查看数据覆盖预览'}
</Button>
</div>
{#if showPreview}
<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"><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" 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}
</ul>
</div></Alert>
{/if}
{#if cov.common.start && cov.warnings.length === 0}
<Alert variant="default">
<span aria-hidden="true">✓</span>
<span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span>
</Alert>
{/if}
<div class="overflow-x-auto min-w-0">
<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="tabular-nums">{row[c]}</Table.Cell>{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if preview.truncated}
<p class="text-sm text-muted-foreground">预览仅展示部分行</p>
{/if}
{/if}
</div>
{/if}
{/if}
{:else}
<Status busy={busy} error={error} busyText="载入数据集…" empty={null} />
{/if}
</Card.Root>
|