diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/pages/ProjectStrategyTab.svelte | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/pages/ProjectStrategyTab.svelte')
| -rw-r--r-- | frontend/src/pages/ProjectStrategyTab.svelte | 420 |
1 files changed, 420 insertions, 0 deletions
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> |
