402 lines
14 KiB
Vue
402 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import MonacoEditor from '@/components/MonacoEditor.vue'
|
||
import MonacoDiff from '@/components/MonacoDiff.vue'
|
||
import { apiClient } from '@/api/client'
|
||
import { getCodeVersions, getCodeVersion, type CodeVersion } from '@/api/strategy'
|
||
|
||
interface StrategyFile {
|
||
name: string
|
||
dir: string
|
||
class_name: string
|
||
type: string
|
||
lines: number
|
||
modified: string
|
||
}
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const files = ref<StrategyFile[]>([])
|
||
const activeName = ref('')
|
||
const code = ref('')
|
||
const dirty = ref(false)
|
||
const saving = ref(false)
|
||
const checking = ref(false)
|
||
const syntaxOk = ref<boolean | null>(null)
|
||
const syntaxError = ref<string | null>(null)
|
||
|
||
const active = computed(() => files.value.find((f) => f.name === activeName.value))
|
||
|
||
// 文件树按类型分组:组合策略在前(常用),个股策略在后;组头可点击折叠/展开
|
||
const fileGroups = computed(() => [
|
||
{ label: '组合策略', items: files.value.filter((f) => f.type === 'portfolio') },
|
||
{ label: '个股策略', items: files.value.filter((f) => f.type !== 'portfolio') },
|
||
].filter((g) => g.items.length))
|
||
const collapsed = ref<Record<string, boolean>>({})
|
||
function toggleGroup(label: string): void {
|
||
collapsed.value = { ...collapsed.value, [label]: !collapsed.value[label] }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const { data } = await apiClient.get<{ files: StrategyFile[] }>('/strategy/files')
|
||
files.value = data.files
|
||
if (files.value.length) {
|
||
const initial = route.query.file
|
||
? files.value.find((f) => f.name === route.query.file)
|
||
: null
|
||
select(initial ?? files.value[0])
|
||
}
|
||
} catch {
|
||
ElMessage.error('策略文件加载失败')
|
||
}
|
||
})
|
||
|
||
const loadingCode = ref(false)
|
||
// 载入宽限:载入后 Monaco setValue→onDidChangeModelContent→回写 modelValue 的链路
|
||
// 会再次改变 code(可能规整换行),不能当用户编辑;300ms 后才视为真人输入
|
||
let echoGrace = false
|
||
|
||
async function select(f: StrategyFile): Promise<void> {
|
||
activeName.value = f.name
|
||
dirty.value = false
|
||
syntaxOk.value = null
|
||
syntaxError.value = null
|
||
loadingCode.value = true
|
||
try {
|
||
const { data } = await apiClient.get<{ code: string }>(`/strategy/file/${f.name}`)
|
||
echoGrace = true
|
||
code.value = data.code
|
||
window.setTimeout(() => { echoGrace = false }, 300)
|
||
} catch {
|
||
code.value = ''
|
||
ElMessage.error(`读取 ${f.name} 失败`)
|
||
} finally {
|
||
loadingCode.value = false
|
||
}
|
||
}
|
||
|
||
watch(code, () => {
|
||
if (echoGrace) return
|
||
dirty.value = true
|
||
syntaxOk.value = null
|
||
syntaxError.value = null
|
||
})
|
||
|
||
async function onCheck(): Promise<void> {
|
||
if (!active.value) return
|
||
checking.value = true
|
||
syntaxError.value = null
|
||
try {
|
||
const { data } = await apiClient.post<{ ok: boolean; error?: string; line?: number | null }>(
|
||
`/strategy/file/${activeName.value}/check`, { code: code.value })
|
||
if (data.ok) {
|
||
syntaxOk.value = true
|
||
ElMessage.success('语法检查通过')
|
||
} else {
|
||
syntaxOk.value = false
|
||
syntaxError.value = data.line != null ? `第 ${data.line} 行:${data.error ?? '语法错误'}` : (data.error ?? '语法错误')
|
||
ElMessage.error(syntaxError.value)
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '语法检查请求失败')
|
||
} finally {
|
||
checking.value = false
|
||
}
|
||
}
|
||
|
||
async function onSave(): Promise<void> {
|
||
if (!active.value) return
|
||
saving.value = true
|
||
try {
|
||
await apiClient.post(`/strategy/file/${activeName.value}`, { code: code.value })
|
||
dirty.value = false
|
||
ElMessage.success('已保存 · 下次回测自动加载最新代码')
|
||
} catch {
|
||
ElMessage.error('保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 跳到对应的回测参数页:初始值按当前策略预填,区间等参数由人调整
|
||
function runBacktest(): void {
|
||
if (!active.value) return
|
||
if (dirty.value) {
|
||
ElMessage.warning('有未保存修改,请先保存再跳转回测')
|
||
return
|
||
}
|
||
if (active.value.type === 'portfolio') {
|
||
router.push({ path: '/backtest/portfolio', query: { strategy: active.value.name.replace(/\.py$/, '') } })
|
||
} else {
|
||
router.push({ path: '/backtest/new', query: { class: active.value.class_name } })
|
||
}
|
||
}
|
||
|
||
// ===== §12.6 补:版本历史(发起时快照)=====
|
||
const versions = ref<CodeVersion[]>([])
|
||
const versionsOpen = ref(false)
|
||
const diffCode = ref<string | null>(null)
|
||
const diffReady = ref(false)
|
||
const versionsLoading = ref(false)
|
||
|
||
async function openVersions(): Promise<void> {
|
||
if (!active.value) return
|
||
versionsOpen.value = true
|
||
versionsLoading.value = true
|
||
diffCode.value = null
|
||
diffReady.value = false
|
||
try {
|
||
versions.value = await getCodeVersions(active.value.name)
|
||
} catch {
|
||
ElMessage.error('版本列表加载失败')
|
||
} finally {
|
||
versionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function showDiff(h8: string): Promise<void> {
|
||
if (!active.value) return
|
||
diffReady.value = false
|
||
try {
|
||
diffCode.value = await getCodeVersion(active.value.name, h8)
|
||
// 抽屉展开动画(~300ms)期间容器宽为 0,Monaco diff 在其中创建会把左右分栏算死
|
||
// (左栏被压扁且事后 layout() 救不回)。等动画结束容器全宽后再挂载组件。
|
||
setTimeout(() => { diffReady.value = true }, 360)
|
||
} catch {
|
||
ElMessage.error('读取版本失败')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page code-page">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2 class="page-title">策略代码编辑</h2>
|
||
<p class="page-subtitle">自研策略在线编辑 · Monaco · 保存后下次回测热生效(ProcessPool spawn 自动加载)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ide term-corners">
|
||
<aside class="tree">
|
||
<div class="tree-head">
|
||
<span class="section-mark"></span>策略文件
|
||
</div>
|
||
<template v-for="g in fileGroups" :key="g.label">
|
||
<div class="tree-group" @click="toggleGroup(g.label)">
|
||
<span class="tree-arrow" :class="{ folded: collapsed[g.label] }">▾</span>
|
||
{{ g.label }}
|
||
<span class="tree-count mono">{{ g.items.length }}</span>
|
||
</div>
|
||
<div
|
||
v-for="f in g.items"
|
||
v-show="!collapsed[g.label]"
|
||
:key="f.name"
|
||
class="tree-item"
|
||
:class="{ active: f.name === activeName, dirty: f.name === activeName && dirty }"
|
||
@click="select(f)"
|
||
>
|
||
<span class="file-icon">◐</span>
|
||
<div class="file-body">
|
||
<span class="file-name">{{ f.name }}</span>
|
||
<span class="file-cls mono">{{ f.class_name }}</span>
|
||
</div>
|
||
<span class="file-lines mono">{{ f.lines }}</span>
|
||
</div>
|
||
</template>
|
||
</aside>
|
||
|
||
<section class="editor-pane">
|
||
<div class="editor-bar">
|
||
<div class="bar-left">
|
||
<span class="lamp" :class="dirty ? 'lamp-warn lamp-pulse' : 'lamp-ok'"></span>
|
||
<span class="path mono">{{ active?.dir }}{{ activeName }}</span>
|
||
<span v-if="dirty" class="tag tag-dirty">未保存</span>
|
||
<span v-if="syntaxOk" class="tag tag-ok mono">✓ 语法正确</span>
|
||
<span v-else-if="syntaxError" class="tag tag-err mono" :title="syntaxError">✗ {{ syntaxError }}</span>
|
||
</div>
|
||
<div class="bar-right">
|
||
<span class="modified mono">{{ active?.modified }}</span>
|
||
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
|
||
<button class="term-btn sm" :disabled="loadingCode" @click="openVersions">版本历史</button>
|
||
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
|
||
<button class="term-btn sm" :disabled="loadingCode" @click="runBacktest">运行回测</button>
|
||
</div>
|
||
</div>
|
||
<div class="monaco-wrap">
|
||
<MonacoEditor v-model="code" language="python" />
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- §12.6 版本历史:发起时快照列表 + 与当前代码 diff -->
|
||
<el-drawer :model-value="versionsOpen" size="860px" :with-header="false" append-to-body destroy-on-close @close="versionsOpen = false">
|
||
<div v-loading="versionsLoading" class="ver-drawer">
|
||
<div class="ver-head">
|
||
<div>
|
||
<div style="font-size:15px;font-weight:700;color:var(--text)">版本历史 · {{ activeName }}</div>
|
||
<div class="muted" style="font-size:11.5px;margin-top:2px">发起回测/模拟/实盘时的代码快照(内容寻址去重);点「对比」查看与当前代码差异</div>
|
||
</div>
|
||
<button class="term-btn sm" @click="versionsOpen = false">关闭</button>
|
||
</div>
|
||
<div v-if="!versions.length && !versionsLoading" class="muted" style="padding:20px 0;text-align:center;font-size:12.5px">
|
||
还没有快照——从策略档案发起过回测/模拟/实盘后自动生成
|
||
</div>
|
||
<div v-for="v in versions" :key="v.code_version" class="ver-row">
|
||
<span class="mono" style="color:var(--brand)">v{{ v.code_version }}</span>
|
||
<span class="muted mono" style="font-size:11px">{{ v.saved_at }}</span>
|
||
<span class="muted mono" style="font-size:11px">{{ (v.size / 1024).toFixed(1) }} KB</span>
|
||
<button class="term-btn sm" style="margin-left:auto" @click="showDiff(v.code_version)">对比当前</button>
|
||
</div>
|
||
<div v-if="diffCode != null && diffReady" class="ver-diff">
|
||
<div class="muted mono" style="font-size:11px;margin-bottom:6px">左右滚动联动 · 快照 vs 当前</div>
|
||
<MonacoDiff :original="diffCode" :modified="code" />
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.code-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
height: calc(100vh - 80px);
|
||
}
|
||
|
||
.ide {
|
||
flex: 1;
|
||
display: flex;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r-md);
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 文件树 */
|
||
.tree {
|
||
width: 240px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
background: var(--panel);
|
||
}
|
||
.tree-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 14px;
|
||
font-family: var(--mono);
|
||
font-size: 10.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 1.5px;
|
||
color: var(--text-3);
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid var(--border-2);
|
||
}
|
||
.section-mark {
|
||
display: inline-block;
|
||
width: 3px;
|
||
height: 11px;
|
||
background: var(--brand);
|
||
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
|
||
}
|
||
.tree-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-2);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.tree-arrow {
|
||
display: inline-block;
|
||
font-size: 9px;
|
||
color: var(--text-3);
|
||
transition: transform 0.15s var(--ease);
|
||
}
|
||
.tree-arrow.folded { transform: rotate(-90deg); }
|
||
.tree-count {
|
||
font-size: 10px;
|
||
color: var(--text-3);
|
||
background: var(--bg-hover);
|
||
border-radius: 8px;
|
||
padding: 0 6px;
|
||
}
|
||
.tree-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
padding: 8px 14px;
|
||
cursor: pointer;
|
||
border-left: 2px solid transparent;
|
||
transition: background 0.12s var(--ease);
|
||
}
|
||
.tree-item:hover { background: var(--bg-hover); }
|
||
.tree-item.active {
|
||
background: var(--cyan-soft);
|
||
border-left-color: var(--brand);
|
||
}
|
||
.file-icon { color: var(--brand); font-size: 11px; }
|
||
.file-body { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
|
||
.file-name { font-size: 12.5px; color: var(--text); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.tree-item.active .file-name { color: var(--brand); }
|
||
.file-cls { font-size: 10px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.file-lines { font-size: 10px; color: var(--text-3); flex-shrink: 0; }
|
||
.tree-item.dirty .file-name::after { content: ' ●'; color: var(--lamp-warn); }
|
||
|
||
/* 编辑器面板 */
|
||
.editor-pane {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
.editor-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 7px 14px;
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 12px;
|
||
}
|
||
.bar-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||
.path { font-size: 11.5px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.bar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||
.modified { font-size: 10.5px; color: var(--text-3); }
|
||
.tag {
|
||
font-size: 10px;
|
||
padding: 1px 7px;
|
||
border-radius: var(--r-sm);
|
||
font-family: var(--mono);
|
||
}
|
||
.tag-dirty { color: var(--lamp-warn); background: var(--amber-soft); border: 1px solid rgba(255, 176, 0, 0.3); }
|
||
.tag-err { color: var(--lamp-err, #ff5470); background: rgba(255, 84, 112, 0.1); border: 1px solid rgba(255, 84, 112, 0.3); max-width: 46ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tag-ok { color: var(--lamp-ok); background: rgba(46, 230, 138, 0.1); border: 1px solid rgba(46, 230, 138, 0.3); }
|
||
|
||
.monaco-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background: #05080d;
|
||
}
|
||
|
||
/* 版本历史抽屉 */
|
||
.ver-drawer { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px; }
|
||
.ver-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
|
||
.ver-row {
|
||
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
|
||
border: 1px solid var(--border-2); border-radius: var(--r-sm);
|
||
}
|
||
.ver-row:hover { background: var(--bg-hover); }
|
||
.ver-diff { height: 460px; border: 1px solid var(--border-2); border-radius: var(--r-sm); overflow: hidden; }
|
||
</style>
|