diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/components | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/Chart.svelte | 67 | ||||
| -rw-r--r-- | frontend/src/components/CodeEditor.svelte | 104 | ||||
| -rw-r--r-- | frontend/src/components/DatasetCard.svelte | 157 | ||||
| -rw-r--r-- | frontend/src/components/DatasetWizard.svelte | 374 | ||||
| -rw-r--r-- | frontend/src/components/DiffView.svelte | 71 | ||||
| -rw-r--r-- | frontend/src/components/Layout.svelte | 241 | ||||
| -rw-r--r-- | frontend/src/components/Loading.svelte | 24 | ||||
| -rw-r--r-- | frontend/src/components/Modal.svelte | 63 | ||||
| -rw-r--r-- | frontend/src/components/Page.svelte | 37 | ||||
| -rw-r--r-- | frontend/src/components/Status.svelte | 66 |
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> |
