summaryrefslogtreecommitdiff
path: root/frontend/src/pages
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/pages
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages')
-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
12 files changed, 2342 insertions, 0 deletions
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>