summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Chart.svelte67
-rw-r--r--frontend/src/components/CodeEditor.svelte104
-rw-r--r--frontend/src/components/DatasetCard.svelte157
-rw-r--r--frontend/src/components/DatasetWizard.svelte374
-rw-r--r--frontend/src/components/DiffView.svelte71
-rw-r--r--frontend/src/components/Layout.svelte241
-rw-r--r--frontend/src/components/Loading.svelte24
-rw-r--r--frontend/src/components/Modal.svelte63
-rw-r--r--frontend/src/components/Page.svelte37
-rw-r--r--frontend/src/components/Status.svelte66
10 files changed, 1204 insertions, 0 deletions
diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte
new file mode 100644
index 0000000..ca8f4f8
--- /dev/null
+++ b/frontend/src/components/Chart.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { onMount, onDestroy } from 'svelte';
+ import type * as echartsNS from 'echarts';
+
+ interface Props {
+ option: echartsNS.EChartsOption;
+ height?: number;
+ ariaLabel?: string;
+ }
+ let { option, height = 360, ariaLabel = '图表' }: Props = $props();
+
+ let host: HTMLDivElement;
+ let chart: echartsNS.ECharts | null = null;
+ let observer: ResizeObserver | null = null;
+
+ onMount(async () => {
+ // ECharts chunk loads lazily when a result/comparison view is opened.
+ const echarts = (await import('echarts')) as typeof echartsNS;
+ if (!host) return;
+ // Pass explicit sizes when the host has no layout yet (jsdom regression
+ // tests report 0×0 and ECharts warns "Can't get DOM width or height").
+ // In a real browser clientWidth/clientHeight are non-zero, so the measured
+ // size is used exactly as before; responsive resizing still works through
+ // the ResizeObserver below.
+ chart = echarts.init(host, undefined, {
+ renderer: 'svg',
+ width: host.clientWidth || 640,
+ height: host.clientHeight || height
+ });
+ observer = new ResizeObserver(() => {
+ if (!chart) return;
+ // re-measure on EVERY resize callback and pass the measured size
+ // explicitly: echarts keeps the width given at init/resize, so a
+ // desktop->mobile viewport change would otherwise leave the SVG at the
+ // old width and overflow the page (real, re-verified live bug).
+ const w = host.clientWidth || undefined;
+ const h = host.clientHeight || undefined;
+ if (w === undefined && h === undefined) return;
+ chart.resize({ width: w, height: h });
+ });
+ observer.observe(host);
+ chart?.setOption(option, { notMerge: true });
+ });
+
+ onDestroy(() => {
+ observer?.disconnect();
+ chart?.dispose();
+ chart = null;
+ });
+
+ $effect(() => {
+ chart?.setOption(option, { notMerge: true });
+ });
+</script>
+
+<div
+ bind:this={host}
+ style="height:{height}px; width:100%; min-width:0;"
+ role="img"
+ aria-label={ariaLabel}
+></div>
+
+<style>
+ div {
+ display: block;
+ }
+</style>
diff --git a/frontend/src/components/CodeEditor.svelte b/frontend/src/components/CodeEditor.svelte
new file mode 100644
index 0000000..544a6a4
--- /dev/null
+++ b/frontend/src/components/CodeEditor.svelte
@@ -0,0 +1,104 @@
+<script lang="ts">
+ import { onMount, onDestroy } from 'svelte';
+ import { EditorView, keymap, drawSelection } from '@codemirror/view';
+ import { EditorState, StateEffect } from '@codemirror/state';
+ import { python } from '@codemirror/lang-python';
+ import { indentWithTab } from '@codemirror/commands';
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
+
+ interface Props {
+ value: string;
+ onInput: (v: string) => void;
+ height?: number;
+ readOnly?: boolean;
+ label?: string;
+ }
+ let { value, onInput, height = 420, readOnly = false, label }: Props = $props();
+
+ let host: HTMLDivElement;
+ let view: EditorView | null = null;
+ let applyingExternal = false;
+ // svelte-ignore state_referenced_locally
+ let handler = onInput;
+ $effect(() => {
+ handler = onInput;
+ });
+
+ const setDoc = StateEffect.define<string>();
+
+ onMount(() => {
+ view = new EditorView({
+ parent: host,
+ state: EditorState.create({
+ doc: value ?? '',
+ extensions: [
+ readOnly ? EditorView.editable.of(false) : [],
+ history(),
+ drawSelection(),
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
+ python(),
+ EditorView.lineWrapping,
+ EditorView.theme({
+ '&': {
+ fontSize: '13px',
+ background: 'var(--surface)',
+ color: 'var(--text)',
+ fontFamily:
+ "ui-monospace, 'SF Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace"
+ },
+ '.cm-content': { padding: '10px 0', minHeight: `${height}px` },
+ '.cm-scroller': { lineHeight: '1.55' },
+ '&.cm-focused': { outline: 'none' },
+ '.cm-gutters': {
+ background: 'var(--surface)',
+ border: 'none',
+ color: 'var(--text-3)'
+ },
+ '.cm-activeLine': { background: 'var(--surface-2)' },
+ '.cm-activeLineGutter': { background: 'var(--surface-2)' }
+ }),
+ EditorView.updateListener.of((u) => {
+ if (applyingExternal) return;
+ if (u.docChanged) handler(u.state.doc.toString());
+ })
+ ]
+ })
+ });
+ return () => view?.destroy();
+ });
+
+ $effect(() => {
+ if (!view) return;
+ const current = view.state.doc.toString();
+ if (value !== current) {
+ applyingExternal = true;
+ view.dispatch({ changes: { from: 0, to: current.length, insert: value ?? '' } });
+ applyingExternal = false;
+ }
+ });
+
+ $effect(() => {
+ if (view) {
+ if (readOnly) view.contentDOM.setAttribute('aria-readonly', 'true');
+ else view.contentDOM.removeAttribute('aria-readonly');
+ }
+ });
+
+ onDestroy(() => view?.destroy());
+</script>
+
+<div>
+ {#if label}<span class="hint editor-label">{label}</span>{/if}
+ <div bind:this={host} class="cm-host"></div>
+</div>
+
+<style>
+ .cm-host {
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ overflow: hidden;
+ background: var(--surface);
+ }
+ .cm-host :global(.cm-editor) { min-height: 60px; }
+ .editor-label { display: block; margin-bottom: 0.25rem; }
+</style>
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>
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>
diff --git a/frontend/src/components/DiffView.svelte b/frontend/src/components/DiffView.svelte
new file mode 100644
index 0000000..cf0c1be
--- /dev/null
+++ b/frontend/src/components/DiffView.svelte
@@ -0,0 +1,71 @@
+<script lang="ts">
+ import { diffLines } from 'diff';
+
+ interface Props {
+ before: string;
+ after: string;
+ beforeLabel?: string;
+ afterLabel?: string;
+ maxHeight?: number;
+ }
+ let { before, after, beforeLabel = '原版本', afterLabel = '新版本', maxHeight = 480 }: Props = $props();
+
+ const parts = $derived(diffLines(before ?? '', after ?? ''));
+
+ const lines = $derived.by(() => {
+ const out: { t: 'a' | 'd' | 'c'; text: string }[] = [];
+ for (const p of parts) {
+ const pieces = p.value.replace(/\n$/, '').split('\n');
+ for (const piece of pieces) {
+ if (p.added) out.push({ t: 'a', text: piece });
+ else if (p.removed) out.push({ t: 'd', text: piece });
+ else out.push({ t: 'c', text: piece });
+ }
+ }
+ return out;
+ });
+</script>
+
+<div class="diff">
+ <div class="legend">
+ <span><i class="sw a"></i>新增({afterLabel})</span>
+ <span><i class="sw d"></i>删除({beforeLabel})</span>
+ </div>
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
+ <div class="body" style="max-height:{maxHeight}px" tabindex="0" aria-label="代码差异" role="region">
+ {#each lines as l, i (i)}
+ {#if l.t === 'a'}
+ <span class="ln add" aria-hidden="true"></span><span class="code add">+{l.text}</span>
+ {:else if l.t === 'd'}
+ <span class="ln del" aria-hidden="true"></span><span class="code del">−{l.text}</span>
+ {:else}
+ <span class="ln" aria-hidden="true"> </span><span class="code">{l.text}</span>
+ {/if}
+ {/each}
+ </div>
+</div>
+
+<style>
+ .diff .legend {
+ color: var(--text-3);
+ font-size: 0.8rem;
+ margin-bottom: 0.4rem;
+ display: flex;
+ gap: 1rem;
+ }
+ .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 4px; }
+ .sw.a { background: #c7f0e6; }
+ .sw.d { background: #fbd4d0; }
+ .body {
+ overflow: auto;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.5rem 0.7rem;
+ }
+ .body .code { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-all; display: inline-block; width: 100%; }
+ .add { background: #eafaf5; color: #14532d; }
+ .del { background: #fdf0ee; color: #7f1d1d; }
+ .ln { user-select: none; }
+ .code.add::before, .code.del::before { opacity: 0.6; }
+</style>
diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte
new file mode 100644
index 0000000..78cee45
--- /dev/null
+++ b/frontend/src/components/Layout.svelte
@@ -0,0 +1,241 @@
+<script lang="ts">
+ import type { Route } from '../lib/router';
+ import { go } from '../lib/router';
+ import { session } from '../lib/session.svelte';
+ import { postLogout } from '../lib/client';
+ import { projectOf, projectInvisible, projectsStore } from '../lib/listsStore.svelte';
+
+ interface Props {
+ route: Route;
+ children?: import('svelte').Snippet;
+ }
+ let { route, children }: Props = $props();
+
+ const topLeft: { name: string; label: string }[] = [
+ { name: 'projects', label: '我的项目' },
+ { name: 'datasets', label: '数据集' },
+ { name: 'runs', label: '实验记录' },
+ { name: 'usage', label: 'AI 用量' }
+ ];
+
+ const bottom: { name: string; label: string }[] =
+ session.me?.role === 'admin'
+ ? [
+ { name: 'account', label: '账户与安全' },
+ { name: 'admin', label: '管理员' }
+ ]
+ : [{ name: 'account', label: '账户与安全' }];
+
+ async function logout() {
+ await postLogout().catch(() => undefined);
+ session.set(null);
+ // drop cached projects/failure marks: the next session on this browser
+ // must not see (or be marked invisible by) the previous user's projects
+ projectsStore.clear();
+ go('/login');
+ }
+
+ const isProject = $derived(route.name.startsWith('project-'));
+ const projectTab = $derived(isProject ? route.name.replace('project-', '') : '');
+ const projectNav: [string, string][] = [
+ ['data', '数据'],
+ ['strategy', '策略'],
+ ['backtest', '回测'],
+ ['results', '结果']
+ ];
+ const activeProject = $derived(isProject ? projectOf(route.params.id) : null);
+ const projectHidden =
+ $derived(isProject && !activeProject && projectInvisible(route.params.id));
+ // Deep link / first navigation into a project tab: the sidebar store may not
+ // contain the project yet (ProjectsPage was never visited). Fetch it once so
+ // the 当前项目 name resolves without waiting for the tab's own lifecycle.
+ $effect(() => {
+ const id = isProject ? route.params.id : null;
+ if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined);
+ });
+</script>
+
+<div class="layout">
+ <aside class="sidebar" aria-label="全局导航">
+ <a href="#/projects" class="brand">
+ <span class="brand-mark" aria-hidden="true"></span>
+ <span class="brand-text">策研 <span class="brand-sub">Strategy Lab</span></span>
+ </a>
+
+ {#if isProject}
+ <div class="projctx">
+ <div class="nav-title">当前项目</div>
+ <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div>
+ {#each projectNav as [t, label] (t)}
+ <a
+ class="navlink"
+ href={`#/projects/${route.params.id}/${t}`}
+ aria-current={projectTab === t ? 'page' : undefined}
+ >{label}</a>
+ {/each}
+ <div class="divider" aria-hidden="true"></div>
+ </div>
+ {/if}
+
+ <div class="nav-title">工作台</div>
+ {#each topLeft as item (item.name)}
+ <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+
+ <div class="push"></div>
+ <div class="nav-title">账户</div>
+ {#each bottom as item (item.name)}
+ <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}>
+ {item.label}
+ </a>
+ {/each}
+ {#if session.me}
+ <div class="me">
+ <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span>
+ <button class="btn small ghost" onclick={() => logout()}>退出</button>
+ </div>
+ {/if}
+ </aside>
+
+ <main class="content" aria-label="主内容">
+ {@render children?.()}
+ </main>
+</div>
+
+<style>
+ .layout {
+ display: grid;
+ grid-template-columns: 240px 1fr;
+ min-height: 100vh;
+ }
+ .sidebar {
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ padding: 1rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+ }
+ .brand {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.3rem 0.4rem 0.6rem;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 0.4rem;
+ color: var(--text);
+ }
+ .brand:hover { text-decoration: none; }
+ .brand-mark {
+ width: 26px;
+ height: 26px;
+ border-radius: 7px;
+ background: var(--teal-strong);
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+ }
+ .brand-mark::after {
+ content: '';
+ position: absolute;
+ inset: 5px;
+ background: linear-gradient(
+ to top,
+ transparent 4px,
+ #f0fdfa 4px,
+ #f0fdfa 8px,
+ transparent 8px,
+ transparent 13px,
+ #f0fdfa 13px,
+ #f0fdfa 19px,
+ transparent 19px
+ ) left/ 4px 100% no-repeat;
+ opacity: 0.85;
+ }
+ .brand-text { font-weight: 700; font-size: 1.02rem; letter-spacing: 0.02em; }
+ .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.72rem; display: block; }
+ .nav-title {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-3);
+ padding: 0.4rem 0.5rem 0.1rem;
+ font-weight: 700;
+ }
+ .navlink {
+ display: block;
+ padding: 0.4rem 0.6rem;
+ border-radius: 8px;
+ color: var(--text-2);
+ font-size: 0.92rem;
+ }
+ .navlink:hover { background: var(--surface-2); text-decoration: none; }
+ .navlink[aria-current='page'] {
+ background: var(--teal-soft);
+ color: var(--teal-strong);
+ font-weight: 600;
+ }
+ .projctx .navlink { font-size: 0.88rem; }
+ .projname {
+ font-weight: 600;
+ padding: 0 0.6rem 0.25rem;
+ font-size: 0.94rem;
+ }
+ .divider {
+ height: 1px;
+ background: var(--border);
+ margin: 0.7rem 0.3rem;
+ }
+ .push { flex: 1; min-height: 1rem; }
+ .me {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.4rem;
+ padding: 0.5rem;
+ background: var(--surface-2);
+ border-radius: 8px;
+ font-size: 0.86rem;
+ }
+ .me-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-2);
+ }
+ .content {
+ padding: 1.6rem 2.2rem 3rem;
+ max-width: 1200px;
+ width: 100%;
+ /* grid/flex children default to min-content sizing; without min-width:0 a
+ wide table cell propagates min-content into the column and inflates the
+ whole document (375px probe measured 552px). */
+ min-width: 0;
+ }
+ @media (max-width: 900px) {
+ .layout { grid-template-columns: minmax(0, 1fr); }
+ .sidebar {
+ position: static;
+ height: auto;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ border-bottom: 1px solid var(--border);
+ border-right: none;
+ gap: 0.3rem;
+ min-width: 0;
+ }
+ .nav-title, .divider, .push { display: none; }
+ .navlink { padding: 0.35rem 0.7rem; }
+ /* the project tab row must wrap, not force one long line */
+ .projctx { display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 0; }
+ .projname { max-width: 100%; overflow-wrap: anywhere; }
+ .content { padding: 1.1rem 1rem 3rem; max-width: 100%; }
+ }
+</style>
diff --git a/frontend/src/components/Loading.svelte b/frontend/src/components/Loading.svelte
new file mode 100644
index 0000000..6de2ea2
--- /dev/null
+++ b/frontend/src/components/Loading.svelte
@@ -0,0 +1,24 @@
+<div class="loading-page" role="status" aria-live="polite">
+ <span class="spinner" aria-hidden="true"></span>
+ <p>加载中…</p>
+</div>
+
+<style>
+ .loading-page {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ color: var(--text-3);
+ }
+ .spinner {
+ width: 22px;
+ height: 22px;
+ border: 2.5px solid var(--teal);
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+</style>
diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte
new file mode 100644
index 0000000..c9f334c
--- /dev/null
+++ b/frontend/src/components/Modal.svelte
@@ -0,0 +1,63 @@
+<script lang="ts">
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ title: string;
+ onClose: () => void;
+ wide?: boolean;
+ children?: Snippet;
+ }
+ let { title, onClose, wide = false, children }: Props = $props();
+
+ function onBackdrop(e: MouseEvent) {
+ if (e.target === e.currentTarget) onClose();
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape') onClose();
+ }
+
+ let container: HTMLDivElement;
+ $effect(() => {
+ container?.focus();
+ });
+</script>
+
+<div
+ class="overlay"
+ onclick={onBackdrop}
+ role="presentation"
+ onkeydown={onKeydown}
+ tabindex="-1"
+ bind:this={container}
+>
+ <div class="dialog card" role="dialog" aria-modal="true" aria-label={title}>
+ <div class="spread">
+ <h2>{title}</h2>
+ <button class="btn ghost" onclick={onClose} aria-label="关闭">✕</button>
+ </div>
+ <div class="content">
+ {@render children?.()}
+ </div>
+ </div>
+</div>
+
+<style>
+ .overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 30, 35, 0.35);
+ display: grid;
+ place-items: center;
+ z-index: 50;
+ padding: 1rem;
+ }
+ .dialog {
+ width: min(760px, 100%);
+ max-height: 88vh;
+ overflow: auto;
+ padding: 1.1rem 1.2rem;
+ }
+
+ .content { margin-top: 0.6rem; }
+</style>
diff --git a/frontend/src/components/Page.svelte b/frontend/src/components/Page.svelte
new file mode 100644
index 0000000..a9549bb
--- /dev/null
+++ b/frontend/src/components/Page.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { Snippet } from 'svelte';
+
+ interface Props {
+ title: string;
+ subtitle?: string;
+ actions?: Snippet;
+ children?: Snippet;
+ wide?: boolean;
+ }
+ let { title, subtitle, actions, children, wide }: Props = $props();
+</script>
+
+<header class="page-head">
+ <div>
+ <h1>{title}</h1>
+ {#if subtitle}<p class="hint">{subtitle}</p>{/if}
+ </div>
+ {#if actions}{@render actions?.()}{/if}
+</header>
+<div class="page-body" class:wide>
+ {@render children?.()}
+</div>
+
+<style>
+ .page-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1.1rem;
+ flex-wrap: wrap;
+ }
+ .page-head .hint { margin-top: 0.2rem; }
+ .page-body { max-width: 780px; }
+ .page-body.wide { max-width: none; }
+</style>
diff --git a/frontend/src/components/Status.svelte b/frontend/src/components/Status.svelte
new file mode 100644
index 0000000..019c224
--- /dev/null
+++ b/frontend/src/components/Status.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ interface Props {
+ error?: string | null;
+ busy?: boolean;
+ busyText?: string;
+ empty?: string | boolean | null;
+ emptyHint?: string;
+ warnings?: string[];
+ }
+ let { error, busy, busyText = '加载中…', empty, emptyHint, warnings }: Props = $props();
+</script>
+
+{#if busy}
+ <div class="status" role="status">
+ <span class="spinner" aria-hidden="true"></span>
+ {busyText}
+ </div>
+{:else if error}
+ <div class="banner error" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <span>{error}</span>
+ </div>
+{:else if empty !== null && empty !== undefined && empty !== false}
+ <div class="state empty">
+ <p class="empty-title">{empty}</p>
+ {#if emptyHint}<p class="hint">{emptyHint}</p>{/if}
+ </div>
+{/if}
+{#if warnings && warnings.length > 0}
+ <div class="stack">
+ {#each warnings as w, i (i)}
+ <div class="banner warn"><span aria-hidden="true">△</span><span>{w}</span></div>
+ {/each}
+ </div>
+{/if}
+
+<style>
+ .status {
+ color: var(--text-3);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.9rem 0;
+ font-size: 0.9rem;
+ }
+ .spinner {
+ width: 15px;
+ height: 15px;
+ border: 2px solid var(--teal);
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+ .empty {
+ text-align: center;
+ padding: 2.2rem 1rem;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius);
+ color: var(--text-2);
+ background: var(--surface);
+ }
+ .empty-title { font-weight: 600; margin-bottom: 0.2rem; }
+</style>