summaryrefslogtreecommitdiff
path: root/frontend/src/components/DatasetWizard.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/DatasetWizard.svelte')
-rw-r--r--frontend/src/components/DatasetWizard.svelte374
1 files changed, 374 insertions, 0 deletions
diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte
new file mode 100644
index 0000000..1e61d92
--- /dev/null
+++ b/frontend/src/components/DatasetWizard.svelte
@@ -0,0 +1,374 @@
+<script lang="ts">
+ import Status from './Status.svelte';
+ import { getCapabilities, searchInstruments, createDataset } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format';
+ import { createSearchRunner, SEARCH_EMPTY_MSG } from '../lib/instrumentSearch';
+ import type { Capabilities, Dataset, Instrument } from '../lib/types';
+
+ interface Props {
+ onCreated?: (d: Dataset) => void;
+ }
+ let { onCreated }: Props = $props();
+
+ let caps = $state<Capabilities | null>(null);
+ let loadErr: string | null = $state(null);
+
+ let query = $state('');
+ let searchBusy = $state(false);
+ let searchItems: Instrument[] = $state([]);
+ let searchSource = $state('');
+ let searchErr: string | null = $state(null);
+
+ const searchRunner = createSearchRunner(searchInstruments, {
+ onBusy: () => {
+ searchBusy = true;
+ searchErr = null;
+ searchItems = [];
+ },
+ onResult: (res) => {
+ searchItems = res.items;
+ searchSource = res.source;
+ if (res.items.length === 0 && !searchErr) {
+ searchErr = SEARCH_EMPTY_MSG;
+ }
+ },
+ onError: (message) => {
+ searchItems = [];
+ searchErr = message;
+ },
+ onFinish: () => {
+ searchBusy = false;
+ }
+ });
+
+ function doSearch() {
+ searchRunner.runNow(query);
+ }
+
+ function onSearchInput() {
+ searchRunner.requestDebounced(query);
+ }
+
+ let selected: Instrument[] = $state([]);
+ let symbol = $state('');
+ let market = $state('SH');
+ let assetType = $state('stock');
+ let manualName = $state('');
+
+ let startDate = $state('');
+ let endDate = $state('');
+ let adjustment: 'none' | 'qfq' | 'hfq' = $state('none');
+ let chosenRaw: string[] = $state([]);
+ let dsName = $state('');
+
+ let submitErr: string | null = $state(null);
+ let submitting = $state(false);
+ let formErr: string[] = $state([]);
+ let formOk: string | null = $state(null);
+
+ const maxYears = $derived(caps?.limits.max_years ?? 15);
+ const maxSymbols = $derived(caps?.limits.max_symbols ?? 5);
+ const rawFieldChoices = $derived(caps?.fields.filter((f) => f.raw) ?? []);
+ const baseRequired = $derived(caps?.fields.filter((f) => !f.raw) ?? []);
+
+ $effect(() => {
+ (async () => {
+ try {
+ caps = await getCapabilities();
+ } catch (e) {
+ loadErr = extractStatusMessage(e);
+ }
+ })();
+ });
+
+ function isRaw(code: string) {
+ if (!caps) return false;
+ return !caps.fields.some((f) => f.code === code && !f.raw);
+ }
+
+ function toggleRawField(code: string) {
+ chosenRaw = chosenRaw.includes(code) ? chosenRaw.filter((c) => c !== code) : [...chosenRaw, code];
+ }
+
+ function sameInstrument(a: Instrument, b: Instrument) {
+ return a.market === b.market && a.symbol === b.symbol && a.asset_type === b.asset_type;
+ }
+
+ function addSelection(inst: Instrument) {
+ if (selected.some((s) => sameInstrument(s, inst))) {
+ formErr = [`${inst.market}.${inst.symbol} 已在列表中`];
+ return;
+ }
+ if (selected.length >= maxSymbols) {
+ formErr = [`最多选择 ${maxSymbols} 个标的(能力上限)`];
+ return;
+ }
+ formErr = [];
+ selected = [...selected, inst];
+ }
+
+ function removeSelection(target: Instrument) {
+ selected = selected.filter((s) => !sameInstrument(s, target));
+ }
+
+ function addManual() {
+ const sym = symbol.trim();
+ if (!sym) {
+ formErr = ['请输入确切的代码,例如 600000 或 510300'];
+ return;
+ }
+ const inst: Instrument = {
+ symbol: sym,
+ market: market.trim().toUpperCase(),
+ asset_type: assetType as Instrument['asset_type'],
+ name: manualName.trim() || null,
+ currency: null
+ };
+ addSelection(inst);
+ symbol = '';
+ manualName = '';
+ }
+
+ async function submit() {
+ const errs: string[] = [];
+ if (selected.length === 0) errs.push('至少选择一个标的');
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) errs.push('请选择开始日期');
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) errs.push('请选择结束日期');
+ if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate)) {
+ const years = (new Date(endDate).getTime() - new Date(startDate).getTime()) / (365.25 * 86400000);
+ if (!(endDate > startDate)) errs.push('结束日期须晚于开始日期');
+ else if (years > maxYears) errs.push(`范围不能超过 ${maxYears} 年(当前约 ${years.toFixed(1)} 年)`);
+ }
+ if (errs.length > 0) {
+ formErr = errs;
+ return;
+ }
+ formErr = [];
+ formOk = null;
+ submitting = true;
+ submitErr = null;
+ try {
+ const fields = ['open', 'high', 'low', 'close', 'volume'].concat(chosenRaw);
+ const ds = await createDataset({
+ name: dsName.trim() || undefined,
+ instruments: selected,
+ start_date: startDate,
+ end_date: endDate,
+ frequency: 'daily',
+ adjustment,
+ fields
+ });
+ formOk = `数据请求已提交,编号 ${ds.id.slice(0, 8)}…,可在下方列表查看获取进度`;
+ selected = [];
+ startDate = '';
+ endDate = '';
+ dsName = '';
+ chosenRaw = [];
+ onCreated?.(ds);
+ } catch (e) {
+ submitErr = extractStatusMessage(e);
+ if ((e as { details?: { errors?: string[] } }).details?.errors) {
+ formErr = (e as { details: { errors: string[] } }).details.errors;
+ }
+ } finally {
+ submitting = false;
+ }
+ }
+</script>
+
+<div class="card stack">
+ <h2>新建数据集</h2>
+
+ <Status busy={!caps && !loadErr} busyText="加载能力配置…" error={loadErr} empty={null} />
+
+ {#if caps}
+ <section class="stack">
+ <h3 class="step-h">1) 选择标的 <span class="hint">最多 {maxSymbols} 个 · 日频 POC</span></h3>
+ <div class="row">
+ <input
+ style="flex:2 1 200px"
+ placeholder="搜索代码或名称,如 600000、沪深300ETF"
+ bind:value={query}
+ oninput={onSearchInput}
+ onkeydown={(ev) => {
+ if (ev.key === 'Enter') doSearch();
+ }}
+ aria-label="标的搜索"
+ />
+ <button class="btn" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}>
+ {searchBusy ? '搜索中…' : '搜索'}
+ </button>
+ </div>
+ <Status busy={searchBusy} busyText="搜索中…" error={searchErr} empty={null} />
+ {#if searchErr && !searchBusy}
+ <button class="btn small" onclick={() => doSearch()}>重试</button>
+ {/if}
+ {#if searchItems.length > 0}
+ <table class="data">
+ <thead>
+ <tr><th>代码</th><th>类型</th><th>名称</th><th>来源</th><th></th></tr>
+ </thead>
+ <tbody>
+ {#each searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))}
+ <tr>
+ <td class="num">{inst.market}.<strong>{inst.symbol}</strong></td>
+ <td>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</td>
+ <td>{inst.name ?? '—'}</td>
+ <td class="hint">{itemSourceLabel(inst.source, searchSource)}</td>
+ <td>
+ <button class="btn small" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}>
+ {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'}
+ </button>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+
+ <details>
+ <summary>手动录入标的(目录未收录时)</summary>
+ <div class="card stack manual">
+ <p class="hint">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p>
+ <div class="row wrap">
+ <label style="flex:1 1 130px">
+ 代码
+ <input bind:value={symbol} placeholder="600000 / 510300 / 000300" />
+ </label>
+ <label style="flex:0 0 110px">
+ 市场
+ <select bind:value={market}>
+ {#each ['SH', 'SZ', 'BJ'] as m (m)}
+ <option value={m}>{m}</option>
+ {/each}
+ </select>
+ </label>
+ <label style="flex:0 0 120px">
+ 类型
+ <select bind:value={assetType}>
+ {#each caps.asset_types as t (t)}
+ <option value={t}>{assetTypeLabel[t] ?? t}</option>
+ {/each}
+ </select>
+ </label>
+ <label style="flex:1 1 160px">
+ 名称(可选)
+ <input bind:value={manualName} placeholder="与供应商页面一致" />
+ </label>
+ </div>
+ <div class="row">
+ <button class="btn" onclick={addManual}>加入所选</button>
+ </div>
+ </div>
+ </details>
+ </section>
+
+ <section>
+ <h3 class="step-h">2) 已选标的 <span class="hint">{selected.length}/{maxSymbols}</span></h3>
+ {#if selected.length === 0}
+ <p class="hint">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p>
+ {:else}
+ <ul class="chip-list">
+ {#each selected as s, i (s.market + '.' + s.symbol + s.asset_type)}
+ <li>
+ <span class="num">{s.market}.{s.symbol}</span>
+ {#if s.name}<span class="hint">{s.name}</span>{/if}
+ <span class="badge grey">{assetTypeLabel[s.asset_type] ?? s.asset_type}</span>
+ <button class="btn small ghost" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</button>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+
+ <section class="grid3 wrap-3">
+ <label>
+ 起始日期
+ <input type="date" bind:value={startDate} />
+ </label>
+ <label>
+ 结束日期
+ <input type="date" bind:value={endDate} />
+ </label>
+ <label style="min-width:160px">
+ 复权
+ <select bind:value={adjustment}>
+ {#each caps.adjustments as a (a.code)}
+ <option value={a.code}>{a.label}</option>
+ {/each}
+ </select>
+ {#if adjustment !== 'none'}
+ <p class="hint">{adjustmentLabel[adjustment]}:以复权口径返回</p>
+ {/if}
+ <p class="hint">日频 · 周期上限 {maxYears} 年</p>
+ </label>
+ </section>
+
+ <section>
+ <label for="ds-name">数据集名称(可选,留空自动生成)</label>
+ <input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" />
+ </section>
+
+ <details>
+ <summary>附加原始字段(可选)</summary>
+ <p class="hint">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p>
+ <ul class="field-list">
+ {#each rawFieldChoices as f (f.code)}
+ <li>
+ <label class="check">
+ <input type="checkbox" checked={chosenRaw.includes(f.code)} onchange={() => toggleRawField(f.code)} />
+ {f.label} <span class="hint">(未请求不返回)</span>
+ </label>
+ </li>
+ {/each}
+ </ul>
+ {#if caps.fields.some((f) => !f.raw)}
+ <p class="hint">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p>
+ {/if}
+ </details>
+
+ {#if formErr.length > 0}
+ <div class="banner error" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <ul class="err-list">
+ {#each formErr as msg, i (i)}<li>{msg}</li>{/each}
+ </ul>
+ </div>
+ {/if}
+ {#if submitErr}
+ <div class="banner error" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <span>数据请求提交失败:{submitErr}</span>
+ </div>
+ {/if}
+ <p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p>
+ <div>
+ <button class="btn primary" disabled={submitting || selected.length === 0} onclick={submit}>
+ {submitting ? '提交中…' : '提交数据请求'}
+ </button>
+ </div>
+ {/if}
+
+ <Status busy={false} error={null} empty={formOk} />
+</div>
+
+<style>
+ .manual { padding: 0.8rem; margin: 0.4rem 0; }
+ .manual input, .manual select { margin-top: 0.25rem; }
+ .chip-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; }
+ .chip-list li {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ border: 1px solid var(--border-strong);
+ padding: 0.25rem 0.5rem;
+ border-radius: 999px;
+ background: var(--surface-2);
+ }
+ .field-list { list-style: none; padding: 0; margin: 0.3rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.2rem; }
+ .check { font-size: 0.88rem; margin: 0; display: flex; gap: 0.35rem; align-items: center; }
+ .step-h { font-size: 0.95rem; }
+ .err-list { padding-left: 1.1rem; margin: 0; list-style: disc; }
+ .wrap-3 { align-items: start; }
+</style>