summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.svelte71
-rw-r--r--frontend/src/app.css249
-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
-rw-r--r--frontend/src/lib/__tests__/state.test.ts21
-rw-r--r--frontend/src/lib/api.test.ts94
-rw-r--r--frontend/src/lib/api.ts70
-rw-r--r--frontend/src/lib/chartResize.test.ts131
-rw-r--r--frontend/src/lib/client.ts118
-rw-r--r--frontend/src/lib/coverage.test.ts43
-rw-r--r--frontend/src/lib/coverage.ts52
-rw-r--r--frontend/src/lib/draftGuard.test.ts38
-rw-r--r--frontend/src/lib/draftGuard.ts46
-rw-r--r--frontend/src/lib/format.ts118
-rw-r--r--frontend/src/lib/instrumentSearch.test.ts121
-rw-r--r--frontend/src/lib/instrumentSearch.ts120
-rw-r--r--frontend/src/lib/layout.test.ts18
-rw-r--r--frontend/src/lib/listsStore.svelte.ts92
-rw-r--r--frontend/src/lib/mobileCss.test.ts63
-rw-r--r--frontend/src/lib/resultsAccounting.test.ts99
-rw-r--r--frontend/src/lib/router.test.ts51
-rw-r--r--frontend/src/lib/router.ts43
-rw-r--r--frontend/src/lib/session.svelte.ts45
-rw-r--r--frontend/src/lib/sidebarCurrentProject.test.ts186
-rw-r--r--frontend/src/lib/state.ts15
-rw-r--r--frontend/src/lib/strategyTab.test.ts95
-rw-r--r--frontend/src/lib/types.ts244
-rw-r--r--frontend/src/lib/wizardError.test.ts18
-rw-r--r--frontend/src/main.ts7
-rw-r--r--frontend/src/pages/AccountPage.svelte189
-rw-r--r--frontend/src/pages/AdminPage.svelte371
-rw-r--r--frontend/src/pages/AuthPages.svelte187
-rw-r--r--frontend/src/pages/DatasetsPage.svelte8
-rw-r--r--frontend/src/pages/ProjectBacktestTab.svelte384
-rw-r--r--frontend/src/pages/ProjectDataTab.svelte85
-rw-r--r--frontend/src/pages/ProjectPage.svelte23
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte299
-rw-r--r--frontend/src/pages/ProjectStrategyTab.svelte420
-rw-r--r--frontend/src/pages/ProjectsPage.svelte112
-rw-r--r--frontend/src/pages/RunsPage.svelte180
-rw-r--r--frontend/src/pages/UsagePage.svelte84
-rw-r--r--frontend/src/test-setup.ts36
50 files changed, 5850 insertions, 0 deletions
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..ae70ba4
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,71 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type { Route } from './lib/router';
+ import { routeFromHash, replace } from './lib/router';
+ import { session } from './lib/session.svelte';
+ import Layout from './components/Layout.svelte';
+ import AuthPages from './pages/AuthPages.svelte';
+ import ProjectsPage from './pages/ProjectsPage.svelte';
+ import ProjectPage from './pages/ProjectPage.svelte';
+ import DatasetsPage from './pages/DatasetsPage.svelte';
+ import RunsPage from './pages/RunsPage.svelte';
+ import UsagePage from './pages/UsagePage.svelte';
+ import AccountPage from './pages/AccountPage.svelte';
+ import AdminPage from './pages/AdminPage.svelte';
+ import Loading from './components/Loading.svelte';
+
+ let route = $state<Route>(routeFromHash());
+
+ window.addEventListener('hashchange', () => {
+ route = routeFromHash();
+ });
+
+ const publicRoutes = new Set(['login', 'register', 'reset']);
+ const projectRoutes = new Set(['project-data', 'project-strategy', 'project-backtest', 'project-results']);
+
+ onMount(() => {
+ void session.refresh();
+ });
+
+ $effect(() => {
+ if (publicRoutes.has(route.name)) return;
+ if (session.status === 'anon') {
+ replace('/login');
+ return;
+ }
+ if (session.me && route.name === 'admin' && session.me.role !== 'admin') {
+ replace('/projects');
+ }
+ });
+
+ const showAuth = $derived(publicRoutes.has(route.name) || session.status === 'anon');
+ const booting = $derived(session.status === 'loading' && !publicRoutes.has(route.name));
+</script>
+
+<div class="app-root">
+ {#if booting}
+ <Loading />
+ {:else if showAuth}
+ <AuthPages {route} />
+ {:else}
+ <Layout {route}>
+ {#if route.name === 'projects'}
+ <ProjectsPage />
+ {:else if projectRoutes.has(route.name)}
+ <ProjectPage {route} />
+ {:else if route.name === 'datasets'}
+ <DatasetsPage />
+ {:else if route.name === 'runs'}
+ <RunsPage />
+ {:else if route.name === 'usage'}
+ <UsagePage />
+ {:else if route.name === 'account'}
+ <AccountPage />
+ {:else if route.name === 'admin'}
+ <AdminPage />
+ {:else}
+ <ProjectsPage />
+ {/if}
+ </Layout>
+ {/if}
+</div>
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..c9ed4be
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,249 @@
+:root {
+ --bg: #f7fafb;
+ --surface: #ffffff;
+ --surface-2: #f1f5f6;
+ --border: #e2e8ea;
+ --border-strong: #cbd5d8;
+ --text: #1e2a30;
+ --text-2: #4b5a62;
+ --text-3: #7b8a92;
+ --teal: #0d9488;
+ --teal-strong: #0f766e;
+ --teal-soft: #e6f4f2;
+ --ink: #12312d;
+ --danger: #b91c1c;
+ --danger-soft: #fdf2f2;
+ --warn: #92400e;
+ --warn-soft: #fff9ec;
+ --ok: #15803d;
+ --ok-soft: #ecfdf3;
+ --radius: 10px;
+ --shadow: 0 1px 2px rgba(16, 42, 49, 0.05), 0 2px 8px rgba(16, 42, 49, 0.05);
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace;
+ --font: -apple-system, 'SF Pro Text', BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
+ 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 14.5px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3, h4 { font-weight: 600; margin: 0; }
+h1 { font-size: 1.35rem; }
+h2 { font-size: 1.1rem; }
+h3 { font-size: 1rem; }
+a { color: var(--teal-strong); text-decoration: none; }
+a:hover { text-decoration: underline; }
+p { margin: 0.4rem 0; }
+
+button, input, select, textarea { font: inherit; color: inherit; }
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.45rem 0.95rem;
+ border-radius: var(--radius);
+ border: 1px solid var(--border-strong);
+ background: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 0.92rem;
+ transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
+ white-space: nowrap;
+}
+.btn:hover:not(:disabled) { background: var(--surface-2); }
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
+ outline: 2px solid var(--teal);
+ outline-offset: 1px;
+}
+.btn.primary {
+ background: var(--teal-strong);
+ border-color: var(--teal-strong);
+ color: #fff;
+}
+.btn.primary:hover:not(:disabled) { background: #115e59; }
+.btn.danger { color: var(--danger); border-color: #f2c7c7; }
+.btn.danger:hover:not(:disabled) { background: var(--danger-soft); }
+.btn.ghost { border-color: transparent; background: transparent; }
+.btn.ghost:hover:not(:disabled) { background: var(--surface-2); }
+.btn.small { padding: 0.25rem 0.6rem; font-size: 0.83rem; }
+.btn[aria-pressed='true'] { background: var(--teal-soft); border-color: var(--teal); color: var(--ink); }
+
+label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; }
+input, select, textarea.plain {
+ width: 100%;
+ padding: 0.45rem 0.65rem;
+ border: 1px solid var(--border-strong);
+ border-radius: 8px;
+ background: var(--surface);
+ font-size: 0.92rem;
+}
+input[type='checkbox'], input[type='radio'] { width: auto; }
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1rem 1.15rem;
+ box-shadow: var(--shadow);
+}
+.card + .card { margin-top: 0.9rem; }
+
+.hint { color: var(--text-3); font-size: 0.83rem; }
+
+table.data {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.88rem;
+}
+table.data th {
+ text-align: left;
+ font-weight: 600;
+ color: var(--text-2);
+ border-bottom: 1px solid var(--border-strong);
+ padding: 0.45rem 0.6rem;
+ white-space: nowrap;
+}
+table.data td {
+ border-bottom: 1px solid var(--border);
+ padding: 0.42rem 0.6rem;
+ vertical-align: top;
+}
+table.data tr:hover td { background: var(--surface-2); }
+.num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
+
+.badge {
+ display: inline-block;
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ font-weight: 600;
+ border: 1px solid transparent;
+}
+.badge.teal { background: var(--teal-soft); color: var(--teal-strong); }
+.badge.grey { background: var(--surface-2); color: var(--text-2); border-color: var(--border); }
+.badge.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0ddb4; }
+.badge.danger { background: var(--danger-soft); color: var(--danger); border-color: #f2c7c7; }
+.badge.ok { background: var(--ok-soft); color: var(--ok); border-color: #c3ecd0; }
+
+.banner {
+ border-radius: var(--radius);
+ padding: 0.65rem 0.9rem;
+ font-size: 0.9rem;
+ border: 1px solid;
+ display: flex;
+ gap: 0.6rem;
+ align-items: flex-start;
+}
+.banner.error { background: var(--danger-soft); border-color: #f2c7c7; color: var(--danger); }
+.banner.warn { background: var(--warn-soft); border-color: #f0ddb4; color: var(--warn); }
+.banner.info { background: var(--teal-soft); border-color: #c8eae6; color: var(--teal-strong); }
+
+.stack { display: flex; flex-direction: column; gap: 0.75rem; }
+.row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
+.spread { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
+.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
+.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
+
+.tabs {
+ display: flex;
+ gap: 0.25rem;
+ border-bottom: 1px solid var(--border);
+}
+.tabs button {
+ padding: 0.55rem 1rem;
+ border: none;
+ background: none;
+ color: var(--text-2);
+ font-size: 0.95rem;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ border-radius: 6px 6px 0 0;
+}
+.tabs button:hover { background: var(--surface-2); }
+.tabs button[aria-selected='true'] {
+ color: var(--teal-strong);
+ font-weight: 600;
+ border-bottom-color: var(--teal);
+}
+
+/* metric tiles shared by the results card and other metric rows; without
+ these the six result metrics stacked vertically with large blank space */
+.metric-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 0.7rem;
+}
+.metric {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.6rem 0.85rem;
+ min-width: 0;
+}
+.mlabel { color: var(--text-3); font-size: 0.8rem; }
+.mvalue {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+ overflow-wrap: anywhere;
+}
+.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; }
+.table-wrap { overflow-x: auto; min-width: 0; }
+
+@media (max-width: 900px) {
+ .cols-2 { grid-template-columns: minmax(0, 1fr); }
+ .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); }
+}
+
+code, pre {
+ font-family: var(--mono);
+ font-size: 0.85rem;
+}
+pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
+
+.sr-only {
+ position: absolute;
+ width: 1px; height: 1px;
+ padding: 0; margin: -1px;
+ overflow: hidden; clip: rect(0 0 0 0);
+ border: 0;
+}
+
+/* ---- narrow-viewport wrapping (no global content hiding) ----
+ Real provider error strings contain long unbreakable URLs; .spread/.stack
+ children would otherwise propagate min-content outwards and inflate the
+ document width on mobile. `overflow-wrap` keeps every word reachable while
+ wrapping; nothing is hidden — long tables get a local scroll container. */
+.card, .stack, .row, .spread, .banner { min-width: 0; }
+.banner { overflow-wrap: anywhere; }
+.banner .err-list { overflow-wrap: anywhere; }
+.card table.data { min-width: 0; }
+
+@media (max-width: 900px) {
+ /* collapse multi-column forms: the dataset wizard grid3 + label min-widths
+ measured 523px min-content at 375px viewport (mobile-overflow-probe) */
+ .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }
+ /* wrap table cells instead of nowrap inflating min-content; a truly wide
+ tabular block still gets its own local scrollbar (.scrollx), content
+ remains fully accessible without a document-level horizontal overflow */
+ table.data th, table.data td {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+ .btn { white-space: normal; }
+}
+
+
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>
diff --git a/frontend/src/lib/__tests__/state.test.ts b/frontend/src/lib/__tests__/state.test.ts
new file mode 100644
index 0000000..245c38c
--- /dev/null
+++ b/frontend/src/lib/__tests__/state.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { extractStatusMessage } from '../state';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('extractStatusMessage', () => {
+ it('prefers ApiError.code-mapped Chinese text', () => {
+ const msg = extractStatusMessage({
+ status: 409,
+ code: 'stale_generation',
+ message: '草稿已被其他修改更新'
+ });
+ expect(msg).toContain('409');
+ expect(msg).toContain('草稿已被其他修改更新');
+ });
+
+ it('handles unknown errors without leaking internals', () => {
+ const msg = extractStatusMessage({ message: 'boom' });
+ expect(msg).toContain('出错');
+ });
+});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..b66d9ca
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { apiFetch, ApiError } from './api';
+import type { ApiErrorBody } from './api';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('apiFetch', () => {
+ it('parses JSON success responses', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }))
+ );
+ const res = await apiFetch<void, { ok: number }>('/api/health');
+ expect(res).toEqual({ ok: 1 });
+ });
+
+ it('returns undefined for empty body', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 204 }))
+ );
+ const res = await apiFetch<void, undefined>('/api/auth/logout', { method: 'POST' });
+ expect(res).toBeUndefined();
+ });
+
+ it('raises ApiError with structured error body', async () => {
+ const body: ApiErrorBody = {
+ error: { code: 'stale_generation', message: '草稿已被其他修改更新', details: { expected: 'g1' } }
+ };
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify(body), { status: 409, headers: { 'content-type': 'application/json' } }))
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(409);
+ expect(err.code).toBe('stale_generation');
+ expect(err.message).toBe('草稿已被其他修改更新');
+ expect(err.details).toEqual({ expected: 'g1' });
+ });
+
+ it('raises ApiError for non-JSON error body', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('oops', { status: 500 }))
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(500);
+ expect(err.code).toBe('server_error');
+ });
+
+ it('raises network ApiError', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new TypeError('failed');
+ })
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(0);
+ expect(err.code).toBe('network');
+ });
+
+ it('sends JSON content type for body requests and does not for bodyless', async () => {
+ const f = vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', f);
+ await apiFetch('/api/projects', { method: 'POST', body: { name: 'x' } });
+ expect(f).toHaveBeenCalledWith(
+ '/api/projects',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({ 'content-type': 'application/json' }),
+ credentials: 'same-origin'
+ })
+ );
+ await apiFetch('/api/projects');
+ expect(f).toHaveBeenLastCalledWith(
+ '/api/projects',
+ expect.not.objectContaining({ headers: expect.objectContaining({ 'content-type': 'application/json' }) })
+ );
+ });
+
+ it('rejects a 200 without JSON body per API contract', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('<html>oops</html>', { status: 200, headers: { 'content-type': 'text/html' } }))
+ );
+ const err = await apiFetch<void>('/api/projects').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.code).toBe('bad_response');
+ });
+});
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..923a6e8
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,70 @@
+export interface ApiErrorShape {
+ code: string;
+ message: string;
+ details?: unknown;
+}
+
+export interface ApiErrorBody {
+ error: ApiErrorShape;
+}
+
+export class ApiError extends Error {
+ status: number;
+ code: string;
+ details?: unknown;
+
+ constructor(status: number, code: string, message: string, details?: unknown) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+interface Options {
+ method?: string;
+ body?: unknown;
+ signal?: AbortSignal;
+}
+
+export async function apiFetch<T = unknown, R = T>(url: string, options: Options = {}): Promise<R> {
+ const headers: Record<string, string> = { accept: 'application/json' };
+ let bodyStr: string | undefined;
+ if (options.body !== undefined) {
+ headers['content-type'] = 'application/json';
+ bodyStr = JSON.stringify(options.body);
+ }
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: options.method ?? 'GET',
+ headers,
+ body: bodyStr,
+ credentials: 'same-origin',
+ signal: options.signal
+ });
+ } catch (e) {
+ if (e instanceof DOMException && e.name === 'AbortError') throw e;
+ throw new ApiError(0, 'network', '网络请求失败,请确认服务已启动后重试');
+ }
+ if (res.status === 204) {
+ return undefined as R;
+ }
+ const contentType = res.headers.get('content-type') ?? '';
+ if (!contentType.includes('application/json')) {
+ if (!res.ok) {
+ throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`);
+ }
+ throw new ApiError(res.status, 'bad_response', '服务返回了非预期的非 JSON 响应(接口约定为 JSON)');
+ }
+ const parsed: unknown = await res.json().catch(() => null);
+ if (!res.ok) {
+ const errBody = parsed as ApiErrorBody | null;
+ if (errBody?.error) {
+ throw new ApiError(res.status, errBody.error.code, errBody.error.message, errBody.error.details);
+ }
+ throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`);
+ }
+ return parsed as R;
+}
diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts
new file mode 100644
index 0000000..95beaad
--- /dev/null
+++ b/frontend/src/lib/chartResize.test.ts
@@ -0,0 +1,131 @@
+// @ts-nocheck
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { mount, unmount } from 'svelte';
+import Chart from '../components/Chart.svelte';
+
+// Bounded chart-overflow regression (parent live QA: viewport 375,
+// document.scrollWidth 1133 after desktop->mobile resize):
+//
+// echarts.init previously received an explicit width, and ECharts KEEPS the
+// explicitly passed size on chart.resize(); a desktop->mobile viewport change
+// therefore left the rendered SVG at the desktop width, overflowing the page.
+// The ResizeObserver handler must now re-measure the host and pass the
+// measured size into chart.resize() so the SVG actually shrinks with the
+// viewport. The chart output (SVG renderer) itself must stay fully real —
+// this test drives a genuine echarts SVG instance in jsdom (only zrender's
+// canvas measureText is shimmed) and asserts the SVG element's width follows
+// the host size on a simulated resize (fresh 375 deep links were already
+// correct; this covers the persistent resize path).
+
+interface CapturedRO {
+ callback: () => void;
+ el: Element;
+}
+let captured: CapturedRO[] = [];
+
+function makeResizeObserverCtor() {
+ return class FakeResizeObserver {
+ cb: () => void;
+ el: Element | null = null;
+ constructor(cb: ResizeObserverCallback) {
+ this.cb = cb;
+ captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never });
+ }
+ observe(el: Element) {
+ const capture = captured[captured.length - 1];
+ if (capture) capture.el = el;
+ }
+ unobserve() {}
+ disconnect() {}
+ };
+}
+
+const baseOption = {
+ grid: { top: 24, left: 60, right: 24, bottom: 54 },
+ xAxis: { type: 'category', data: ['a', 'b'] },
+ yAxis: { type: 'value' },
+ series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }]
+};
+
+function hostSvgWidth(host: HTMLElement): number | null {
+ const svg = host.querySelector('svg');
+ if (!svg) return null;
+ const attr = svg.getAttribute('width');
+ if (attr) return parseFloat(attr);
+ const style = svg.getAttribute('style') ?? '';
+ const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style);
+ return m ? parseFloat(m[1]) : null;
+}
+
+describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => {
+ beforeEach(() => {
+ captured = [];
+ vi.stubGlobal('ResizeObserver', makeResizeObserverCtor());
+ });
+
+ it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => {
+ const host = document.createElement('div');
+ host.style.width = '900px';
+ document.body.appendChild(host);
+ const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
+ Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } });
+ try {
+ const inst = mount(Chart, {
+ target: host,
+ props: { option: baseOption, height: 340, ariaLabel: '曲线' }
+ });
+ // let the lazy echarts import mount
+ await new Promise((r) => setTimeout(r, 120));
+ const ro = captured[captured.length - 1];
+ expect(ro).toBeTruthy();
+ expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host
+ const inner = ro.el as HTMLElement;
+
+ const svg = inner.querySelector('svg');
+ expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy();
+ // initial size: measured/fallback (target has no clientWidth -> 640)
+ expect(parseFloat(svg!.getAttribute('width')!)).toBe(640);
+
+ // seed the chart at a "desktop" width, then shrink the host (viewport
+ // resize) and fire the observer: svg must follow, not stay sticky
+ const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } });
+ setW(900);
+ ro.callback();
+ await new Promise((r) => setTimeout(r, 40));
+ expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900);
+
+ // now the actual overflow scenario: shrink to 375-device width
+ setW(340);
+ ro.callback();
+ await new Promise((r) => setTimeout(r, 40));
+
+ const svgAfter = inner.querySelector('svg')!;
+ const wAfter = svgAfter!.getAttribute('width');
+ expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy();
+ expect(parseFloat(wAfter!)).toBe(340);
+
+ unmount(inst);
+ host.remove();
+ vi.unstubAllGlobals();
+ } finally {
+ Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 });
+ }
+ });
+
+ it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(Chart, {
+ target: host,
+ props: { option: baseOption, height: 340, ariaLabel: '曲线' }
+ });
+ await new Promise((r) => setTimeout(r, 120));
+ const ro = captured[captured.length - 1];
+ expect(ro?.el).toBeTruthy();
+ // host with zero layout: callback must be a no-op (no degenerate 0-width resize)
+ expect(() => ro.callback()).not.toThrow();
+ unmount(inst);
+ host.remove();
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts
new file mode 100644
index 0000000..4937902
--- /dev/null
+++ b/frontend/src/lib/client.ts
@@ -0,0 +1,118 @@
+// Central typed binding layer for the 策研 Strategy Lab HTTP contract.
+import { apiFetch } from './api';
+import type {
+ AIAssist,
+ AIUsage,
+ AdminUserUpdate,
+ BacktestResult,
+ Capabilities,
+ Dataset,
+ DatasetPreview,
+ DatasetRequest,
+ InstrumentSearchResponse,
+ Invitation,
+ Project,
+ Run,
+ SessionInfo,
+ User,
+ Version
+} from './types';
+
+export const getHealth = () =>
+ apiFetch<{ status: string; version: string; worker_available: boolean; ai_configured: boolean }>('/api/health');
+
+// ---- auth ----
+export const postLogin = (email: string, password: string) =>
+ apiFetch<{ user: User }>('/api/auth/login', { method: 'POST', body: { email, password } });
+export const postLogout = () => apiFetch<void>('/api/auth/logout', { method: 'POST', body: {} });
+export const getMe = () => apiFetch<{ user: User }>('/api/auth/me');
+export const postRegister = (invite_token: string, name: string, email: string, password: string) =>
+ apiFetch<{ user: User }>('/api/auth/register', { method: 'POST', body: { invite_token, name, email, password } });
+export const patchProfile = (name: string) => apiFetch<{ user: User }>('/api/auth/profile', { method: 'PATCH', body: { name } });
+export const postPassword = (current_password: string, new_password: string) =>
+ apiFetch<void>('/api/auth/password', { method: 'POST', body: { current_password, new_password } });
+export const getSessions = () => apiFetch<{ items: SessionInfo[] }>('/api/auth/sessions');
+export const deleteSession = (id: string) => apiFetch<void>(`/api/auth/sessions/${id}`, { method: 'DELETE', body: {} });
+export const postResetPassword = (token: string, new_password: string) =>
+ apiFetch<{ user?: User }>('/api/auth/reset-password', { method: 'POST', body: { token, new_password } });
+
+// ---- capabilities ----
+export const getCapabilities = () => apiFetch<Capabilities>('/api/capabilities');
+
+// ---- instruments ----
+export const searchInstruments = (q: string, signal?: AbortSignal) =>
+ apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal });
+
+// ---- projects ----
+export const listProjects = () => apiFetch<{ items: Project[] }>('/api/projects');
+export const createProject = (name: string, description?: string) =>
+ apiFetch<Project>('/api/projects', { method: 'POST', body: { name, description } });
+export const getProject = (id: string) => apiFetch<Project>(`/api/projects/${id}`);
+export const patchProject = (id: string, body: { name?: string; description?: string }) =>
+ apiFetch<Project>(`/api/projects/${id}`, { method: 'PATCH', body });
+export const putDraft = (id: string, code: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/projects/${id}/draft`, { method: 'PUT', body: { code, expected_generation } });
+export const listVersions = (id: string) => apiFetch<{ items: Version[] }>(`/api/projects/${id}/versions`);
+export const postVersion = (id: string, message: string) =>
+ apiFetch<Version>(`/api/projects/${id}/versions`, { method: 'POST', body: { message } });
+export const getVersionCode = (id: string, versionId: string) => apiFetch<{ code: string }>(`/api/projects/${id}/versions/${versionId}`);
+export const postRestore = (id: string, version_id: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/projects/${id}/restore`, { method: 'POST', body: { version_id, expected_generation } });
+
+// ---- datasets ----
+export const listDatasets = () => apiFetch<{ items: Dataset[] }>('/api/datasets');
+export const createDataset = (body: DatasetRequest) => apiFetch<Dataset>('/api/datasets', { method: 'POST', body });
+export const getDataset = (id: string) => apiFetch<Dataset>(`/api/datasets/${id}`);
+export const getDatasetPreview = (id: string) => apiFetch<DatasetPreview>(`/api/datasets/${id}/preview`);
+
+// ---- runs ----
+export const listRuns = (project_id?: string) =>
+ apiFetch<{ items: Run[] }>(project_id ? `/api/runs?project_id=${encodeURIComponent(project_id)}` : '/api/runs');
+export interface RunCreateBody {
+ project_id: string;
+ dataset_id: string;
+ capital: number;
+ commission: number;
+ slippage: number;
+ benchmark_symbol?: string | null;
+ parameters?: Record<string, unknown>;
+ acknowledge_warnings?: boolean;
+}
+export const createRun = (body: RunCreateBody) => apiFetch<Run>('/api/runs', { method: 'POST', body });
+export const getRun = (id: string) => apiFetch<Run>(`/api/runs/${id}`);
+export const cancelRun = (id: string) => apiFetch<Run>(`/api/runs/${id}/cancel`, { method: 'POST', body: {} });
+export const rerunRun = (id: string, use_original_data = true) =>
+ apiFetch<Run>(`/api/runs/${id}/rerun`, { method: 'POST', body: { use_original_data } });
+
+// ---- AI ----
+export const postAIAssist = (project_id: string, instruction: string, expected_generation: number) =>
+ apiFetch<AIAssist>('/api/ai/assist', { method: 'POST', body: { project_id, instruction, expected_generation } });
+export const postAIAccept = (aiId: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/ai/${aiId}/accept`, { method: 'POST', body: { expected_generation } });
+export const getAIUsage = () => apiFetch<AIUsage>('/api/ai/usage');
+
+// ---- admin ----
+export const listAdminUsers = () => apiFetch<{ items: User[] }>('/api/admin/users');
+export const patchAdminUser = (id: string, body: AdminUserUpdate) =>
+ apiFetch<User>(`/api/admin/users/${id}`, { method: 'PATCH', body });
+export const postAdminUserPassword = (id: string) =>
+ apiFetch<{ reset_token: string; expires_at: string }>(`/api/admin/users/${id}/reset-password`, { method: 'POST', body: {} });
+export const createInvitation = (body: { email?: string; role?: string; expires_hours?: number }) =>
+ apiFetch<{ token: string; expires_at: string }>('/api/admin/invitations', { method: 'POST', body });
+export const listInvitations = () => apiFetch<{ items: Invitation[] }>('/api/admin/invitations');
+export const deleteInvitation = (id: string) => apiFetch<void>(`/api/admin/invitations/${id}`, { method: 'DELETE', body: {} });
+export const getAdminAudit = () =>
+ apiFetch<{ items: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] }>(
+ '/api/admin/audit'
+ );
+
+// helpers
+export async function poll<T>(fn: () => Promise<T>, until: (v: T) => boolean, intervalMs = 1500): Promise<T> {
+ for (;;) {
+ const v = await fn();
+ if (until(v)) return v;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+}
+
+export type { BacktestResult };
diff --git a/frontend/src/lib/coverage.test.ts b/frontend/src/lib/coverage.test.ts
new file mode 100644
index 0000000..cf59d54
--- /dev/null
+++ b/frontend/src/lib/coverage.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { summarizeCoverage, type CoverageEntry } from './coverage';
+
+describe('summarizeCoverage', () => {
+ const entry = (o: Partial<CoverageEntry>): CoverageEntry => ({
+ instrument: '600000',
+ market: 'SH',
+ asset_type: 'stock',
+ requested_start: '',
+ requested_end: '',
+ actual_start: '',
+ actual_end: '',
+ row_count: 1,
+ ...o
+ });
+
+ it('lists unaligned starts without invented dates', () => {
+ const rows = [
+ entry({ instrument: 'A', actual_start: '2018-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }),
+ entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' })
+ ];
+ const s = summarizeCoverage(rows);
+ expect(s.common.start).toBe('2020-01-02');
+ expect(s.common.end).toBe('2024-12-31');
+ expect(s.warnings.includes('B 起始数据较其他标的更晚(2020-01-02)')).toBe(true);
+ });
+
+ it('reports missing rows', () => {
+ const rows = [entry({ instrument: 'C', row_count: 0, actual_start: '', actual_end: '' })];
+ const s = summarizeCoverage(rows);
+ expect(s.warnings.includes('C 没有返回任何数据行')).toBe(true);
+ expect(s.common.start).toBeNull();
+ });
+
+ it('reports different end dates', () => {
+ const rows = [
+ entry({ instrument: 'A', actual_start: '2020-01-02', actual_end: '2024-12-31' }),
+ entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-11-29' })
+ ];
+ const s = summarizeCoverage(rows);
+ expect(s.warnings.some((w) => w.includes('B 较早结束'))).toBe(true);
+ });
+});
diff --git a/frontend/src/lib/coverage.ts b/frontend/src/lib/coverage.ts
new file mode 100644
index 0000000..c9a8447
--- /dev/null
+++ b/frontend/src/lib/coverage.ts
@@ -0,0 +1,52 @@
+export interface CoverageEntry {
+ instrument: string;
+ market?: string;
+ asset_type?: string;
+ requested_start?: string | null;
+ requested_end?: string | null;
+ actual_start?: string | null;
+ actual_end?: string | null;
+ row_count?: number;
+ warnings?: string[];
+}
+
+export interface CoverageSummary {
+ common: { start: string | null; end: string | null };
+ warnings: string[];
+}
+
+export function summarizeCoverage(entries: CoverageEntry[]): CoverageSummary {
+ const warnings: string[] = [];
+ const withData = entries.filter((e) => (e.row_count ?? 0) > 0 && e.actual_start && e.actual_end);
+ for (const e of entries) {
+ if ((e.row_count ?? 0) === 0) {
+ warnings.push(`${e.instrument} 没有返回任何数据行`);
+ } else if (!e.actual_start || !e.actual_end) {
+ warnings.push(`${e.instrument} 数据区间不完整`);
+ }
+ }
+ let common: { start: string | null; end: string | null } = { start: null, end: null };
+ if (withData.length > 0) {
+ const starts = withData.map((e) => e.actual_start as string).sort();
+ const startsAsc = [...starts];
+ const earliest = startsAsc[0];
+ const lateStart = starts[starts.length - 1];
+ if (lateStart > earliest) {
+ const owner = withData.find((e) => e.actual_start === lateStart);
+ if (owner) {
+ warnings.push(`${owner.instrument} 起始数据较其他标的更晚(${lateStart})`);
+ }
+ }
+ const ends = withData.map((e) => e.actual_end as string);
+ const commonEnd = ends.sort()[0];
+ const earlyOwners = withData.filter((e) => e.actual_end === commonEnd);
+ const otherEnds = withData.filter((e) => (e.actual_end as string) > commonEnd);
+ if (otherEnds.length > 0) {
+ for (const e of earlyOwners) {
+ warnings.push(`${e.instrument} 较早结束(${commonEnd}),造成共有区间缺失`);
+ }
+ }
+ common = { start: lateStart, end: commonEnd };
+ }
+ return { common, warnings };
+}
diff --git a/frontend/src/lib/draftGuard.test.ts b/frontend/src/lib/draftGuard.test.ts
new file mode 100644
index 0000000..a18de48
--- /dev/null
+++ b/frontend/src/lib/draftGuard.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { unifiedDiffStats, ConflictResolver } from './draftGuard';
+
+describe('unifiedDiffStats', () => {
+ it('counts added and removed lines', () => {
+ const s = unifiedDiffStats('a\nb\n', 'a\nc\nd\n');
+ expect(s.added).toBe(2);
+ expect(s.removed).toBe(1);
+ });
+
+ it('is zero for identical inputs', () => {
+ const s = unifiedDiffStats('same', 'same');
+ expect(s.added).toBe(0);
+ expect(s.removed).toBe(0);
+ });
+});
+
+describe('ConflictResolver', () => {
+ const local = { generation: 1, code: 'x = 1\n' };
+
+ it('flags stale response: server generation ahead of local base', () => {
+ const r = new ConflictResolver(local);
+ const verdict = r.onServerState({ generation: 2, code: 'x = 2\n' }, local.generation);
+ expect(verdict.stale).toBe(true);
+ });
+
+ it('not stale when server generation equals local base generation', () => {
+ const r = new ConflictResolver(local);
+ const verdict = r.onServerState({ generation: 1, code: 'x = 1\n' }, local.generation);
+ expect(verdict.stale).toBe(false);
+ });
+
+ it('force-overwrite plan uses server generation to retry save', () => {
+ const r = new ConflictResolver(local);
+ const plan = r.retryPlan({ generation: 2, code: 'x = 2\n' } as const);
+ expect(plan).toEqual({ generation: 2, code: 'x = 1\n' });
+ });
+});
diff --git a/frontend/src/lib/draftGuard.ts b/frontend/src/lib/draftGuard.ts
new file mode 100644
index 0000000..d6a5222
--- /dev/null
+++ b/frontend/src/lib/draftGuard.ts
@@ -0,0 +1,46 @@
+import { diffLines, diffWordsWithSpace } from 'diff';
+
+export interface DiffStats {
+ added: number;
+ removed: number;
+}
+
+export function unifiedDiffStats(before: string, after: string): DiffStats {
+ const parts = diffLines(before, after);
+ let added = 0;
+ let removed = 0;
+ for (const p of parts) {
+ if (p.added) added += p.value.replace(/\n$/, '').split('\n').length;
+ else if (p.removed) removed += p.value.replace(/\n$/, '').split('\n').length;
+ }
+ return { added, removed };
+}
+
+export interface ServerDraft {
+ generation: number;
+ code: string;
+}
+
+export interface ConflictVerdict {
+ stale: boolean;
+ serverEqualsLocal: boolean;
+ localDirty: boolean;
+}
+
+export class ConflictResolver {
+ constructor(private local: { generation: number; code: string }) {}
+
+ onServerState(server: ServerDraft, lastKnownGeneration: number): ConflictVerdict {
+ const stale = server.generation !== lastKnownGeneration && lastKnownGeneration !== null;
+ const serverEqualsLocal = server.generation === this.local.generation && server.code === this.local.code;
+ return { stale, serverEqualsLocal, localDirty: true };
+ }
+
+ retryPlan(server: ServerDraft): { generation: number; code: string } {
+ return { generation: server.generation, code: this.local.code };
+ }
+}
+
+export function wordDiff(before: string, after: string) {
+ return diffWordsWithSpace(before, after);
+}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..13e9d1e
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -0,0 +1,118 @@
+import type {
+ BacktestMetrics,
+ Dataset,
+ Instrument,
+ Run
+} from './types';
+
+export function uuidLike(id: string): boolean {
+ return /^[0-9a-f-]+$/i.test(id);
+}
+
+export function fmtDateTime(iso?: string | null): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleString('zh-CN', { hour12: false });
+}
+
+export function fmtDate(iso?: string | null): string {
+ if (!iso) return '—';
+ return iso.replace('T', ' ').replace(/Z$|\+08:00$/i, '');
+}
+
+export function fmtPct(v: number | null | undefined, digits = 2): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ return `${(v * 100).toFixed(digits)}%`;
+}
+
+export function fmtNum(v: number | null | undefined, digits = 2): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`;
+ if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)} 万`;
+ let r = v.toFixed(digits);
+ if (r === '0.00' && v !== 0) r = '0';
+ return r;
+}
+
+export function itemSourceLabel(itemSource?: string | null, fallback = ''): string {
+ const s = itemSource || fallback;
+ if (!s) return '—';
+ if (s === 'catalog') return '目录';
+ if (s === 'provider_suggest') return '供应商';
+ if (s === 'eastmoney') return '东财';
+ if (s === 'tencent') return '腾讯';
+ if (s === 'sina') return '新浪';
+ return s;
+}
+
+export const runStatusLabel: Record<Run['status'], string> = {
+ queued: '排队中',
+ running: '执行中',
+ succeeded: '已完成',
+ failed: '失败',
+ cancelled: '已取消'
+};
+
+export const runStatusBadge: Record<Run['status'], 'grey' | 'warn' | 'ok' | 'danger' | 'teal'> = {
+ queued: 'grey',
+ running: 'warn',
+ succeeded: 'ok',
+ failed: 'danger',
+ cancelled: 'grey'
+};
+
+export const datasetStatusLabel: Record<Dataset['status'], string> = {
+ pending: '等待中',
+ running: '获取数据',
+ ready: '就绪',
+ failed: '失败'
+};
+
+export const adjustmentLabel: Record<string, string> = {
+ none: '不复权',
+ qfq: '前复权',
+ hfq: '后复权'
+};
+
+export const assetTypeLabel: Record<string, string> = {
+ stock: '股票',
+ etf: 'ETF',
+ index: '指数'
+};
+
+export function instrumentLabel(i: Instrument): string {
+ const name = i.name ? ` ${i.name}` : '';
+ return `${i.market}.${i.symbol}${name}`;
+}
+
+export function formatMoney(v: number | null | undefined): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ return `¥ ${fmtNum(v)}`;
+}
+
+export const versionSourceLabel: Record<string, string> = {
+ manual: '手动快照',
+ run: '回测快照',
+ ai: 'AI 采用',
+ restore: '恢复产生'
+};
+
+export function metricsLabels(): string[] {
+ // trade_count semantics (worker/backtest.py): COUNT of closed round trips —
+ // an open that is fully closed afterwards counts as 1; fills/orders and
+ // cancellations do NOT count. Consistent label for the result card AND the
+ // run comparison tables.
+ return ['总收益率', '年化收益率', '最大回撤', '夏普比率', '平仓回合数', '期末权益'];
+}
+
+export function metricsRow(m: BacktestMetrics): string[] {
+ return [
+ fmtPct(m.total_return),
+ fmtPct(m.annual_return),
+ fmtPct(m.max_drawdown),
+ m.sharpe === null || m.sharpe === undefined ? '—' : m.sharpe.toFixed(2),
+ m.trade_count === null || m.trade_count === undefined ? '—' : String(m.trade_count),
+ m.final_equity === null || m.final_equity === undefined ? '—' : fmtNum(m.final_equity)
+ ];
+}
diff --git a/frontend/src/lib/instrumentSearch.test.ts b/frontend/src/lib/instrumentSearch.test.ts
new file mode 100644
index 0000000..15636be
--- /dev/null
+++ b/frontend/src/lib/instrumentSearch.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ createSearchRunner,
+ SEARCH_TIMEOUT_MSG,
+ SEARCH_UNAVAILABLE_MSG,
+ type SearchFetcher,
+ type SearchHandlers
+} from './instrumentSearch';
+import type { Instrument, InstrumentSearchResponse } from './types';
+
+function inst(symbol: string): Instrument {
+ return { symbol, market: 'SH', asset_type: 'stock', name: symbol, currency: 'CNY', source: 'eastmoney' };
+}
+
+function ok(items: Instrument[]): InstrumentSearchResponse {
+ return { items, source: 'provider_suggest', status: 'ok' };
+}
+
+function trackedHandlers(): SearchHandlers & { events: string[] } {
+ const events: string[] = [];
+ return {
+ events,
+ onBusy: () => events.push('busy'),
+ onResult: (res) => events.push(`result:${res.items.length}:${res.status}`),
+ onError: (m) => events.push(`error:${m}`),
+ onFinish: () => events.push('finish')
+ };
+}
+
+const flush = () => new Promise<void>((r) => setTimeout(r, 0));
+
+describe('instrument search coordinator', () => {
+ it('successful provider response shows items and clears the spinner', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = vi.fn(async () => ok([inst('600000')]));
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events).toEqual(['busy', 'result:1:ok', 'finish']);
+ });
+
+ it('unavailable status shows an explicit Chinese message and clears items', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = async () => ({ items: [], source: 'none', status: 'unavailable: providers down' });
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events).toContain(`error:${SEARCH_UNAVAILABLE_MSG}`);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ });
+
+ it('provider rejection surfaces a visible failure and the spinner is cleared in finally', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = async () => { throw { status: 502, message: 'bad gateway' }; };
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events.some((e) => e.startsWith('error:'))).toBe(true);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ });
+
+ it('a hard timer aborts the hung request and shows the timeout wording', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = vi.fn(async (_q: string, signal?: AbortSignal) =>
+ new Promise<InstrumentSearchResponse>((_, reject) => {
+ signal?.addEventListener('abort', () => reject(new DOMException('x', 'AbortError')));
+ }));
+ const runner = createSearchRunner(fetch, h, { timeoutMs: 30 });
+ runner.runNow('600000');
+ await flush();
+ await new Promise((r) => setTimeout(r, 60));
+ expect(h.events).toContain(`error:${SEARCH_TIMEOUT_MSG}`);
+ expect(h.events[h.events.length - 1]).toBe('finish'); // spinner never hangs
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('stale prior query: its late result never repaints and never hangs the spinner', async () => {
+ const h = trackedHandlers();
+ let releaseA!: (v: InstrumentSearchResponse) => void;
+ let calls = 0;
+ const fetch: SearchFetcher = vi.fn(async () => {
+ calls += 1;
+ if (calls === 1) {
+ return new Promise<InstrumentSearchResponse>((resolve) => { releaseA = resolve; });
+ }
+ return ok([inst('600000')]);
+ });
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('old');
+ await flush();
+ const busyCount = h.events.filter((e) => e === 'busy').length;
+ runner.runNow('new');
+ await flush();
+ // first request aborted; the slow promise is released AFTER the swap and
+ // resolves into a stale seq → no UI effect, no extra finish
+ releaseA(ok([inst('000001')]));
+ await flush();
+ const busyAfter = h.events.filter((e) => e === 'busy').length;
+ expect(busyAfter).toBe(2);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ expect(h.events[busyCount - 1]).toBe('busy');
+ expect(h.events.join('|')).not.toContain('result:1:ok|finish|result');
+ expect(h.events.filter((e) => e.startsWith('result')).length).toBe(1);
+ });
+
+ it('dispose invalidates in-flight callbacks entirely', async () => {
+ const h = trackedHandlers();
+ let pendings = 0;
+ const fetch: SearchFetcher = async () => {
+ pendings += 1;
+ return new Promise<InstrumentSearchResponse>(() => { /* pending */ });
+ };
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ const eventsAtDispose = h.events.length;
+ runner.dispose();
+ await flush();
+ expect(h.events.length).toBe(eventsAtDispose); // nothing further fired
+ });
+});
diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts
new file mode 100644
index 0000000..8a83a47
--- /dev/null
+++ b/frontend/src/lib/instrumentSearch.ts
@@ -0,0 +1,120 @@
+// Production search bug fix /instrument-search coordinator.
+//
+// Root cause of the hung spinner: the API previously did a full catalog fetch
+// server-side. Client-side we now keep the round trip bounded end to end:
+// debounce + abort of prior stale queries + a hard AbortController timer, and
+// explicit handling of the honest failure statuses the backend now returns.
+// Pure dependency injection makes stale-query cleanup unit testable.
+import { apiFetch } from './api';
+import { extractStatusMessage } from './state';
+import type { InstrumentSearchResponse } from './types';
+
+export const SEARCH_TIMEOUT_MS = 12_000;
+export const SEARCH_DEBOUNCE_MS = 350;
+
+export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试';
+export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试';
+export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份';
+
+export type SearchFetcher = (
+ q: string,
+ signal?: AbortSignal
+) => Promise<InstrumentSearchResponse>;
+
+export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> {
+ return apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal });
+}
+
+export interface SearchHandlers {
+ onBusy(): void;
+ onResult(res: InstrumentSearchResponse): void;
+ onError(message: string): void;
+ onFinish(): void;
+}
+
+export interface SearchRunner {
+ runNow(q: string): void;
+ requestDebounced(q: string): void;
+ dispose(): void;
+}
+
+export interface SearchRunnerOptions {
+ timeoutMs?: number;
+ debounceMs?: number;
+}
+
+export function createSearchRunner(
+ doFetch: SearchFetcher = fetchSearch,
+ handlers: SearchHandlers,
+ options: SearchRunnerOptions = {},
+): SearchRunner {
+ const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
+ const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS;
+ let seq = 0;
+ let controller: AbortController | null = null;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ function clearTimer() {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ }
+
+ async function run(q: string) {
+ const mySeq = ++seq;
+ // abort the previous in-flight request first: its callbacks all become
+ // stale (mySeq !== seq) and must not touch the UI
+ controller?.abort();
+ controller = new AbortController();
+ const ctl = controller;
+ const timeout = setTimeout(() => ctl.abort(), timeoutMs);
+ timer = timeout;
+ handlers.onBusy();
+ try {
+ const res = await doFetch(q, ctl.signal);
+ if (mySeq !== seq) return;
+ if (res.status === 'ok') {
+ handlers.onResult(res);
+ } else {
+ handlers.onResult({ items: [], source: 'none', status: res.status });
+ handlers.onError(SEARCH_UNAVAILABLE_MSG);
+ }
+ } catch (e) {
+ if (mySeq !== seq) return;
+ const signalAborted = ctl.signal.aborted;
+ const abortError = (e as { name?: string }).name === 'AbortError';
+ handlers.onError(
+ signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e),
+ );
+ } finally {
+ if (timer === timeout) timer = null;
+ clearTimeout(timeout);
+ // stale requests never touch busy state (their handler may already have
+ // been superseded); only the still-current query may reset the spinner
+ if (mySeq === seq) handlers.onFinish();
+ }
+ }
+
+ return {
+ runNow: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ void run(q.trim());
+ },
+ requestDebounced: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ timer = setTimeout(() => {
+ timer = null;
+ void run(q.trim());
+ }, debounceMs);
+ },
+ dispose: () => {
+ clearTimer();
+ seq += 1; // invalidate any in-flight callbacks
+ controller?.abort();
+ controller = null;
+ },
+ };
+}
diff --git a/frontend/src/lib/layout.test.ts b/frontend/src/lib/layout.test.ts
new file mode 100644
index 0000000..99d3c47
--- /dev/null
+++ b/frontend/src/lib/layout.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+// `?raw` gives us the exact Svelte source for a real regression check
+// (ProjectPage used to wrap a second <Layout>, doubling the sidebar).
+// Type support comes from vite/client's `*?raw` module declaration.
+import projectPageSource from '../pages/ProjectPage.svelte?raw';
+
+describe('single layout shell', () => {
+ it('ProjectPage does not nest its own Layout', () => {
+ const src: string = projectPageSource as unknown as string;
+ expect(src).not.toMatch(/import\s+Layout/);
+ expect(src).not.toMatch(/<Layout/);
+ // page props still passed through on each tab
+ expect(src).toMatch(/ProjectDataTab/);
+ expect(src).toMatch(/ProjectStrategyTab/);
+ expect(src).toMatch(/ProjectBacktestTab/);
+ expect(src).toMatch(/ProjectResultsTab/);
+ });
+});
diff --git a/frontend/src/lib/listsStore.svelte.ts b/frontend/src/lib/listsStore.svelte.ts
new file mode 100644
index 0000000..102543f
--- /dev/null
+++ b/frontend/src/lib/listsStore.svelte.ts
@@ -0,0 +1,92 @@
+import type { Project } from './types';
+import { getProject, listProjects } from './client';
+import { ApiError } from './api';
+
+let projects: Project[] = $state([]);
+
+// ids that failed to fetch because the project is not visible to this session
+// (404 not found / 403 not owner). Remembered so the sidebar can show an honest
+// 不可见项目 state instead of an endless 加载中… retry loop. Cleared on logout /
+// session switch so cross-user stale names or failure marks never leak.
+// $state map so derived views (sidebar) re-react when a mark is added/cleared.
+let invisibleIds: Record<string, boolean> = $state({});
+let inflight = new Map<string, Promise<Project | null>>();
+
+export interface ProjectsStore {
+ get all(): Project[];
+ set(items: Project[]): void;
+ /** fetch + cache a project the sidebar/derived views cannot see yet (deep link) */
+ ensure(id: string): Promise<Project | null>;
+ bump(p: Project): void;
+ /** reactive lookup: reads only the $state projects array */
+ get(id: string): Project | undefined;
+ load(): Promise<Project[]>;
+ /** drop all cached projects + failure marks (must be called on logout so a
+ * future session on the same browser cannot see the previous user's names) */
+ clear(): void;
+}
+
+function upsert(p: Project) {
+ const idx = projects.findIndex((x) => x.id === p.id);
+ if (idx >= 0) projects[idx] = p;
+ else projects = [...projects, p];
+}
+
+export const projectsStore: ProjectsStore = {
+ get all() {
+ return projects;
+ },
+ set(items: Project[]) {
+ projects = items;
+ },
+ get(id: string) {
+ return projects.find((p) => p.id === id);
+ },
+ bump(p: Project) {
+ upsert(p);
+ },
+ async ensure(id: string) {
+ const known = projects.find((p) => p.id === id);
+ if (known) return known;
+ if (invisibleIds[id]) return null;
+ const pending = inflight.get(id);
+ if (pending) return pending;
+ const fetchPromise = (async () => {
+ try {
+ const p = await getProject(id);
+ upsert(p);
+ return p;
+ } catch (e) {
+ if (e instanceof ApiError && (e.status === 403 || e.status === 404)) {
+ invisibleIds = { ...invisibleIds, [id]: true };
+ return null;
+ }
+ throw e;
+ } finally {
+ inflight.delete(id);
+ }
+ })();
+ inflight.set(id, fetchPromise);
+ return fetchPromise;
+ },
+ async load() {
+ const { items } = await listProjects();
+ projects = items;
+ return items;
+ },
+ clear() {
+ projects = [];
+ invisibleIds = {};
+ inflight.clear();
+ }
+};
+
+/** reactive derived read for components */
+export function projectOf(id: string): Project | undefined {
+ return projectsStore.get(id);
+}
+
+/** true when a previous ensure() proved the id is not visible for this session */
+export function projectInvisible(id: string): boolean {
+ return !!invisibleIds[id];
+}
diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts
new file mode 100644
index 0000000..c4d1efb
--- /dev/null
+++ b/frontend/src/lib/mobileCss.test.ts
@@ -0,0 +1,63 @@
+// @ts-nocheck
+import { describe, expect, it } from 'vitest';
+// Live 375px browser overflow regression (artifacts/qa/supervisor-live/
+// mobile-overflow-probe.json): document scrollWidth measured 532 → 552 at a
+// 375px viewport, sidebar/main 552px, cards 523px; DOM authoritative (a
+// full-page screenshot expands to the overflow width, so source/DOM checks
+// are the honest regression). Root causes are real CSS: grid min-content
+// blow-up (layout/grid3), nowrap table cells, and unbreakable provider error
+// URLs. CSS `?raw` imports are stubbed empty by vitest, so the real source
+// files are read from disk instead.
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src');
+const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8');
+const layoutSource = readFileSync(path.join(srcRoot, 'components', 'Layout.svelte'), 'utf8');
+const datasetCard = readFileSync(path.join(srcRoot, 'components', 'DatasetCard.svelte'), 'utf8');
+
+describe('375px responsive sizing (no global content hiding)', () => {
+ it('layout columns shrink below min-content (no document-level overflow)', () => {
+ // the mobile layout must be allowed to shrink (minmax(0,1fr), not bare 1fr)
+ expect(layoutSource).toMatch(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.layout\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/);
+ // the content column opts out of min-content propagation
+ expect(layoutSource).toMatch(/\.content\s*\{[^}]*min-width:\s*0/);
+ // project tab context wraps instead of forcing one long line
+ expect(layoutSource).toMatch(/\.projctx\s*\{[^}]*flex-wrap:\s*wrap/);
+ });
+
+ it('multi-column forms and tables collapse/wrap on narrow view', () => {
+ expect(appCss).toContain('@media (max-width: 900px)');
+ expect(appCss).toContain('.grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }');
+ expect(appCss).toContain('table.data th, table.data td {');
+ expect(appCss).toContain('white-space: normal');
+ });
+
+ it('long real provider errors wrap instead of inflating width', () => {
+ // `overflow-wrap: anywhere` wraps even long URL payloads; NOT
+ // overflow-x:hidden on body/document (that would hide content).
+ expect(appCss).toMatch(/\.banner\s*\{[^}]*overflow-wrap:\s*anywhere/);
+ expect(appCss.includes('body { overflow-x: hidden')).toBe(false);
+ expect(appCss.includes('html { overflow-x: hidden')).toBe(false);
+ });
+
+ it('dataset card keeps a local scroll container for tabular data', () => {
+ expect(datasetCard).toContain('scrollx');
+ });
+});
+
+function getMediaBlock(css, query) {
+ const idx = css.indexOf(`@media ${query}`);
+ if (idx < 0) return null;
+ const open = css.indexOf('{', idx);
+ let depth = 1;
+ for (let i = open + 1; i < css.length; i++) {
+ if (css[i] === '{') depth++;
+ else if (css[i] === '}') {
+ depth--;
+ if (depth === 0) return css.slice(idx, i + 1);
+ }
+ }
+ return null;
+}
diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts
new file mode 100644
index 0000000..226e1d0
--- /dev/null
+++ b/frontend/src/lib/resultsAccounting.test.ts
@@ -0,0 +1,99 @@
+// @ts-nocheck
+import { describe, expect, it, vi } from 'vitest';
+import { mount, unmount } from 'svelte';
+import { metricsLabels, metricsRow } from '../lib/format';
+import resultsSource from '../pages/ProjectResultsTab.svelte?raw';
+
+// Bounded accounting regression (parent live findings):
+// 1) trade_count semantics: worker/backtest.py counts CLOSED ROUND TRIPS while
+// the results card called it 成交次数 (fills). Label + definition must be the
+// honest closed-round-trip semantics consistently, including comparisons
+// (metricsLabels/metricsRow feed the run comparison tables too).
+// 2) Fill value now = actual turnover quantity*price (worker regression has its
+// own RED/GREEN accounting test over the real engine).
+// 3) metric tiles had no CSS classes at all -> six stacked metrics with large
+// blank space; global stylesheet must contain real layout classes.
+
+const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b';
+const RUN_ID = '569b23d0-1111-4222-8333-4444444444442';
+
+function resultBody() {
+ return {
+ id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd',
+ status: 'succeeded',
+ config: { capital: 100000, commission: 0.0003, slippage: 0.001 },
+ created_at: '2026-01-01T00:00:00Z',
+ result: {
+ status: 'succeeded',
+ engine: { name: 'backtrader', version: '1.9' },
+ metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 },
+ equity: [{ date: '2024-01-02', equity: 100000, cash: 100000 }, { date: '2024-01-03', equity: 101234.5, cash: 98967.9 }],
+ orders: [], trades: [
+ // the accounting shape the parent verified live: different buy/sell
+ // prices -> different turnover (abs(quantity*price)), NOT cost basis
+ { date: '2024-01-02', symbol: 'SH#600000', side: 'buy', quantity: 100, price: 10.2, commission: 0.30606, value: 1020.0 },
+ { date: '2024-01-03', symbol: 'SH#600000', side: 'sell', quantity: 100, price: 10.4, commission: 0.32, value: 1040.0 },
+ ], closed_trades: [],
+ logs: ['Engine is required', 'Final writing'], warnings: [],
+ elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234',
+ },
+ };
+}
+
+function runBody() {
+ return { id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: {}, created_at: '2026-01-01T00:00:00Z' };
+}
+
+const fetchMockOnce = () => (url) => {
+ if (/\/api\/runs\/[0-9a-f-]+$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify(resultBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ if (/\/api\/runs\?|\/api\/runs$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify({ items: [runBody()] }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url)))
+ return Promise.resolve(new Response(JSON.stringify({ id: PROJECT_ID, name: '端到端验收项目', draft_code: 'x=1', draft_generation: 2 }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+};
+
+describe('results accounting labels + values (live browser accounting)', () => {
+ it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', async () => {
+ // jsdom lacks ResizeObserver; Chart.svelte needs it only for responsive
+ // canvas resize, which jsdom never triggers. Narrow environment shim.
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ vi.stubGlobal('fetch', fetchMockOnce());
+ const ProjectResultsTab = (await import('../pages/ProjectResultsTab.svelte')).default;
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(ProjectResultsTab, { target: host, props: { projectId: PROJECT_ID, runId: RUN_ID } });
+ await new Promise((r) => setTimeout(r, 80));
+ try {
+ const text = host.textContent;
+ // closed round trip semantics (not fill count)
+ expect(text).toContain('平仓回合数');
+ expect(text).toContain('已完成的开仓并全部平仓的完整回合');
+ expect(text).not.toContain('成交次数(不含撤单)');
+ // numeric trade_count shows through: equity div reached rendering
+ expect(host.querySelectorAll('.metric').length).toBe(6);
+ // turnover: two fills with distinct values (buy vs sell prices differ)
+ const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim());
+ const hasSell = [...host.children].join('').includes('卖出');
+ // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis
+ expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2);
+ // trade_count metric value renders
+ expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true);
+ } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } });
+
+ it('comparison labels use the same closed-round-trip semantics', () => {
+ const labels = metricsLabels();
+ expect(labels).toContain('平仓回合数');
+ const m = metricsRow({ total_return: 0.1, annual_return: 0.2, max_drawdown: -0.1, sharpe: 1, trade_count: 1, final_equity: 110000 });
+ const label = labels[m.findIndex((_, i) => i === labels.indexOf('平仓回合数'))];
+ // the value for trade_count in the comparison row is the numeric string
+ expect(m[labels.indexOf('平仓回合数')]).toBe('1');
+ expect(labels).not.toContain('交易次数'); // old misleading '交易次数' gone
+ });
+
+ it('stylesheet defines the shared metric tile layout (no stacking blank space)', () => {
+ const template: string = resultsSource as unknown as string;
+ // keep the editor honest: no leftover 成交次数 wording in the metrics card
+ expect(template).toContain('平仓回合数');
+ expect(template).not.toContain('成交次数');
+ });
+});
diff --git a/frontend/src/lib/router.test.ts b/frontend/src/lib/router.test.ts
new file mode 100644
index 0000000..944e342
--- /dev/null
+++ b/frontend/src/lib/router.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { routeFromHash, projectPath } from './router';
+
+function setHash(h: string) {
+ window.location.hash = h;
+}
+
+describe('router', () => {
+ it('routes auth pages', () => {
+ setHash('#/login');
+ expect(routeFromHash().name).toBe('login');
+ setHash('#/register');
+ expect(routeFromHash().name).toBe('register');
+ setHash('#/reset');
+ expect(routeFromHash().name).toBe('reset');
+ });
+
+ it('routes protected areas', () => {
+ setHash('#/projects');
+ expect(routeFromHash().name).toBe('projects');
+ setHash('#/datasets');
+ expect(routeFromHash().name).toBe('datasets');
+ setHash('#/runs');
+ expect(routeFromHash().name).toBe('runs');
+ setHash('#/usage');
+ expect(routeFromHash().name).toBe('usage');
+ setHash('#/account');
+ expect(routeFromHash().name).toBe('account');
+ setHash('#/admin');
+ expect(routeFromHash().name).toBe('admin');
+ });
+
+ it('routes project tabs', () => {
+ setHash('#/projects/abc-1/strategy');
+ const r = routeFromHash();
+ expect(r.name).toBe('project-strategy');
+ expect(r.params.id).toBe('abc-1');
+ setHash('#/projects/abc-1');
+ expect(routeFromHash().name).toBe('project-data');
+ setHash('#/projects/abc-1/backtest');
+ expect(routeFromHash().name).toBe('project-backtest');
+ setHash('#/projects/abc-1/results?run=r9');
+ const rr = routeFromHash();
+ expect(rr.name).toBe('project-results');
+ expect(rr.query.get('run')).toBe('r9');
+ });
+
+ it('projects deep path builder', () => {
+ expect(projectPath('id1', 'strategy')).toBe('/projects/id1/strategy');
+ });
+});
diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts
new file mode 100644
index 0000000..3c2631a
--- /dev/null
+++ b/frontend/src/lib/router.ts
@@ -0,0 +1,43 @@
+export interface Route {
+ name: string;
+ params: Record<string, string>;
+ query: URLSearchParams;
+}
+
+export function routeFromHash(): Route {
+ const raw = window.location.hash.replace(/^#/, '') || '/login';
+ const [pathPart, queryPart] = raw.split('?');
+ const segments = pathPart.split('/').filter(Boolean);
+ const query = new URLSearchParams(queryPart ?? '');
+ if (segments[0] === 'login' || segments.length === 0) return { name: 'login', params: {}, query };
+ if (segments[0] === 'register') return { name: 'register', params: {}, query };
+ if (segments[0] === 'reset') return { name: 'reset', params: {}, query };
+ if (segments[0] === 'account') return { name: 'account', params: {}, query };
+ if (segments[0] === 'admin') return { name: 'admin', params: {}, query };
+ if (segments[0] === 'datasets') return { name: 'datasets', params: {}, query };
+ if (segments[0] === 'runs') return { name: 'runs', params: {}, query };
+ if (segments[0] === 'usage') return { name: 'usage', params: {}, query };
+ if (segments[0] === 'projects') {
+ if (!segments[1]) return { name: 'projects', params: {}, query };
+ const id = segments[1];
+ const tab = segments[2] ?? 'data';
+ if (['data', 'strategy', 'backtest', 'results'].includes(tab)) {
+ return { name: `project-${tab}`, params: { id }, query };
+ }
+ return { name: 'project-data', params: { id }, query };
+ }
+ return { name: 'login', params: {}, query };
+}
+
+export function go(path: string) {
+ if (!path.startsWith('#')) path = `#${path.startsWith('/') ? path : `/${path}`}`;
+ window.location.hash = path;
+}
+
+export function replace(path: string) {
+ go(path);
+}
+
+export function projectPath(id: string, tab: 'data' | 'strategy' | 'backtest' | 'results') {
+ return `/projects/${id}/${tab}`;
+}
diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts
new file mode 100644
index 0000000..47b452f
--- /dev/null
+++ b/frontend/src/lib/session.svelte.ts
@@ -0,0 +1,45 @@
+import type { User } from './types';
+import { getMe } from './client';
+
+class SessionStore {
+ me = $state<User | null>(null);
+ status = $state<'loading' | 'anon' | 'authed'>('loading');
+
+ async refresh(): Promise<User | null> {
+ try {
+ const { user } = await getMe();
+ if (!user || typeof user.id !== 'string' || user.id.length === 0) {
+ this.clear();
+ return null;
+ }
+ this.me = user;
+ this.status = 'authed';
+ return user;
+ } catch (e) {
+ const status = (e as { status?: number }).status ?? 0;
+ if (status === 0) {
+ // network/protocol error: keep previous state so UI can show retry instead
+ if (this.status === 'loading') this.clear();
+ return null;
+ }
+ this.clear();
+ return null;
+ }
+ }
+
+ clear() {
+ this.me = null;
+ this.status = 'anon';
+ }
+
+ set(u: User | null) {
+ if (u && typeof u.id === 'string' && u.id.length > 0) {
+ this.me = u;
+ this.status = 'authed';
+ } else {
+ this.clear();
+ }
+ }
+}
+
+export const session: SessionStore = new SessionStore();
diff --git a/frontend/src/lib/sidebarCurrentProject.test.ts b/frontend/src/lib/sidebarCurrentProject.test.ts
new file mode 100644
index 0000000..5dc9b9d
--- /dev/null
+++ b/frontend/src/lib/sidebarCurrentProject.test.ts
@@ -0,0 +1,186 @@
+// @ts-nocheck
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { mount, unmount, flushSync } from 'svelte';
+import Layout from '../components/Layout.svelte';
+import { session } from '../lib/session.svelte';
+import { projectsStore, projectInvisible, projectOf } from '../lib/listsStore.svelte';
+import { ApiError } from '../lib/api';
+import type { Route } from '../lib/router';
+
+// Bounded sidebar regression (parent live finding): on a deep link or first
+// navigation into a project tab the sidebar showed an endless 加载中… because
+// projectOf() only read the (empty) list store and nothing fetched the missing
+// project. Fix: Layout triggers projectsStore.ensure() for unknown ids; the
+// name then resolves reactively, updates on in-tab changes (bump), and a
+// non-visible project (404/403) shows an honest 不可见项目 state instead of a
+// retry loop. Failure marks + cache are cleared on logout so no cross-user
+// stale name (or invisible mark from another user's project) can leak.
+
+const PID = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc01';
+const PID_OTHER = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc02';
+const route = (name: string, id?: string): Route =>
+ id
+ ? { name, params: { id }, query: new URLSearchParams() }
+ : { name: 'projects', params: {}, query: new URLSearchParams() };
+
+const USER = { id: 'u1', name: '测试用户', role: 'user', session_id: 's1' };
+
+function projectBody(name = '回测验收项目') {
+ return {
+ id: PID,
+ name,
+ owner_id: 'u1',
+ draft_code: 'x=1',
+ draft_generation: 2,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z'
+ };
+}
+
+async function mountLayout(r: Route) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ const inst = mount(Layout, { target: el, props: { route: r } });
+ flushSync();
+ await new Promise((res) => setTimeout(res, 50));
+ flushSync();
+ return { el, inst };
+}
+
+describe('sidebar current project (deep link / navigation / updates)', () => {
+ beforeEach(() => {
+ session.set(USER as never);
+ projectsStore.clear();
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ });
+
+ it('deep link into a project tab resolves 当前项目 name without another page loading first', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-strategy', PID));
+ try {
+ expect(el.textContent).toContain('回测验收项目');
+ expect(el.textContent).not.toContain('加载中');
+ expect(el.textContent).not.toContain('不可见项目');
+ // exactly ONE direct project fetch (deduped by ensure(); no retry loops)
+ const projectCalls = calls.filter((u) => u.includes(`/api/projects/${PID}`));
+ expect(projectCalls.length).toBe(1);
+ // project is now visible through the shared reactive store
+ expect(projectOf(PID)?.name).toBe('回测验收项目');
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('reacts to in-tab updates (bump) without extra fetching, then navigates between tabs', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-data', PID));
+ try {
+ expect(el.textContent).toContain('回测验收项目');
+
+ // one shared ensure cache: opening another tab of the SAME project does
+ // not refetch and stays showing the name
+ const { inst: inst2, el: el2 } = await mountLayout(route('project-backtest', PID));
+ try {
+ expect(el2.textContent).toContain('回测验收项目');
+ expect(calls.filter((u) => u.includes(`/api/projects/${PID}`)).length).toBe(1);
+ } finally {
+ unmount(inst2);
+ el2.remove();
+ }
+
+ // in-tab save/restore/AI bumps the store -> sidebar updates in place
+ projectsStore.bump({ ...projectBody('改名后的验收项目') } as never);
+ flushSync();
+ expect(el.textContent).toContain('改名后的验收项目');
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('shows 不可见项目 (not an endless 加载中) for another user\'s/deleted project and clears marks on logout', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ return Promise.resolve(new Response(JSON.stringify({ error: { code: 'not_found', message: 'no' } }), { status: 404, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-results', PID_OTHER));
+ try {
+ expect(el.textContent).toContain('不可见项目');
+ expect(el.textContent).not.toContain('回测验收项目');
+ // retry guard: layout remount doesn't hammer a known-invisible project
+ await mountLayout(route('project-results', PID_OTHER)).then(({ inst: i2, el: e2 }) => {
+ unmount(i2);
+ e2.remove();
+ });
+ expect(calls.filter((u) => u.includes(`/api/projects/${PID_OTHER}`)).length).toBe(1);
+ expect(projectInvisible(PID_OTHER)).toBe(true);
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+
+ // logout must clear the failure marks + cache: the NEXT session must not
+ // inherit invisible marks or cached project names (cross-user leakage)
+ projectsStore.clear();
+ expect(projectInvisible(PID_OTHER)).toBe(false);
+ expect(projectOf(PID)).toBeUndefined();
+ });
+
+ it('transient network errors are not remembered as invisible (honest retry)', async () => {
+ let fail = true;
+ // phase 1: the fetch to the project id rejects with a network-layer error
+ vi.stubGlobal('fetch', (url) => {
+ if (String(url).includes(`/api/projects/${PID}`)) {
+ if (fail) throw new TypeError('network down');
+ return new Promise<Response>(() => {});
+ }
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-data', PID));
+ try {
+ // network error: not marked invisible; sidebar keeps the honest 加载中…
+ expect(el.textContent).toContain('加载中');
+ expect(projectInvisible(PID)).toBe(false);
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ // phase 2: a later retry (fresh fetch stub) succeeds and renders
+ vi.stubGlobal('fetch', (url) => {
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody('重试后的项目')), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ projectsStore.clear();
+ const once = await mountLayout(route('project-data', PID));
+ // allow the ensure() fetch+reactive update to settle regardless of load
+ await new Promise((res) => setTimeout(res, 300));
+ flushSync();
+ try {
+ expect(projectOf(PID)?.name).toBe('重试后的项目');
+ expect(once.el.textContent).toContain('重试后的项目');
+ } finally {
+ unmount(once.inst);
+ once.el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts
new file mode 100644
index 0000000..3beb2c0
--- /dev/null
+++ b/frontend/src/lib/state.ts
@@ -0,0 +1,15 @@
+import type { ApiError } from './api';
+
+export function extractStatusMessage(err: unknown): string {
+ const e = err as Partial<ApiError>;
+ if (e && typeof e.status === 'number') {
+ return `联网出错:HTTP ${e.status} ${e.message ?? ''}`.trim();
+ }
+ if (e && e.message) return `出错:${e.message}`;
+ return '出错:未知错误,请稍后再试';
+}
+
+export function statusByName(err: unknown): string {
+ const code = (err as Partial<ApiError>)?.code ?? '';
+ return code;
+}
diff --git a/frontend/src/lib/strategyTab.test.ts b/frontend/src/lib/strategyTab.test.ts
new file mode 100644
index 0000000..a5bf6d8
--- /dev/null
+++ b/frontend/src/lib/strategyTab.test.ts
@@ -0,0 +1,95 @@
+// @ts-nocheck
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { mount, unmount } from 'svelte';
+// NOTE: `resolve.conditions: ['browser']` lets vitest resolve svelte's client
+// build so actual component mounting works in jsdom.
+import { putDraft } from '../lib/client';
+import { ApiError } from '../lib/api';
+
+// Live regression (artifacts/qa/supervisor-live/browser-strategy.json):
+// ProjectStrategyTab rendered `c(...).slice is not a function` pageerror and a
+// blank main because the backend sends draft_generation as a NUMBER (i64) while
+// the frontend typed it as a string and called .slice on it. Backend keeps the
+// integer; these tests exercise the ACTUAL numeric API shape through the real
+// fetch client and the REAL component render in jsdom (no source-string-only
+// assertions).
+
+const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b';
+
+const json = (v, status = 200) =>
+ new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json' } });
+
+function projectBody(generation) {
+ return {
+ id: PROJECT_ID, name: '端到端验收项目', description: null,
+ draft_code: 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n',
+ draft_generation: generation, // NUMERIC contract from the backend (i64)
+ created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
+ };
+}
+
+async function mountStrategyTab(fetchImpl) {
+ vi.stubGlobal('fetch', fetchImpl);
+ const ProjectStrategyTab = (await import('../pages/ProjectStrategyTab.svelte')).default;
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(ProjectStrategyTab, { target: host, props: { projectId: PROJECT_ID } });
+ // flush effects/microtasks
+ await new Promise((r) => setTimeout(r, 50));
+ return { host, inst, cleanup: () => { try { unmount(inst); } catch {} host.remove(); vi.unstubAllGlobals(); } };
+}
+
+function handler() {
+ return (url, _opts = {}) => {
+ if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url)))
+ return Promise.resolve(json(projectBody(3)));
+ if (/\/versions/.test(String(url)))
+ return Promise.resolve(json({ items: [] }));
+ return Promise.resolve(json({}));
+ };
+}
+
+describe('strategy tab numeric generation contract (pageerror regression)', () => {
+ beforeEach(() => { document.body.innerHTML = ''; });
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('full numeric flow renders editor + 草稿代 3 without .slice crash', async () => {
+ const { host, cleanup } = await mountStrategyTab(handler());
+ try {
+ const text = host.textContent;
+ // editor content from the numeric flow (component reached rendering)
+ expect(text).toContain('class Strategy(bt.Strategy)');
+ // generation renders as the actual number, never a truncated-slice crash
+ expect(text).toMatch(/草稿代\s*3(?:\s|$|·)/);
+ expect(text).not.toContain('slice is not a function');
+ } finally { cleanup(); }
+ });
+
+ it('client PUT sends numeric expected_generation and accepts numeric response', async () => {
+ const seen = [];
+ vi.stubGlobal('fetch', (url, opts = {}) => {
+ seen.push([String(url), opts]);
+ return Promise.resolve(json({ id: PROJECT_ID, draft_code: 'x=1', draft_generation: 4 }));
+ });
+ const res = await putDraft(PROJECT_ID, 'x=1', 3);
+ expect(res.draft_generation).toBe(4);
+ expect(typeof res.draft_generation).toBe('number');
+ const [, sentOpts] = seen[0];
+ const sentBody = typeof sentOpts.body === 'string' ? JSON.parse(sentOpts.body) : sentOpts.body;
+ expect(sentBody.expected_generation).toBe(3);
+ expect(typeof sentBody.expected_generation).toBe('number');
+ });
+
+ it('409 conflict keeps stale_generation code (numeric retry flow preserved)', async () => {
+ vi.stubGlobal('fetch', (url) => {
+ if (/\/draft$/.test(String(url)))
+ return Promise.resolve(new Response(JSON.stringify({ error: { code: 'stale_generation', message: 'g3' } }), { status: 409, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(json({}));
+ });
+ let err;
+ await putDraft(PROJECT_ID, 'stale', 2).catch((e) => { err = e; });
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(409);
+ expect(err.code).toBe('stale_generation');
+ });
+});
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..db065e4
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,244 @@
+import type { CoverageEntry } from './coverage';
+
+export type { CoverageEntry };
+
+export interface User {
+ id: string;
+ email: string;
+ name: string;
+ role: 'admin' | 'member';
+ active: boolean;
+ daily_run_limit?: number | null;
+ ai_enabled?: boolean;
+ created_at?: string;
+}
+
+export interface Project {
+ id: string;
+ name: string;
+ description: string | null;
+ draft_code: string;
+ draft_generation: number;
+ draft_code_preview?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Version {
+ id: string;
+ project_id: string;
+ message: string;
+ hash: string;
+ source: 'manual' | 'run' | 'ai' | 'restore';
+ created_at: string;
+ code?: string;
+}
+
+export interface Instrument {
+ symbol: string;
+ market: string;
+ asset_type: 'stock' | 'etf' | 'index';
+ name: string | null;
+ currency: string | null;
+ source?: string | null;
+}
+
+export interface InstrumentSearchItem extends Instrument {
+ symbol_requested?: string;
+}
+
+export interface InstrumentSearchResponse {
+ items: Instrument[];
+ source: 'provider_suggest' | 'operator_entry' | 'none';
+ status: string;
+}
+
+export interface DatasetManifestObject {
+ instrument: string;
+ market?: string;
+ asset_type?: string;
+ provider: string;
+ endpoint?: string;
+ params?: Record<string, unknown>;
+ akshare_version?: string;
+ fetched_at?: string;
+ requested_start: string;
+ requested_end: string;
+ actual_start: string | null;
+ actual_end: string | null;
+ row_count: number;
+ columns: string[];
+ adjustment: string;
+ frequency?: string;
+ warnings?: string[];
+}
+
+export interface DatasetRequest {
+ name?: string;
+ instruments: Instrument[];
+ start_date: string;
+ end_date: string;
+ frequency: 'daily';
+ adjustment: 'none' | 'qfq' | 'hfq';
+ fields: string[];
+}
+
+export interface Dataset {
+ id: string;
+ name: string | null;
+ request: DatasetRequest;
+ status: 'pending' | 'running' | 'ready' | 'failed';
+ manifest?: DatasetManifest | null;
+ manifest_hash?: string | null;
+ error?: string | null;
+ cache_hit?: boolean;
+ created_at: string;
+}
+
+export interface DatasetManifest {
+ version?: string;
+ hash?: string;
+ objects: DatasetManifestObject[];
+ columns?: string[];
+}
+
+export interface DatasetPreview {
+ columns: string[];
+ rows: Record<string, unknown>[];
+ coverage: CoverageEntry[];
+ warnings: string[];
+ truncated?: boolean;
+}
+
+export interface RunConfig {
+ capital: number;
+ commission: number;
+ slippage: number;
+ benchmark_symbol?: string | null;
+ parameters?: Record<string, unknown>;
+}
+
+export interface Run {
+ id: string;
+ project_id: string;
+ version_id: string;
+ dataset_id: string;
+ status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
+ config: RunConfig;
+ created_at: string;
+ started_at?: string | null;
+ finished_at?: string | null;
+ error?: string | null;
+ result?: BacktestResult | null;
+}
+
+export interface Trade {
+ date: string;
+ symbol: string;
+ side: string;
+ quantity: number;
+ price: number;
+ commission: number;
+ value: number;
+}
+
+export interface OrderRecord {
+ date?: string;
+ symbol?: string;
+ side?: string;
+ quantity?: number;
+ price?: number | null;
+ state?: string;
+ value?: number | null;
+ commission?: number | null;
+}
+
+export interface BacktestMetrics {
+ total_return?: number | null;
+ annual_return?: number | null;
+ max_drawdown?: number | null;
+ sharpe?: number | null;
+ /** closed ROUND-TRIP count (open fully closed = 1), NOT fill count */
+ trade_count?: number | null;
+ final_equity?: number | null;
+}
+
+export interface BacktestResult {
+ equity: { date: string; equity: number; cash: number; benchmark?: number | null }[];
+ orders?: OrderRecord[];
+ trades: Trade[];
+ metrics: BacktestMetrics;
+ logs: string[];
+ engine: { name: string; version: string };
+ warnings: string[];
+ elapsed_ms?: number | null;
+ peak_rss_kb?: number | null;
+ data_manifest_hash?: string;
+}
+
+export interface RunListItem extends Run {
+ project_name?: string;
+}
+
+export interface AIUsageItem {
+ id: string;
+ project_id?: string;
+ model: string;
+ input_tokens?: number;
+ output_tokens?: number;
+ status?: string;
+ created_at: string;
+}
+
+export interface AIUsage {
+ items: AIUsageItem[];
+ totals?: { input_tokens?: number; output_tokens?: number; requests?: number };
+ internal_poc?: boolean;
+}
+
+export interface AIAssist {
+ id: string;
+ model: string;
+ explanation: string;
+ proposed_code: string;
+ diff: string | null;
+ base_generation: number;
+ usage?: { input_tokens?: number; output_tokens?: number } | null;
+ status?: string;
+}
+
+export interface Invitation {
+ id: string;
+ email?: string | null;
+ role?: string;
+ token?: string | null;
+ expires_at?: string;
+ used_at?: string | null;
+ revoked?: boolean;
+ created_by?: string;
+ created_at?: string;
+}
+
+export interface AdminUserUpdate extends Partial<Omit<User, 'id' | 'email' | 'created_at'>> {}
+
+export interface Capabilities {
+ frequencies: string[];
+ asset_types: string[];
+ adjustments: { code: string; label: string }[];
+ fields: { code: string; label: string; raw?: boolean }[];
+ limits: {
+ max_symbols: number;
+ max_years: number;
+ max_code_length: number;
+ };
+ internal_only: boolean;
+}
+
+export interface SessionInfo {
+ id: string;
+ created_at?: string;
+ last_seen_at?: string | null;
+ user_agent?: string | null;
+ expires_at?: string | null;
+ current?: boolean;
+}
diff --git a/frontend/src/lib/wizardError.test.ts b/frontend/src/lib/wizardError.test.ts
new file mode 100644
index 0000000..43a6bb0
--- /dev/null
+++ b/frontend/src/lib/wizardError.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+// Live browser QA regression: a failing dataset submit (e.g. HTTP422/500 from
+// the backend) was captured into `submitErr` but never rendered, so the user
+// saw silently nothing. The wizard markup must surface submitErr.
+// Type support comes from vite/client's `*?raw` module declaration.
+import wizardSource from '../components/DatasetWizard.svelte?raw';
+
+describe('dataset wizard visible submit errors', () => {
+ it('renders backend submit failures (submitErr in template, not script-only)', () => {
+ const src: string = wizardSource as unknown as string;
+ // captured at least once
+ expect(src).toMatch(/submitErr = extractStatusMessage\(e\)/);
+ // and rendered outside the script: a template reference (Svelte template)
+ const template = src.slice(src.indexOf('/script') + 7);
+ expect(template).toMatch(/\{submitErr\}/);
+ expect(template).toMatch(/role="alert"/);
+ });
+});
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..d94ef99
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,7 @@
+import { mount } from 'svelte';
+import './app.css';
+import App from './App.svelte';
+
+const app = mount(App, { target: document.getElementById('app')! });
+
+export default app;
diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte
new file mode 100644
index 0000000..ef6d1a3
--- /dev/null
+++ b/frontend/src/pages/AccountPage.svelte
@@ -0,0 +1,189 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import { session } from '../lib/session.svelte';
+ import { getSessions, patchProfile, postPassword, deleteSession } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime } from '../lib/format';
+ import type { SessionInfo } from '../lib/types';
+
+ let nameInput = $state(session.me?.name ?? '');
+ let profileBusy = $state(false);
+ let profileOk = $state<string | null>(null);
+ let profileErr = $state<string | null>(null);
+
+ let curPw = $state('');
+ let newPw = $state('');
+ let newPw2 = $state('');
+ let pwBusy = $state(false);
+ let pwErr = $state<string | null>(null);
+ let pwOk = $state<string | null>(null);
+
+ let sessions: SessionInfo[] = $state([]);
+ let sessBusy = $state(true);
+ let sessErr = $state<string | null>(null);
+ let sessMsg = $state<string | null>(null);
+
+ $effect(() => {
+ nameInput = session.me?.name ?? '';
+ void loadSessions();
+ });
+
+ async function loadSessions() {
+ sessBusy = true;
+ sessErr = null;
+ try {
+ const { items } = await getSessions();
+ sessions = items;
+ } catch (e) {
+ sessErr = extractStatusMessage(e);
+ } finally {
+ sessBusy = false;
+ }
+ }
+
+ async function saveProfile(e: SubmitEvent) {
+ e.preventDefault();
+ profileBusy = true;
+ profileOk = null;
+ profileErr = null;
+ try {
+ await patchProfile(nameInput.trim());
+ profileOk = '资料已更新';
+ await session.refresh();
+ } catch (err) {
+ profileErr = extractStatusMessage(err);
+ } finally {
+ profileBusy = false;
+ }
+ }
+
+ async function changePassword(e: SubmitEvent) {
+ e.preventDefault();
+ pwErr = null;
+ if (newPw.length < 8) {
+ pwErr = '新密码长度需至少 8 位';
+ return;
+ }
+ if (newPw !== newPw2) {
+ pwErr = '两次输入的新密码不一致';
+ return;
+ }
+ pwBusy = true;
+ try {
+ await postPassword(curPw, newPw);
+ pwOk = '密码已更新;其他会话已被登出,请用新密码重新登录本机外的应用。';
+ curPw = '';
+ newPw = '';
+ newPw2 = '';
+ } catch (err) {
+ pwErr = extractStatusMessage(err);
+ } finally {
+ pwBusy = false;
+ }
+ }
+
+ async function revoke(s: SessionInfo) {
+ sessMsg = null;
+ sessErr = null;
+ try {
+ await deleteSession(s.id);
+ sessMsg = s.current ? '该会话已注销;如果你注销的是当前会话,会稍后自动退出。' : '会话已撤销';
+ await loadSessions();
+ await session.refresh();
+ } catch (e) {
+ sessErr = extractStatusMessage(e);
+ }
+ }
+</script>
+
+<Page title="账户与安全" subtitle="资料、密码与个人会话管理">
+ <div class="stack">
+ <section class="card stack">
+ <h2>个人信息</h2>
+ <form onsubmit={saveProfile} class="row">
+ <label style="flex:1 1 200px">
+ 姓名
+ <input bind:value={nameInput} autocomplete="name" />
+ </label>
+ <div>
+ 邮箱
+ <div class="fixed">{session.me?.email ?? '—'}</div>
+ </div>
+ <div>
+ 角色
+ <div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div>
+ </div>
+ <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}>
+ {profileBusy ? '保存中…' : '保存资料'}
+ </button>
+ </form>
+ <Status busy={false} error={profileErr} empty={profileOk} />
+ </section>
+
+ <section class="card stack">
+ <h2>修改密码</h2>
+ <form onsubmit={changePassword} class="stack">
+ <div class="grid3 wrap-3">
+ <label>
+ 当前密码
+ <input type="password" bind:value={curPw} required autocomplete="current-password" />
+ </label>
+ <label>
+ 新密码
+ <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} />
+ </label>
+ <label>
+ 确认新密码
+ <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} />
+ </label>
+ </div>
+ <div class="row">
+ <button class="btn primary" disabled={pwBusy}>
+ {pwBusy ? '更新中…' : '更新密码'}
+ </button>
+ </div>
+ <Status busy={false} error={pwErr} empty={pwOk} />
+ </form>
+ </section>
+
+ <section class="card stack">
+ <h2>登录会话</h2>
+ <Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} />
+ {#if !sessBusy && !sessErr && sessions.length === 0}
+ <Status busy={false} empty="没有其他会话记录" />
+ {/if}
+ {#if sessions.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>创建</th>
+ <th>最近活跃</th>
+ <th>设备/UA</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each sessions as s (s.id)}
+ <tr>
+ <td>{fmtDateTime(s.created_at)}</td>
+ <td>{fmtDateTime(s.last_seen_at)}</td>
+ <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td>
+ <td>
+ <div class="row">
+ {#if s.current}<span class="badge teal">当前会话</span>{/if}
+ <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}>
+ {s.current ? '注销当前' : '撤销'}
+ </button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <Status busy={false} error={null} empty={sessMsg} />
+ <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p>
+ </section>
+ </div>
+</Page>
diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte
new file mode 100644
index 0000000..984962d
--- /dev/null
+++ b/frontend/src/pages/AdminPage.svelte
@@ -0,0 +1,371 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import Modal from '../components/Modal.svelte';
+ import {
+ listAdminUsers,
+ patchAdminUser,
+ createInvitation,
+ listInvitations,
+ deleteInvitation,
+ getAdminAudit,
+ postAdminUserPassword
+ } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime } from '../lib/format';
+ import type { User, Invitation } from '../lib/types';
+
+ let tab = $state<'users' | 'invites' | 'audit'>('users');
+
+ let users: User[] = $state([]);
+ let usersBusy = $state(true);
+ let usersErr = $state<string | null>(null);
+ let resetFor = $state<User | null>(null);
+ let resetToken = $state('');
+ let resetting = $state(false);
+ let adminOpsErr = $state<string | null>(null);
+ let inviteCopied = $state(false);
+
+ let invites: Invitation[] = $state([]);
+ let inviteBusy = $state(true);
+ let inviteErr = $state<string | null>(null);
+ let inviteEmail = $state('');
+ let inviteHours = $state(72);
+ let creatingInvite = $state(false);
+ let newInvite = $state<{ token: string; expires_at: string } | null>(null);
+ let gotToken = $state(false);
+
+ let audit: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] = $state([]);
+ let auditBusy = $state(true);
+ let auditErr = $state<string | null>(null);
+
+ $effect(() => {
+ void loadTab(tab);
+ });
+
+ async function loadTab(t: 'users' | 'invites' | 'audit') {
+ if (t === 'users') await loadUsers();
+ else if (t === 'invites') await loadInvites();
+ else await loadAudit();
+ }
+
+ async function loadUsers() {
+ usersBusy = true;
+ usersErr = null;
+ try {
+ const { items } = await listAdminUsers();
+ users = items;
+ } catch (e) {
+ usersErr = extractStatusMessage(e);
+ } finally {
+ usersBusy = false;
+ }
+ }
+
+ async function loadInvites() {
+ inviteBusy = true;
+ inviteErr = null;
+ try {
+ const { items } = await listInvitations();
+ invites = items;
+ } catch (e) {
+ inviteErr = extractStatusMessage(e);
+ } finally {
+ inviteBusy = false;
+ }
+ }
+
+ async function loadAudit() {
+ auditBusy = true;
+ auditErr = null;
+ try {
+ const res = await getAdminAudit();
+ audit = res.items;
+ } catch (e) {
+ auditErr = extractStatusMessage(e);
+ } finally {
+ auditBusy = false;
+ }
+ }
+
+ async function patchUser(u: User, patch: { active?: boolean; role?: 'admin' | 'member'; daily_run_limit?: number | null; ai_enabled?: boolean }) {
+ adminOpsErr = null;
+ try {
+ const fresh = await patchAdminUser(u.id, patch);
+ users = users.map((x) => (x.id === fresh.id ? fresh : x));
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function issueReset(u: User) {
+ resetFor = u;
+ resetToken = '';
+ adminOpsErr = null;
+ resetting = true;
+ try {
+ const res = await postAdminUserPassword(u.id);
+ resetToken = res.reset_token;
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ resetFor = null;
+ } finally {
+ resetting = false;
+ }
+ }
+
+ async function makeInvite(e: SubmitEvent) {
+ e.preventDefault();
+ creatingInvite = true;
+ inviteErr = null;
+ newInvite = null;
+ try {
+ const res = await createInvitation({
+ email: inviteEmail.trim() || undefined,
+ expires_hours: inviteHours
+ });
+ newInvite = res;
+ inviteEmail = '';
+ await loadInvites();
+ } catch (err) {
+ inviteErr = extractStatusMessage(err);
+ } finally {
+ creatingInvite = false;
+ }
+ }
+
+ async function revokeInvite(i: Invitation) {
+ adminOpsErr = null;
+ try {
+ await deleteInvitation(i.id);
+ await loadInvites();
+ } catch (e) {
+ adminOpsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function copy(text: string) {
+ try {
+ await navigator.clipboard.writeText(text);
+ inviteCopied = true;
+ } catch {
+ /* clipboard unavailable; user will select manually */
+ }
+ }
+
+ const inviteUrl = $derived(
+ newInvite
+ ? `${window.location.origin}${window.location.pathname}#/register?invite=${encodeURIComponent(newInvite.token)}`
+ : ''
+ );
+ const resetUrl = $derived(
+ resetToken
+ ? `${window.location.origin}${window.location.pathname}#/reset?token=${encodeURIComponent(resetToken)}`
+ : ''
+ );
+</script>
+
+<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码">
+ {#snippet actions()}
+ <div class="tabs" role="tablist">
+ <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button>
+ <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button>
+ <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button>
+ </div>
+ {/snippet}
+
+ <div class="stack">
+ <Status busy={false} error={adminOpsErr} empty={null} />
+
+ {#if tab === 'users'}
+ <section class="card stack">
+ <h2>用户</h2>
+ <Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} />
+ {#if !usersBusy && !usersErr && users.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>邮箱</th>
+ <th>姓名</th>
+ <th>角色</th>
+ <th>状态</th>
+ <th>AI 权限</th>
+ <th>日回测上限</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each users as u (u.id)}
+ <tr>
+ <td>{u.email}</td>
+ <td>{u.name}</td>
+ <td>
+ <select
+ value={u.role}
+ aria-label={`角色 ${u.email}`}
+ onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })}
+ >
+ <option value="member" selected={u.role === 'member'}>成员</option>
+ <option value="admin" selected={u.role === 'admin'}>管理员</option>
+ </select>
+ </td>
+ <td>
+ <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span>
+ </td>
+ <td>
+ <label class="checkline">
+ <input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} />
+ </label>
+ </td>
+ <td>
+ <input
+ class="num"
+ style="width:90px"
+ type="number"
+ min="0"
+ max="200"
+ value={u.daily_run_limit ?? 5}
+ onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })}
+ />
+ </td>
+ <td>
+ <div class="row">
+ <button
+ class="btn small {u.active ? 'danger' : ''}"
+ onclick={() => void patchUser(u, { active: !u.active })}
+ >
+ {u.active ? '停用' : '启用'}
+ </button>
+ <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p>
+ </section>
+ {:else if tab === 'invites'}
+ <section class="card stack">
+ <h2>签发邀请</h2>
+ <form onsubmit={makeInvite} class="row">
+ <label style="flex:1 1 220px">
+ 邮箱(可选备注)
+ <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" />
+ </label>
+ <label style="flex:0 0 130px">
+ 有效小时
+ <input type="number" bind:value={inviteHours} min="1" max="720" />
+ </label>
+ <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button>
+ </form>
+ <Status busy={false} error={inviteErr} empty={null} />
+ {#if newInvite}
+ <div class="banner info">
+ <div>
+ <div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div>
+ <div class="mono-invite">{newInvite.token}</div>
+ <div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div>
+ <div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div>
+ <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button>
+ </div>
+ </div>
+ {/if}
+ </section>
+
+ <section class="card stack">
+ <h2>历史邀请</h2>
+ <Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} />
+ {#if !inviteBusy && !inviteErr && invites.length === 0}
+ <Status busy={false} empty="还没有邀请记录" />
+ {/if}
+ {#if !inviteBusy && invites.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>邮箱备注</th>
+ <th>角色</th>
+ <th>创建时间</th>
+ <th>过期</th>
+ <th>状态</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each invites as inv (inv.id)}
+ <tr>
+ <td>{inv.email ?? '—'}</td>
+ <td>{inv.role === 'admin' ? '管理员' : '成员'}</td>
+ <td>{fmtDateTime(inv.created_at)}</td>
+ <td>{fmtDateTime(inv.expires_at)}</td>
+ <td>
+ {#if inv.used_at}<span class="badge grey">已使用</span>
+ {:else if inv.revoked}<span class="badge grey">已撤销</span>
+ {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span>
+ {:else}<span class="badge teal">有效</span>
+ {/if}
+ </td>
+ <td>
+ {#if !inv.used_at && !inv.revoked}
+ <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button>
+ {/if}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p>
+ </section>
+ {:else}
+ <section class="card stack">
+ <h2>安全审计(收尾尾部)</h2>
+ <Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} />
+ {#if !auditBusy && !auditErr && audit.length === 0}
+ <Status busy={false} empty="暂无审计事件" />
+ {/if}
+ {#if !auditBusy && audit.length > 0}
+ <table class="data">
+ <thead>
+ <tr>
+ <th>时间</th>
+ <th>行为者</th>
+ <th>事件</th>
+ <th>对象</th>
+ <th>结果</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each audit as e, i (i)}
+ <tr>
+ <td>{fmtDateTime(e.time)}</td>
+ <td>{e.actor ?? '—'}</td>
+ <td>{e.action}</td>
+ <td class="num">{e.target ?? '—'}</td>
+ <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+ <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p>
+ </section>
+ {/if}
+ </div>
+
+ {#if resetFor}
+ <Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}>
+ <div class="stack">
+ <div class="banner info">
+ <div>
+ <div>恢复链接(管理员线下告知对方,只出现一次):</div>
+ <div class="mono-invite">{resetUrl}</div>
+ <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div>
+ </div>
+ </div>
+ <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p>
+ </div>
+ </Modal>
+ {/if}
+</Page>
diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte
new file mode 100644
index 0000000..9e56be2
--- /dev/null
+++ b/frontend/src/pages/AuthPages.svelte
@@ -0,0 +1,187 @@
+<script lang="ts">
+ import type { Route } from '../lib/router';
+ import { session } from '../lib/session.svelte';
+ import { go } from '../lib/router';
+ import { postLogin, postRegister, postResetPassword } from '../lib/client';
+ import { extractStatusMessage, statusByName } from '../lib/state';
+
+ interface Props {
+ route: Route;
+ }
+ let { route }: Props = $props();
+
+ const mode = $derived(route.name);
+
+ let email = $state('');
+ let password = $state('');
+ let name = $state('');
+ // Initial route query is deliberately read once: token is consumed at page load.
+ // svelte-ignore state_referenced_locally
+ let inviteToken = $state(route.query.get('invite') ?? '');
+ // svelte-ignore state_referenced_locally
+ let resetToken = $state(route.query.get('token') ?? '');
+ let busy = $state(false);
+ let resultError = $state<string | null>(null);
+ let done = $state<string | null>(null);
+ let pw2 = $state('');
+ let newPassword = $state('');
+
+ async function login(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ await postLogin(email.trim(), password);
+ const user = await session.refresh();
+ if (user) session.set(user);
+ go('/projects');
+ } catch (err) {
+ if (statusByName(err) === 'unauthorized') resultError = '邮箱或密码不正确,或账户已被停用';
+ else resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+
+ async function register(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ const { user } = await postRegister(inviteToken.trim(), name.trim(), email.trim(), password);
+ session.set(user);
+ go('/projects');
+ } catch (err) {
+ resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+
+ async function reset(e: SubmitEvent) {
+ e.preventDefault();
+ busy = true;
+ resultError = null;
+ try {
+ if (!resetToken.trim()) {
+ resultError = '链接中的重置令牌未提供。请使用管理员发给您的完整恢复链接打开本页。';
+ return;
+ }
+ if (newPassword.length < 8) {
+ resultError = '新密码长度需至少 8 位';
+ return;
+ }
+ if (newPassword !== pw2) {
+ resultError = '两次输入的新密码不一致';
+ return;
+ }
+ await postResetPassword(resetToken.trim(), newPassword);
+ done = '密码已重置,现在可以用新密码登录。';
+ } catch (err) {
+ resultError = extractStatusMessage(err);
+ } finally {
+ busy = false;
+ }
+ }
+</script>
+
+<section class="auth-wrap">
+ <div class="auth-card card" role="main">
+ <div class="auth-brand">
+ <span class="mark" aria-hidden="true"></span>
+ <h1>策研 <span class="sub">Strategy Lab</span></h1>
+ <p class="hint">内部策略研究平台 · 仅限受邀成员</p>
+ </div>
+
+ {#if mode === 'register'}
+ <form onsubmit={register}>
+ <div>
+ <label for="inv">邀请令牌</label>
+ <input id="inv" bind:value={inviteToken} required autocomplete="off" />
+ <p class="hint">单次有效,来自管理员邀请</p>
+ </div>
+ <div>
+ <label for="nm">姓名</label>
+ <input id="nm" bind:value={name} required autocomplete="name" />
+ </div>
+ <div>
+ <label for="em">邮箱</label>
+ <input id="em" type="email" bind:value={email} required autocomplete="email" />
+ </div>
+ <div>
+ <label for="pw">密码</label>
+ <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} />
+ <div class="hint">至少 8 位</div>
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button>
+ <p class="hint">已有账户? <a href="#/login">登录</a></p>
+ </form>
+ {:else if mode === 'reset'}
+ {#if done}
+ <div class="banner info">{done}</div>
+ <p><a href="#/login">前往登录</a></p>
+ {:else}
+ <form onsubmit={reset}>
+ <div>
+ <label for="rt">重置令牌</label>
+ <input id="rt" bind:value={resetToken} autocomplete="off" />
+ <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p>
+ </div>
+ <div>
+ <label for="np">新密码</label>
+ <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} />
+ </div>
+ <div>
+ <label for="np2">再输入一次新密码</label>
+ <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} />
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button>
+ <p class="hint">想起密码了? <a href="#/login">直接登录</a></p>
+ </form>
+ {/if}
+ {:else}
+ <form onsubmit={login}>
+ <div>
+ <label for="le">邮箱</label>
+ <input id="le" type="email" bind:value={email} required autocomplete="email" />
+ </div>
+ <div>
+ <label for="lp">密码</label>
+ <input id="lp" type="password" bind:value={password} required autocomplete="current-password" />
+ </div>
+ {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if}
+ <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
+ <p class="hint">
+ 开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a>
+ {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if}
+ </p>
+ <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p>
+ </form>
+ {/if}
+ </div>
+</section>
+
+<style>
+ .auth-wrap {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+ background:
+ radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent),
+ var(--bg);
+ }
+ .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; }
+ .auth-brand { margin-bottom: 1.1rem; }
+ .mark {
+ display: inline-block;
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ background: var(--teal-strong);
+ margin-bottom: 0.6rem;
+ }
+ .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; }
+</style>
diff --git a/frontend/src/pages/DatasetsPage.svelte b/frontend/src/pages/DatasetsPage.svelte
new file mode 100644
index 0000000..b348ee3
--- /dev/null
+++ b/frontend/src/pages/DatasetsPage.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import ProjectDataTab from './ProjectDataTab.svelte';
+</script>
+
+<Page title="数据集" subtitle="你的所有数据请求与物化数据(跨项目共享目录,锁定标的信息与覆盖情况)">
+ <ProjectDataTab />
+</Page>
diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte
new file mode 100644
index 0000000..baa9e5f
--- /dev/null
+++ b/frontend/src/pages/ProjectBacktestTab.svelte
@@ -0,0 +1,384 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import { listDatasets, createRun, getRun, cancelRun, rerunRun, listRuns, getDatasetPreview } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import {
+ fmtDateTime,
+ fmtPct,
+ formatMoney,
+ datasetStatusLabel,
+ runStatusLabel,
+ runStatusBadge
+ } from '../lib/format';
+ import { summarizeCoverage } from '../lib/coverage';
+ import type { Run, Dataset } from '../lib/types';
+
+ interface Props {
+ projectId: string;
+ }
+ let { projectId }: Props = $props();
+
+ // config inputs
+ let datasetsReady: Dataset[] = $state([]);
+ let busyDatasets = $state(true);
+ let cfgErr = $state<string | null>(null);
+
+ let datasetId = $state('');
+ let capital = $state<number>(1_000_000);
+ let commission = $state(0.0003);
+ let slippage = $state(0.001);
+ let benchmark = $state('');
+ let paramsText = $state('');
+ let acknowledged = $state(false);
+
+ // life cycle
+ let latest = $state<Run | null>(null);
+ let latestErr = $state<string | null>(null);
+ let cancelBusy = $state(false);
+ let rerunBusy = $state(false);
+ let actionMsg = $state<string | null>(null);
+
+ let history: Run[] = $state([]);
+ let historyBusy = $state(false);
+ let historyErr = $state<string | null>(null);
+
+ $effect(() => {
+ void loadDatasets();
+ void loadLatest(true);
+ return () => {
+ if (pollTimer) clearInterval(pollTimer);
+ };
+ });
+
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
+
+ function isLiveStatus(r: Run | null | undefined) {
+ return r?.status === 'queued' || r?.status === 'running';
+ }
+
+ async function loadDatasets() {
+ busyDatasets = true;
+ cfgErr = null;
+ try {
+ const { items } = await listDatasets();
+ datasetsReady = items.filter((d) => d.status === 'ready');
+ if (items.some((d) => d.status === 'pending' || d.status === 'running')) {
+ if (!waitTimer) {
+ waitTimer = setInterval(() => {
+ void loadDatasets();
+ }, 2500);
+ }
+ } else {
+ stopWait();
+ }
+ } catch (e) {
+ cfgErr = extractStatusMessage(e);
+ } finally {
+ busyDatasets = false;
+ }
+ }
+
+ let waitTimer: ReturnType<typeof setInterval> | null = null;
+
+ function stopWait() {
+ if (waitTimer) {
+ clearInterval(waitTimer);
+ waitTimer = null;
+ }
+ }
+
+ async function loadLatest(startPolling: boolean) {
+ latestErr = null;
+ try {
+ const res = await listRuns(projectId);
+ history = res.items;
+ latest = res.items[0] ?? null;
+ if (startPolling && isLiveStatus(latest) && !pollTimer) {
+ pollTimer = setInterval(() => void loadLatest(false), 2000);
+ }
+ if (!isLiveStatus(latest) && pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ await refreshHistory();
+ }
+ } catch (e) {
+ latestErr = extractStatusMessage(e);
+ }
+ }
+
+ async function refreshHistory() {
+ historyBusy = true;
+ historyErr = null;
+ try {
+ const res = await listRuns(projectId);
+ history = res.items;
+ } catch (e) {
+ historyErr = extractStatusMessage(e);
+ } finally {
+ historyBusy = false;
+ }
+ }
+
+ async function previewWarningsForDataset(id: string) {
+ try {
+ const preview = await getDatasetPreview(id);
+ const cov = summarizeCoverage(preview.coverage ?? []);
+ datasetWarnMsgs = [...cov.warnings, ...(preview.warnings ?? [])];
+ } catch {
+ datasetWarnMsgs = [];
+ }
+ acknowledged = false;
+ }
+
+ let datasetWarnMsgs: string[] = $state([]);
+
+ const selectedDataset = $derived(datasetsReady.find((d) => d.id === datasetId) ?? null);
+ const needAck = $derived(datasetWarnMsgs.length > 0);
+
+ $effect(() => {
+ if (datasetId) void previewWarningsForDataset(datasetId);
+ });
+
+ async function startRun() {
+ const errs: string[] = [];
+ if (!datasetId) errs.push('请选择一个就绪的数据集');
+ if (!(capital > 0) || !Number.isFinite(capital)) errs.push('起始资金必须是正数');
+ if (!(commission >= 0 && commission < 0.1)) errs.push('佣金率应在 0 与 0.1 之间');
+ if (!(slippage >= 0 && slippage < 0.1)) errs.push('滑点应在 0 与 0.1 之间');
+ let parameters: Record<string, unknown> | undefined;
+ if (paramsText.trim()) {
+ try {
+ parameters = JSON.parse(paramsText);
+ } catch {
+ errs.push('参数必须是合法 JSON');
+ }
+ }
+ if (errs.length > 0) {
+ cfgErr = errs.join(';');
+ return;
+ }
+ cfgErr = null;
+ if (needAck && !acknowledged) {
+ cfgErr = '数据覆盖有差异,请先勾选确认已知晓后再启动回测';
+ return;
+ }
+ try {
+ const run = await createRun({
+ project_id: projectId,
+ dataset_id: datasetId,
+ capital,
+ commission,
+ slippage,
+ benchmark_symbol: benchmark.trim() || undefined,
+ parameters,
+ acknowledge_warnings: acknowledged
+ });
+ latest = run;
+ await loadLatest(true);
+ actionMsg = `回测已提交,编号 ${run.id.slice(0, 8)}…`;
+ void refreshHistory();
+ } catch (e) {
+ cfgErr = extractStatusMessage(e);
+ const details = (e as { details?: { errors?: string[] } }).details?.errors;
+ if (details) cfgErr = details.join(';');
+ }
+ }
+
+
+
+ async function onCancel() {
+ if (!latest) return;
+ cancelBusy = true;
+ try {
+ latest = await cancelRun(latest.id);
+ actionMsg = '已请求取消';
+ } catch (e) {
+ actionMsg = extractStatusMessage(e);
+ } finally {
+ cancelBusy = false;
+ }
+ }
+
+ async function onRerun() {
+ if (!latest) return;
+ rerunBusy = true;
+ try {
+ const run = await rerunRun(latest.id, true);
+ latest = run;
+ await loadLatest(true);
+ actionMsg = `已用原始代码与数据重新提交,编号 ${run.id.slice(0, 8)}…`;
+ } catch (e) {
+ actionMsg = extractStatusMessage(e);
+ } finally {
+ rerunBusy = false;
+ }
+ }
+
+ const canCancel = $derived(isLiveStatus(latest));
+ const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled');
+</script>
+
+<div class="stack">
+ <section class="card stack">
+ <h2>发起回测</h2>
+ <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} />
+ {#if !busyDatasets && datasetsReady.length === 0 && !cfgErr}
+ <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" />
+ {/if}
+ {#if datasetsReady.length > 0}
+ <div class="grid3 wrap-3">
+ <label>
+ 数据集
+ <select bind:value={datasetId}>
+ <option value="" disabled>选择数据集…</option>
+ {#each datasetsReady as d (d.id)}
+ <option value={d.id}>
+ {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')}
+ </option>
+ {/each}
+ </select>
+ </label>
+ <label>
+ 起始资金
+ <input class="num" type="number" bind:value={capital} min="1" step="1000" />
+ </label>
+ <div class="row2">
+ <label>
+ 佣金率
+ <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" />
+ </label>
+ <label>
+ 滑点率
+ <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" />
+ </label>
+ </div>
+ <label>
+ 基准代码(可选)
+ <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" />
+ </label>
+ <label>
+ 策略参数(JSON,可选)
+ <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea>
+ </label>
+ </div>
+ {#if needAck}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <div>
+ 该数据集存在覆盖/预警信息,需要明确知晓后才能启动:
+ <ul>
+ {#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each}
+ </ul>
+ <label class="checkline">
+ <input type="checkbox" bind:checked={acknowledged} />
+ 我已知晓并用当前数据继续
+ </label>
+ </div>
+ </div>
+ {/if}
+ <div class="row">
+ <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button>
+ {#if selectedDataset}
+ <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span>
+ {/if}
+ </div>
+ <Status busy={false} error={cfgErr} empty={actionMsg} />
+ {/if}
+ </section>
+
+ <section class="card stack">
+ <div class="spread">
+ <h2>最近一次回测</h2>
+ {#if latest}
+ <span class="row">
+ <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span>
+ <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span>
+ </span>
+ {/if}
+ </div>
+
+ {#if latest}
+ <Status
+ busy={isLiveStatus(latest)}
+ busyText="排队/执行中,页面会自动刷新状态…"
+ empty={null}
+ error={latestErr}
+ />
+ <div class="rows-grid">
+ <table class="data kv">
+ <tbody>
+ <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr>
+ {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if}
+ {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if}
+ <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr>
+ <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr>
+ <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr>
+ </tbody>
+ </table>
+ </div>
+ {#if latest.error}
+ <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div>
+ {/if}
+ {#if latest.result?.warnings?.length}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <ul class="plain-list">
+ {#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each}
+ </ul>
+ </div>
+ {/if}
+ {#if latest.status === 'succeeded' && latest.result}
+ <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a>
+ {/if}
+ <div class="row">
+ {#if canCancel}
+ <button class="btn danger" onclick={onCancel} disabled={cancelBusy}>
+ {cancelBusy ? '取消中…' : '取消回测'}
+ </button>
+ {/if}
+ {#if canRerun}
+ <button class="btn" onclick={onRerun} disabled={rerunBusy}>
+ {rerunBusy ? '重新提交…' : '按原始代码&数据重跑'}
+ </button>
+ {/if}
+ </div>
+ {:else}
+ <Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" />
+ {/if}
+ </section>
+
+ <section class="stack">
+ <h2>回测历史</h2>
+ <Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} />
+ {#if history && history.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>启动时间</th>
+ <th>状态</th>
+ <th>资金/佣金/滑点</th>
+ <th>数据集</th>
+ <th>结果</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each history as h (h.id)}
+ <tr>
+ <td>{fmtDateTime(h.created_at)}</td>
+ <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td>
+ <td>
+ {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)}
+ </td>
+ <td class="num">{h.dataset_id?.slice(0, 8)}…</td>
+ <td>
+ <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ </section>
+</div>
diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte
new file mode 100644
index 0000000..e3b1923
--- /dev/null
+++ b/frontend/src/pages/ProjectDataTab.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import DatasetCard from '../components/DatasetCard.svelte';
+ import DatasetWizard from '../components/DatasetWizard.svelte';
+ import { listDatasets } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import type { Dataset } from '../lib/types';
+
+ interface Props {
+ projectId?: string;
+ showWizard?: boolean;
+ }
+ let { projectId: _projectId, showWizard = false }: Props = $props();
+
+ let items: Dataset[] = $state([]);
+ let busy = $state(true);
+ let error: string | null = $state(null);
+ // svelte-ignore state_referenced_locally
+ let wizardOpen = $state(showWizard);
+
+
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
+
+ $effect(() => {
+ void load();
+ return () => {
+ if (refreshTimer) clearInterval(refreshTimer);
+ refreshTimer = null;
+ };
+ });
+
+ async function load() {
+ error = null;
+ try {
+ const { items: got } = await listDatasets();
+ items = got;
+ const active = got.some((d) => d.status === 'pending' || d.status === 'running');
+ if (active && !refreshTimer) {
+ refreshTimer = setInterval(async () => {
+ try {
+ const { items: fresh } = await listDatasets();
+ items = fresh;
+ const still = fresh.some((d) => d.status === 'pending' || d.status === 'running');
+ if (!still && refreshTimer) {
+ clearInterval(refreshTimer);
+ refreshTimer = null;
+ }
+ } catch {
+ /* transient error, keep polling */
+ }
+ }, 2000);
+ }
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+</script>
+
+<div class="stack">
+ {#if wizardOpen}
+ <DatasetWizard onCreated={() => void load()} />
+ <button class="btn ghost" onclick={() => (wizardOpen = false)}>收起新建面板</button>
+ {:else}
+ <button class="btn primary" onclick={() => (wizardOpen = true)}>新建数据集</button>
+ {/if}
+
+ <h2>我的数据集</h2>
+ <Status busy={busy} error={error} busyText="加载数据集列表…" empty={null} />
+
+ {#if !busy && !error && items.length === 0}
+ <Status
+ busy={false}
+ empty="还没有数据集"
+ emptyHint="先创建数据集:选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览"
+ />
+ {/if}
+
+ {#if !busy && !error && items.length > 0}
+ {#each items as ds (ds.id)}
+ <DatasetCard dsId={ds.id} />
+ {/each}
+ {/if}
+</div>
diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte
new file mode 100644
index 0000000..b31caa3
--- /dev/null
+++ b/frontend/src/pages/ProjectPage.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import type { Route } from '../lib/router';
+ import ProjectDataTab from '../pages/ProjectDataTab.svelte';
+ import ProjectStrategyTab from '../pages/ProjectStrategyTab.svelte';
+ import ProjectBacktestTab from '../pages/ProjectBacktestTab.svelte';
+ import ProjectResultsTab from '../pages/ProjectResultsTab.svelte';
+
+ interface Props {
+ route: Route;
+ }
+ let { route }: Props = $props();
+</script>
+
+<!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. -->
+{#if route.name === 'project-data'}
+ <ProjectDataTab projectId={route.params.id} />
+{:else if route.name === 'project-strategy'}
+ <ProjectStrategyTab projectId={route.params.id} />
+{:else if route.name === 'project-backtest'}
+ <ProjectBacktestTab projectId={route.params.id} />
+{:else if route.name === 'project-results'}
+ <ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} />
+{/if}
diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte
new file mode 100644
index 0000000..4149213
--- /dev/null
+++ b/frontend/src/pages/ProjectResultsTab.svelte
@@ -0,0 +1,299 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import Chart from '../components/Chart.svelte';
+ import type { EChartsOption, LineSeriesOption } from 'echarts';
+import { listRuns, getRun } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import {
+ fmtDateTime,
+ fmtPct,
+ fmtNum,
+ formatMoney,
+ runStatusLabel,
+ runStatusBadge,
+ metricsLabels,
+ metricsRow
+ } from '../lib/format';
+ import type { Run, BacktestResult } from '../lib/types';
+
+ interface Props {
+ projectId: string;
+ runId?: string;
+ }
+ let { projectId, runId }: Props = $props();
+
+ let run = $state<Run | null>(null);
+ let busy = $state(true);
+ let error = $state<string | null>(null);
+ let history = $state<Run[]>([]);
+ let logsOpen = $state(false);
+ let ordersOpen = $state(false);
+ let chartMode = $state<'equity' | 'drawdown'>('equity');
+
+ $effect(() => {
+ void load();
+ });
+
+ async function load() {
+ busy = true;
+ error = null;
+ try {
+ if (!runId) {
+ const res = await listRuns(projectId);
+ history = res.items;
+ run = res.items[0] ?? null;
+ } else {
+ run = await getRun(runId);
+ }
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+
+ const result = $derived<BacktestResult | null>(run?.result ?? null);
+ const hasEquity = $derived(!!result && result.equity.length > 0);
+
+ const equityOption: EChartsOption = $derived.by(() => {
+ if (!result || !hasEquity) return {};
+ const dates = result.equity.map((p) => p.date);
+ const series: LineSeriesOption[] = [
+ {
+ name: "策略权益",
+ type: "line" as const,
+ data: result.equity.map((p) => p.equity),
+ showSymbol: false,
+ lineStyle: { width: 1.6 },
+ itemStyle: { color: "#0f766e" }
+ },
+ {
+ name: "现金",
+ type: "line" as const,
+ data: result.equity.map((p) => p.cash),
+ showSymbol: false,
+ lineStyle: { type: "dashed", opacity: 0.65 },
+ itemStyle: { color: "#94a3b8" }
+ }
+ ];
+ const bench = result.equity.map((p) => (p.benchmark === null || p.benchmark === undefined ? null : p.benchmark));
+ if (bench.some((x) => x !== null && x !== undefined)) {
+ series.push({
+ name: "基准",
+ type: "line" as const,
+ data: bench as number[],
+ showSymbol: false,
+ itemStyle: { color: "#eab308" }
+ });
+ }
+ return {
+ title: { text: undefined },
+ legend: { top: 0 },
+ grid: { top: 32, left: 60, right: 24, bottom: 54 },
+ tooltip: { trigger: "axis" },
+ xAxis: { type: "category", data: dates },
+ yAxis: { type: "value", scale: true, name: "金额" },
+ series
+ };
+ });
+
+ const drawdownOption: EChartsOption = $derived.by(() => {
+ if (!result || !hasEquity) return {};
+ const dates = result.equity.map((p) => p.date);
+ let peak = -Infinity;
+ const dd = result.equity.map((p) => {
+ peak = Math.max(peak, p.equity);
+ return -((peak - p.equity) / peak) * 100;
+ });
+ return {
+ grid: { top: 24, left: 60, right: 24, bottom: 54 },
+ tooltip: { trigger: "axis", valueFormatter: (v) => `${(v as number).toFixed(2)}%` },
+ xAxis: { type: "category", data: dates },
+ yAxis: { type: "value", name: "回撤 %", max: 0 },
+ series: [
+ {
+ name: "回撤",
+ type: "line" as const,
+ data: dd,
+ showSymbol: false,
+ areaStyle: { color: "rgba(30, 74, 66, 0.25)" },
+ lineStyle: { width: 1 },
+ itemStyle: { color: "#134e4a" }
+ }
+ ]
+ };
+ });
+
+ function switchToRun(id: string) {
+ runId = id;
+ void load();
+ }
+
+ const ml = $derived(metricsLabels());
+ const mr = $derived(result ? metricsRow(result.metrics) : []);
+</script>
+
+<div class="stack">
+ <div class="spread">
+ <h2>回测结果</h2>
+ {#if run}
+ <span class="row">
+ <span class="hint">Run {run.id.slice(0, 8)}…</span>
+ <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span>
+ </span>
+ {/if}
+ </div>
+
+ <Status busy={busy} error={error} busyText="加载结果…" empty={null} />
+
+ {#if !busy && !error && !run}
+ <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" />
+ {/if}
+
+ {#if run && !busy && !error}
+ {#if history.length > 1}
+ <div class="row">
+ <label for="runsel" class="hint" style="margin:0">查看其他回测:</label>
+ <select id="runsel" style="max-width:320px" value={run?.id ?? ""} onchange={(e) => switchToRun((e.target as HTMLSelectElement).value)}>
+ {#each history as h (h.id)}
+ <option value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</option>
+ {/each}
+ </select>
+ </div>
+ {/if}
+
+ {#if run.error}
+ <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div>
+ {/if}
+
+ {#if !result}
+ <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} />
+ {:else}
+ {#if result.warnings?.length}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <div>
+ <ul class="ul-plain">
+ {#each result.warnings as w, i (i)}<li>{w}</li>{/each}
+ </ul>
+ </div>
+ </div>
+ {/if}
+
+ <div class="card">
+ {#if result.metrics}
+ {@const mrow = result.metrics}
+ <div class="metric-grid">
+ <div class="metric">
+ <div class="mlabel">总收益率</div>
+ <div class="mvalue">{fmtPct(mrow.total_return)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">年化收益率</div>
+ <div class="mvalue">{fmtPct(mrow.annual_return)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">最大回撤</div>
+ <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">夏普比率</div>
+ <div class="mvalue">{fmtNum(mrow.sharpe)}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">平仓回合数</div>
+ <div class="mvalue">{mrow.trade_count ?? "—"}</div>
+ </div>
+ <div class="metric">
+ <div class="mlabel">期末权益</div>
+ <div class="mvalue">{formatMoney(mrow.final_equity)}</div>
+ </div>
+ </div>
+ {/if}
+ <p class="hint">
+ 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。
+ 引擎 {result.engine?.name} {result.engine?.version}
+ {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if}
+ {#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if}
+ </p>
+ </div>
+
+ <div class="card stack">
+ <div class="row">
+ <button class="btn small" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</button>
+ <button class="btn small" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</button>
+ </div>
+ {#if chartMode === "equity"}
+ <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} />
+ {:else}
+ <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} />
+ {/if}
+ </div>
+
+ <div class="card stack">
+ <h2>订单与持仓记录</h2>
+ <div class="cols-2">
+ <div>
+ <h3>成交记录 ({result.trades.length})</h3>
+ {#if result.trades.length > 0}
+ <div class="table-wrap">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>日期</th>
+ <th>标的</th>
+ <th>方向</th>
+ <th>数量</th>
+ <th>价格</th>
+ <th>金额</th>
+ <th>佣金</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each result.trades.slice(0, 80) as t, i (i)}
+ <tr>
+ <td>{t.date}</td>
+ <td class="num">{t.symbol}</td>
+ <td>{t.side === "buy" ? "买入" : "卖出"}</td>
+ <td class="num">{t.quantity}</td>
+ <td class="num">{fmtNum(t.price, 3)}</td>
+ <td class="num">{fmtNum(t.value)}</td>
+ <td class="num">{fmtNum(t.commission, 4)}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {#if result.trades.length > 80}
+ <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p>
+ {/if}
+ {:else}
+ <p class="hint">本区间未发生成交</p>
+ {/if}
+ </div>
+ <div>
+ <h3>策略日志</h3>
+ <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}>
+ {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`}
+ </button>
+ {#if logsOpen}
+ <pre class="logs">{result.logs.join("\n")}</pre>
+ {/if}
+ </div>
+ </div>
+ <div>
+ <h3>数据与引擎
+ <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span>
+ </h3>
+ <p class="hint">
+ 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。
+ </p>
+ </div>
+ </div>
+ {/if}
+ {/if}
+
+ {#if !busy && !error && history.length === 0 && run === null}
+ <Status busy={false} empty="还没有可展示的回测" emptyHint="在「回测」标签先启动一遍" />
+ {/if}
+</div>
diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte
new file mode 100644
index 0000000..5f2e242
--- /dev/null
+++ b/frontend/src/pages/ProjectStrategyTab.svelte
@@ -0,0 +1,420 @@
+<script lang="ts">
+ import Status from '../components/Status.svelte';
+ import CodeEditor from '../components/CodeEditor.svelte';
+ import DiffView from '../components/DiffView.svelte';
+ import Modal from '../components/Modal.svelte';
+ import { projectsStore } from '../lib/listsStore.svelte';
+ import {
+ getProject,
+ putDraft,
+ listVersions,
+ postVersion,
+ getVersionCode,
+ postRestore,
+ postAIAssist,
+ postAIAccept
+ } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime, versionSourceLabel } from '../lib/format';
+ import type { Project, Version, AIAssist } from '../lib/types';
+
+ interface Props {
+ projectId: string;
+ }
+ let { projectId }: Props = $props();
+
+ let code = $state('');
+ let baseGeneration = $state(0);
+ let baseCode = $state('');
+ let saveStatus = $state<'saved' | 'saving' | 'dirty' | 'conflict'>('saved');
+ let saveMsg = $state('');
+ let versions: Version[] = $state([]);
+ let versionsBusy = $state(false);
+ let versionsErr = $state<string | null>(null);
+ let snapshotBusy = $state(false);
+ let snapshotMsg = $state<string | null>(null);
+ let snapshotErr = $state<string | null>(null);
+ let snapshotMessage = $state('');
+ let conflictServer: { generation: number; code: string } | null = $state(null);
+ let comparing: { label: string; before: string; after: string } | null = $state(null);
+
+ let instruction = $state('');
+ let aiBusy = $state(false);
+ let aiErr = $state<string | null>(null);
+ let aiResult = $state<AIAssist | null>(null);
+ let aiAcceptBusy = $state(false);
+ let aiAcceptErr = $state<string | null>(null);
+ let aiNote = $state<string | null>(null);
+
+ let initBusy = $state(true);
+ let initErr = $state<string | null>(null);
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
+
+ $effect(() => {
+ void init();
+ return () => {
+ if (saveTimer) clearTimeout(saveTimer);
+ };
+ });
+
+ async function init() {
+ initBusy = true;
+ initErr = null;
+ try {
+ const p: Project = await getProject(projectId);
+ projectsStore.bump(p);
+ code = p.draft_code ?? '';
+ baseGeneration = p.draft_generation;
+ baseCode = code;
+ await refreshVersions();
+ } catch (e) {
+ initErr = extractStatusMessage(e);
+ } finally {
+ initBusy = false;
+ }
+ }
+
+ async function refreshVersions() {
+ versionsBusy = true;
+ versionsErr = null;
+ try {
+ const { items } = await listVersions(projectId);
+ versions = items;
+ } catch (e) {
+ versionsErr = extractStatusMessage(e);
+ } finally {
+ versionsBusy = false;
+ }
+ }
+
+ function onEditorInput(v: string) {
+ if (saveStatus !== 'dirty') saveStatus = 'dirty';
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(() => void saveDraft(v), 800);
+ saveMsg = '输入暂停后自动保存';
+ }
+
+ async function saveDraft(payload?: string) {
+ const toSave = payload ?? code;
+ if (!toSave && !baseCode) return;
+ saveStatus = 'saving';
+ saveMsg = '保存中…';
+ try {
+ const p = await putDraft(projectId, toSave, baseGeneration);
+ code = toSave;
+ baseGeneration = p.draft_generation;
+ baseCode = toSave;
+ projectsStore.bump(p);
+ saveStatus = 'saved';
+ saveMsg = `已保存 · 草稿代 ${baseGeneration}`;
+ if (aiNote) aiNote = null;
+ } catch (e) {
+ const err = e as { status?: number; code?: string };
+ if (err.status === 409 && err.code === 'stale_generation') {
+ const fresh = await getProject(projectId);
+ saveStatus = 'conflict';
+ conflictServer = { generation: fresh.draft_generation, code: fresh.draft_code ?? '' };
+ saveMsg = '检测到冲突';
+ } else {
+ saveStatus = 'dirty';
+ saveMsg = extractStatusMessage(e);
+ }
+ }
+ }
+
+ async function keepMine() {
+ if (!conflictServer) return;
+ try {
+ const p = await putDraft(projectId, code, conflictServer.generation);
+ baseGeneration = p.draft_generation;
+ baseCode = code;
+ saveStatus = 'saved';
+ saveMsg = '已覆盖保存本机内容';
+ conflictServer = null;
+ } catch (e) {
+ saveMsg = extractStatusMessage(e);
+ }
+ }
+
+ function loadServer() {
+ if (!conflictServer) return;
+ code = conflictServer.code;
+ baseGeneration = conflictServer.generation;
+ baseCode = code;
+ saveStatus = 'saved';
+ saveMsg = '已采用服务器草稿';
+ conflictServer = null;
+ }
+
+ async function snapshot() {
+ snapshotBusy = true;
+ snapshotErr = null;
+ snapshotMsg = null;
+ try {
+ await postVersion(projectId, snapshotMessage.trim() || '保存版本快照');
+ snapshotMessage = '';
+ snapshotMsg = '版本快照已创建';
+ await refreshVersions();
+ } catch (e) {
+ snapshotErr = extractStatusMessage(e);
+ } finally {
+ snapshotBusy = false;
+ }
+ }
+
+ async function compareWithDraft(v: Version) {
+ versionsErr = null;
+ try {
+ const { code: vCode } = await getVersionCode(projectId, v.id);
+ comparing = { label: `版本 ${v.hash.slice(0, 10)} · ${v.message}`, before: vCode, after: code };
+ } catch (e) {
+ versionsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function compareTwoVersions(i: number) {
+ const v = versions[i];
+ const prev = versions[i + 1];
+ if (!prev) return;
+ try {
+ const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]);
+ comparing = {
+ label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`,
+ before: b.code,
+ after: a.code
+ };
+ } catch (e) {
+ versionsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function restore(v: Version) {
+ versionsErr = null;
+ try {
+ const p = await postRestore(projectId, v.id, baseGeneration);
+ code = p.draft_code ?? '';
+ baseGeneration = p.draft_generation;
+ baseCode = code;
+ projectsStore.bump(p);
+ versionsErr = null;
+ aiNote = `已从该版本恢复为新草稿(代 ${baseGeneration}),历史未改写`;
+ await refreshVersions();
+ } catch (e) {
+ versionsErr = extractStatusMessage(e);
+ }
+ }
+
+ async function askAI() {
+ if (!instruction.trim()) return;
+ aiBusy = true;
+ aiErr = null;
+ try {
+ const res = await postAIAssist(projectId, instruction.trim(), baseGeneration);
+ aiResult = res;
+ if (res.status && res.status !== 'ok') {
+ aiErr = `模型返回状态 ${res.status},未生成代码建议`;
+ }
+ } catch (e) {
+ aiErr = extractStatusMessage(e);
+ aiResult = null;
+ } finally {
+ aiBusy = false;
+ }
+ }
+
+ async function acceptAI() {
+ if (!aiResult) return;
+ aiAcceptBusy = true;
+ aiAcceptErr = null;
+ try {
+ const p = await postAIAccept(aiResult.id, baseGeneration);
+ code = p.draft_code ?? aiResult.proposed_code;
+ baseGeneration = p.draft_generation;
+ baseCode = code;
+ projectsStore.bump(p);
+ aiNote = 'AI 建议已接受,生成新的代码版本';
+ aiResult = null;
+ instruction = '';
+ await refreshVersions();
+ } catch (e) {
+ const err = e as { status?: number; code?: string };
+ if (err.status === 409) {
+ aiAcceptErr = '草稿代已变化,建议可能失效。请重新发起 AI 说明或刷新草稿后重试。';
+ } else {
+ aiAcceptErr = extractStatusMessage(e);
+ }
+ } finally {
+ aiAcceptBusy = false;
+ }
+ }
+
+ function discardAI() {
+ aiResult = null;
+ aiErr = null;
+ aiAcceptErr = null;
+ }
+</script>
+
+<div class="stack">
+ <Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} />
+
+ {#if !initBusy && !initErr}
+ <div class="card stack">
+ <div class="spread">
+ <h2>策略代码(Backtrader Python)</h2>
+ <span class="save-status" role="status">
+ {saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`}
+ {#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if}
+ {#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if}
+ {#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if}
+ </span>
+ </div>
+ <p class="hint">
+ 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照
+ </p>
+ <CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} />
+ <div class="spread wrap">
+ <input
+ bind:value={snapshotMessage}
+ placeholder="快照说明,例如:加入RSI过滤器(可选)"
+ style="flex:2 1 240px"
+ onkeydown={(ev) => {
+ if (ev.key === 'Enter') void snapshot();
+ }}
+ />
+ <button class="btn" disabled={snapshotBusy} onclick={snapshot}>
+ {snapshotBusy ? '创建快照…' : '保存版本快照'}
+ </button>
+ <button
+ class="btn"
+ onclick={() => versions[1] && void compareWithDraft(versions[1])}
+ disabled={!versions[1]}
+ >
+ 草稿与上一快照对比
+ </button>
+ </div>
+ <Status busy={false} error={snapshotErr} empty={snapshotMsg} />
+
+ {#if conflictServer}
+ <div class="banner warn" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <span>
+ 服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。
+ </span>
+ </div>
+ <DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" />
+ <div class="row">
+ <button class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button>
+ <button class="btn" onclick={loadServer}>采用服务器版本</button>
+ </div>
+ {/if}
+ </div>
+
+ <div class="stack">
+ <h2>AI 助手</h2>
+ <div class="card stack">
+ <label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label>
+ <textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea>
+ <div class="row">
+ <button class="btn primary" disabled={aiBusy || !instruction.trim()} onclick={askAI}>
+ {aiBusy ? 'AI 处理中…' : '获取建议'}
+ </button>
+ </div>
+ {#if aiErr}
+ <div class="banner error" role="alert">
+ <span aria-hidden="true">⚠</span>
+ <span>{aiErr}</span>
+ </div>
+ {/if}
+
+ {#if aiResult}
+ <div class="stack">
+ <div class="spread">
+ <h3>建议解释 <span class="hint">{aiResult.model}</span></h3>
+ <span class="hint">
+ tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出
+ </span>
+ </div>
+ <p>{aiResult.explanation}</p>
+ <h3>完整建议源码</h3>
+ <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} />
+ <h3>与当前草稿的差异</h3>
+ <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" />
+ <div class="row">
+ <button
+ class="btn primary"
+ disabled={aiAcceptBusy}
+ onclick={acceptAI}
+ title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''}
+ >
+ {aiAcceptBusy ? '接受中…' : '接受为新版本'}
+ </button>
+ <button class="btn ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</button>
+ {#if aiResult.base_generation !== baseGeneration}
+ <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span>
+ {/if}
+ {#if aiAcceptErr}
+ <div class="banner error" role="alert">{aiAcceptErr}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
+ {#if aiNote}
+ <div class="banner info">{aiNote}</div>
+ {/if}
+ </div>
+ </div>
+
+ <div class="stack">
+ <h2>版本历史</h2>
+ <Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} />
+ {#if !versionsBusy && versions.length === 0}
+ <Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" />
+ {/if}
+ {#if !versionsBusy && versions.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>时间</th>
+ <th>摘要</th>
+ <th>哈希</th>
+ <th>来源</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each versions as v, i (v.id)}
+ <tr>
+ <td>{fmtDateTime(v.created_at)}</td>
+ <td>
+ {v.message?.trim() || '(无说明)'}
+ <span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span>
+ </td>
+ <td class="num">{v.hash?.slice(0, 12)}</td>
+ <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td>
+ <td>
+ <div class="row">
+ <button class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button>
+ {#if i + 1 < versions.length}
+ <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button>
+ {/if}
+ <button class="btn small" onclick={() => void restore(v)}>恢复为新草稿</button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ <p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p>
+ </div>
+
+ {#if comparing}
+ <Modal title={comparing.label} onClose={() => (comparing = null)}>
+ <DiffView before={comparing.before} after={comparing.after} />
+ </Modal>
+ {/if}
+ {/if}
+</div>
diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte
new file mode 100644
index 0000000..b29a856
--- /dev/null
+++ b/frontend/src/pages/ProjectsPage.svelte
@@ -0,0 +1,112 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import { projectsStore } from '../lib/listsStore.svelte';
+ import { createProject } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { go } from '../lib/router';
+ import { fmtDateTime } from '../lib/format';
+
+ let busy = $state(false);
+ let error = $state<string | null>(null);
+ let creating = $state(false);
+ let showCreate = $state(false);
+ let newName = $state('');
+ let newDesc = $state('');
+ let createErr = $state<string | null>(null);
+
+ async function load() {
+ busy = true;
+ error = null;
+ try {
+ await projectsStore.load();
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+
+ async function create(e: SubmitEvent) {
+ e.preventDefault();
+ if (!newName.trim()) return;
+ creating = true;
+ createErr = null;
+ try {
+ const p = await createProject(newName.trim(), newDesc.trim() || undefined);
+ projectsStore.bump(p);
+ showCreate = false;
+ newName = '';
+ newDesc = '';
+ go(`/projects/${p.id}/data`);
+ } catch (err) {
+ createErr = extractStatusMessage(err);
+ } finally {
+ creating = false;
+ }
+ }
+</script>
+
+<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录">
+ {#snippet actions()}
+ <button class="btn primary" onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}>
+ {showCreate ? '取消新建' : '新建项目'}
+ </button>
+ {/snippet}
+
+ <div class="stack">
+ <Status {busy} {error} busyText="加载项目列表…" empty={null} />
+ {#if showCreate}
+ <form class="card stack" onsubmit={create}>
+ <h2>新建项目</h2>
+ <div>
+ <label for="pn">项目名称</label>
+ <input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" />
+ </div>
+ <div>
+ <label for="pd">简介(可选)</label>
+ <input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" />
+ </div>
+ {#if createErr}<div class="banner error" role="alert">{createErr}</div>{/if}
+ <div class="row">
+ <button type="submit" class="btn primary" disabled={creating || !newName.trim()}>
+ {creating ? '创建中…' : '创建项目'}
+ </button>
+ </div>
+ </form>
+ {/if}
+
+ {#if !busy && !error && projectsStore.all.length === 0}
+ <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" />
+ {/if}
+
+ {#if projectsStore.all.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem;">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>名称</th>
+ <th>简介</th>
+ <th>版本代</th>
+ <th>最近更新</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each projectsStore.all as p (p.id)}
+ <tr>
+ <td><a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a></td>
+ <td class="hint">{p.description ?? '—'}</td>
+ <td class="num">{p.draft_generation ?? '—'}</td>
+ <td>{fmtDateTime(p.updated_at)}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ </div>
+</Page>
+
+<style>
+ .p-name { font-weight: 600; }
+</style>
diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte
new file mode 100644
index 0000000..bd77892
--- /dev/null
+++ b/frontend/src/pages/RunsPage.svelte
@@ -0,0 +1,180 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import Chart from '../components/Chart.svelte';
+ import type { EChartsOption } from 'echarts';
+ import { listRuns } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format';
+ import type { Run } from '../lib/types';
+
+ let runs: Run[] = $state([]);
+ let busy = $state(true);
+ let error = $state<string | null>(null);
+ let chosenIds: string[] = $state([]);
+ let failedLoad = $state<string[]>([]);
+
+ $effect(() => {
+ void load();
+ });
+
+ async function load() {
+ busy = true;
+ try {
+ const { items } = await listRuns();
+ runs = items;
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+
+ const selected = $derived(runs.filter((r) => chosenIds.includes(r.id)));
+ const comparable = $derived(
+ selected.filter((r) => r.status === 'succeeded' && r.result)
+ );
+
+ function toggle(id: string) {
+ if (chosenIds.includes(id)) chosenIds = chosenIds.filter((x) => x !== id);
+ else chosenIds = [...chosenIds.slice(-3), id];
+ }
+
+ const compareOption: EChartsOption = $derived.by(() => {
+ if (comparable.length === 0) return {};
+ const series = comparable.map((r, idx) => {
+ const eq = r.result?.equity ?? [];
+ const base = eq[0]?.equity ?? 1;
+ const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b'];
+ return {
+ name: `Run ${r.id.slice(0, 6)}.`,
+ type: 'line' as const,
+ data: eq.map((p) => p.equity / base),
+ showSymbol: false,
+ lineStyle: { width: 1.5 },
+ itemStyle: { color: colors[idx % colors.length] }
+ };
+ });
+ let xLen = Math.max(...comparable.map((r) => (r.result?.equity ?? []).length), 0);
+ const longest = comparable.reduce((best, r) => ((r.result?.equity ?? []).length > best.length ? r.result!.equity : best), [] as { date: string }[]);
+ return {
+ legend: { top: 0 },
+ grid: { top: 32, left: 64, right: 20, bottom: 50 },
+ tooltip: { trigger: 'axis' },
+ xAxis: { type: 'category', data: longest.map((p) => p.date) },
+ yAxis: { type: 'value', scale: true, name: '归一净值' },
+ series
+ };
+ });
+
+ const configDiffRows = $derived.by(() => {
+ if (selected.length < 2) return [];
+ const keys: (keyof Run)[] = ['project_id', 'version_id', 'dataset_id'];
+ const cfgKeys: (keyof NonNullable<Run['config']>)[] = ['capital', 'commission', 'slippage', 'benchmark_symbol'];
+ const rows: { label: string; vals: string[]; differing: boolean }[] = [];
+ const mk = (label: string, vals: unknown[]) => {
+ const valsStr = vals.map((v) => (v === null || v === undefined || v === '' ? '—' : v instanceof Date ? String(v) : String(v)));
+ rows.push({ label, vals: valsStr, differing: new Set(valsStr).size > 1 });
+ };
+ for (const k of keys) mk(k === 'project_id' ? '项目' : k === 'version_id' ? '代码版本' : '数据集', selected.map((r) => r[k]));
+ for (const k of cfgKeys) {
+ mk(
+ k === 'capital' ? '起始资金' : k === 'commission' ? '佣金率' : k === 'slippage' ? '滑点' : '基准',
+ selected.map((r) => r.config?.[k])
+ );
+ }
+ mk('状态', selected.map((r) => runStatusLabel[r.status]));
+ mk('时间', selected.map((r) => r.created_at));
+ return rows;
+ });
+</script>
+
+<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide>
+ <div class="stack">
+ <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} />
+
+ {#if !busy && !error && runs.length === 0}
+ <Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" />
+ {/if}
+
+ {#if !busy && !error && runs.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem">
+ <table class="data">
+ <thead>
+ <tr>
+ <th></th>
+ <th>时间</th>
+ <th>状态</th>
+ <th>项目</th>
+ <th>数据集</th>
+ <th>资金/佣金/滑点</th>
+ <th>收益</th>
+ <th>最大回撤</th>
+ <th>操作</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each runs as r (r.id)}
+ <tr>
+ <td>
+ <input
+ type="checkbox"
+ checked={chosenIds.includes(r.id)}
+ onchange={() => toggle(r.id)}
+ aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`}
+ disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)}
+ />
+ </td>
+ <td>{fmtDateTime(r.created_at)}</td>
+ <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td>
+ <td class="num">{r.project_id?.slice(0, 8)}…</td>
+ <td class="num">{r.dataset_id?.slice(0, 8)}…</td>
+ <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td>
+ <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td>
+ <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td>
+ <td>
+ <a class="btn small" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</a>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p>
+ </div>
+
+ {#if selected.length > 0}
+ <div class="card stack">
+ <h2>条件差异</h2>
+ <table class="data">
+ <thead>
+ <tr>
+ <th>维度</th>
+ {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each}
+ </tr>
+ </thead>
+ <tbody>
+ {#each configDiffRows as row, i (i)}
+ <tr class:diffrow={row.differing}>
+ <th>{row.label}</th>
+ {#each row.vals as v, j (j)}
+ <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td>
+ {/each}
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+
+ {#if comparable.length > 1}
+ <div class="card stack">
+ <h2>收益曲线对齐比较(首日归一)</h2>
+ <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} />
+ <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p>
+ </div>
+ {:else}
+ <Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" />
+ {/if}
+ {/if}
+ {/if}
+ </div>
+</Page>
diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte
new file mode 100644
index 0000000..88dd8a5
--- /dev/null
+++ b/frontend/src/pages/UsagePage.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+ import Page from '../components/Page.svelte';
+ import Status from '../components/Status.svelte';
+ import { getAIUsage } from '../lib/client';
+ import { extractStatusMessage } from '../lib/state';
+ import { fmtDateTime, fmtNum } from '../lib/format';
+ import type { AIUsage } from '../lib/types';
+
+ let usage = $state<AIUsage | null>(null);
+ let busy = $state(true);
+ let error = $state<string | null>(null);
+
+ $effect(() => {
+ void load();
+ });
+
+ async function load() {
+ busy = true;
+ error = null;
+ try {
+ usage = await getAIUsage();
+ } catch (e) {
+ error = extractStatusMessage(e);
+ } finally {
+ busy = false;
+ }
+ }
+</script>
+
+<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费">
+ <div class="stack">
+ <Status busy={busy} error={error} busyText="正在载入用量…" empty={null} />
+ {#if usage}
+ <div class="card">
+ <div class="row spaced">
+ <div>
+ <div class="mlabel">模型请求</div>
+ <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div>
+ </div>
+ <div>
+ <div class="mlabel">输入 tokens</div>
+ <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div>
+ </div>
+ <div>
+ <div class="mlabel">输出 tokens</div>
+ <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div>
+ </div>
+ </div>
+ {#if usage.internal_poc}
+ <p class="hint">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p>
+ {/if}
+ </div>
+
+ {#if usage.items.length > 0}
+ <div class="card" style="padding:0.4rem 0.6rem">
+ <table class="data">
+ <thead>
+ <tr>
+ <th>时间</th>
+ <th>模型</th>
+ <th>输入</th>
+ <th>输出</th>
+ <th>状态</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each usage.items as it, i (it.id)}
+ <tr>
+ <td>{fmtDateTime(it.created_at)}</td>
+ <td>{it.model}</td>
+ <td class="num">{it.input_tokens ?? '—'}</td>
+ <td class="num">{it.output_tokens ?? '—'}</td>
+ <td class="badge">{it.status ?? '—'}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {:else}
+ <Status busy={false} empty="尚未使用 AI 建议" emptyHint="在项目「策略」标签发起一次 AI 修改,记录会出现在这里" />
+ {/if}
+ {/if}
+ </div>
+</Page>
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts
new file mode 100644
index 0000000..cdd6071
--- /dev/null
+++ b/frontend/src/test-setup.ts
@@ -0,0 +1,36 @@
+
+// jsdom does not implement Range.getClientRects, which CodeMirror's
+// measurement layer calls for drawing decorations (diagnostics markers).
+// Without it the suite emits an unhandled
+// `textRange(...).getClientRects is not a function` TypeError even though
+// assertions pass. Narrow geometry shim ONLY for the missing API; it returns
+// one empty rect, from which CodeMirror derives null geometry — no other
+// errors are suppressed.
+if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) {
+ const EMPTY_RECT = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() { return {}; } };
+ Range.prototype.getClientRects = function getClientRects() {
+ return [EMPTY_RECT] as unknown as DOMRectList;
+ };
+}
+
+// jsdom ships no canvas backend: HTMLCanvasElement.prototype.getContext exists
+// but throws "Not implemented". Charts render with ECharts' SVG renderer; the
+// only canvas user in tests is zrender's text-metrics helper (measureText
+// paints into a hidden canvas). Replace getContext ONLY when the native
+// implementation is the throwing stub, and provide just the measurement
+// approximation — SVG chart rendering itself (the part regressions inspect)
+// is untouched and fully real.
+if (typeof HTMLCanvasElement !== 'undefined') {
+ HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement, contextId: string) {
+ if (contextId === '2d') {
+ return {
+ canvas: this,
+ font: '',
+ measureText(text: string) {
+ return { width: String(text).length * 7, actualBoundingBoxAscent: 7, actualBoundingBoxDescent: 1 };
+ }
+ } as unknown as CanvasRenderingContext2D;
+ }
+ return null;
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext;
+}