summaryrefslogtreecommitdiff
path: root/frontend/src/components/DatasetCard.svelte
blob: 69083fb2fb427a9a0c467881c00fa33a0db4ab63 (plain)
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
233
234
235
236
237
238
239
240
241
242
243
244
<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="spread">
      <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}<Badge variant="secondary">缓存命中 · 复用现有物化对象</Badge>{/if}
      </div>
      <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]}
      </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>
    {#if ds.status === 'failed'}
      <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></Alert>
      <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 variant="outline" size="sm" 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}
              <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]"><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>
              </div></Alert>
            {/if}
            {#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)}
              <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]" 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></Alert>
            {/if}
            {#if cov.common.start && cov.warnings.length === 0}
              <Alert variant="default" class="bg-[var(--brand-soft)] border-[#bfe9db]">
                <span aria-hidden="true">✓</span>
                <span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span>
              </Alert>
            {/if}
            <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}
          {/if}
        </div>
      {/if}
    {/if}
  {:else}
    <Status busy={busy} error={error} busyText="载入数据集…" empty={null} />
  {/if}
</Card.Root>

<style>
  .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; 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>