Files
sanguo_vnpy_v2/frontend/src/views/strategy/Code.vue
T

402 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>