275 lines
11 KiB
Vue
275 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
listPapers, stopPaper, resumePaper, deletePaper, updatePaper,
|
||
type PaperAccount,
|
||
} from '@/api/paper'
|
||
|
||
const router = useRouter()
|
||
const accounts = ref<PaperAccount[]>([])
|
||
const loading = ref(false)
|
||
const kw = ref('')
|
||
const modeFilter = ref('')
|
||
const statusFilter = ref('')
|
||
const actionLoading = ref(0)
|
||
|
||
onMounted(refresh)
|
||
|
||
async function refresh(): Promise<void> {
|
||
loading.value = true
|
||
try {
|
||
accounts.value = await listPapers()
|
||
} catch (e: unknown) {
|
||
ElMessage.error(e instanceof Error ? e.message : '加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const filtered = computed(() =>
|
||
accounts.value.filter((a) => {
|
||
if (kw.value && !String(a.name + a.id + (a.symbols ?? '')).toLowerCase().includes(kw.value.toLowerCase())) return false
|
||
if (modeFilter.value && a.mode !== modeFilter.value) return false
|
||
if (statusFilter.value && a.status !== statusFilter.value) return false
|
||
return true
|
||
}),
|
||
)
|
||
|
||
const stats = computed(() => ({
|
||
total: accounts.value.length,
|
||
running: accounts.value.filter((a) => a.status === 'running').length,
|
||
done: accounts.value.filter((a) => a.status === 'done').length,
|
||
failed: accounts.value.filter((a) => a.status === 'failed').length,
|
||
}))
|
||
|
||
const modeLabel: Record<string, string> = { replay: '回放', live: '实走' }
|
||
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' }
|
||
|
||
function pct(v: number | null | undefined): string {
|
||
if (v == null || !Number.isFinite(v)) return '—'
|
||
return (v * 100).toFixed(2) + '%'
|
||
}
|
||
function num(v: number | null | undefined): string {
|
||
if (v == null || !Number.isFinite(v)) return '—'
|
||
return Math.round(v).toLocaleString('zh-CN')
|
||
}
|
||
function symbolsShort(s: string | undefined): string {
|
||
if (!s) return '—'
|
||
const arr = typeof s === 'string' ? s.split(',') : []
|
||
return arr.length > 3 ? arr.slice(0, 3).join(',') + ` +${arr.length - 3}` : s
|
||
}
|
||
function open(a: PaperAccount): void {
|
||
router.push(a.mode === 'live' ? `/paper/live/${a.id}` : `/paper/result/${a.id}`)
|
||
}
|
||
function goNew(): void {
|
||
router.push('/paper/new')
|
||
}
|
||
|
||
async function onStop(a: PaperAccount): Promise<void> {
|
||
actionLoading.value = a.id
|
||
try {
|
||
await stopPaper(a.id)
|
||
ElMessage.success(`#${a.id} 已停止,今晚起不再结算`)
|
||
await refresh()
|
||
} catch (e: unknown) {
|
||
ElMessage.error(e instanceof Error ? e.message : '停止失败')
|
||
} finally {
|
||
actionLoading.value = 0
|
||
}
|
||
}
|
||
|
||
async function onResume(a: PaperAccount): Promise<void> {
|
||
actionLoading.value = a.id
|
||
try {
|
||
await resumePaper(a.id)
|
||
ElMessage.success(`#${a.id} 已恢复,今晚 20:30 起继续结算`)
|
||
await refresh()
|
||
} catch (e: unknown) {
|
||
ElMessage.error(e instanceof Error ? e.message : '恢复失败')
|
||
} finally {
|
||
actionLoading.value = 0
|
||
}
|
||
}
|
||
|
||
async function onDelete(a: PaperAccount): Promise<void> {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`删除模拟盘 #${a.id}(${a.name})?净值/成交/持仓数据将一并删除,不可恢复。`,
|
||
'删除确认',
|
||
{ type: 'warning' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
actionLoading.value = a.id
|
||
try {
|
||
await deletePaper(a.id)
|
||
ElMessage.success(`#${a.id} 已删除`)
|
||
await refresh()
|
||
} catch (e: unknown) {
|
||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||
} finally {
|
||
actionLoading.value = 0
|
||
}
|
||
}
|
||
|
||
// 编辑(MVP:名称/标的/初始资金)
|
||
const editVisible = ref(false)
|
||
const editForm = reactive({ id: 0, name: '', symbols: '', initial_capital: 0 })
|
||
function openEdit(a: PaperAccount): void {
|
||
editForm.id = a.id
|
||
editForm.name = a.name
|
||
const syms: string = typeof a.symbols === 'string' ? a.symbols : JSON.stringify(a.symbols ?? [])
|
||
try {
|
||
editForm.symbols = (JSON.parse(syms) as string[]).join(',')
|
||
} catch {
|
||
editForm.symbols = syms
|
||
}
|
||
editForm.initial_capital = a.initial_capital ?? 0
|
||
editVisible.value = true
|
||
}
|
||
async function saveEdit(): Promise<void> {
|
||
try {
|
||
await updatePaper(editForm.id, {
|
||
name: editForm.name,
|
||
symbols: editForm.symbols.split(',').map((s) => s.trim()).filter(Boolean),
|
||
initial_capital: Number(editForm.initial_capital),
|
||
})
|
||
ElMessage.success(`#${editForm.id} 已更新(策略参数自下次结算生效)`)
|
||
editVisible.value = false
|
||
await refresh()
|
||
} catch (e: unknown) {
|
||
ElMessage.error(e instanceof Error ? e.message : '更新失败')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page paper-list" v-loading="loading">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2 class="page-title">模拟交易 <span class="count-badge">{{ stats.total }}</span></h2>
|
||
<p class="page-subtitle">回放 / 实走 · 策略组合模拟盘</p>
|
||
</div>
|
||
<el-button type="primary" @click="goNew">+ 新建模拟盘</el-button>
|
||
</div>
|
||
|
||
<!-- 统计 -->
|
||
<div class="stat-row">
|
||
<div class="stat-card"><div class="stat-label">总数</div><div class="stat-value mono">{{ stats.total }}</div></div>
|
||
<div class="stat-card"><div class="stat-label">运行中</div><div class="stat-value mono" style="color:var(--brand)">{{ stats.running }}</div></div>
|
||
<div class="stat-card"><div class="stat-label">完成</div><div class="stat-value mono up">{{ stats.done }}</div></div>
|
||
<div class="stat-card"><div class="stat-label">失败</div><div class="stat-value mono down">{{ stats.failed }}</div></div>
|
||
</div>
|
||
|
||
<!-- 筛选 -->
|
||
<div class="filter-row">
|
||
<el-input v-model="kw" placeholder="搜索 名称 / ID / 标的" clearable style="width: 240px" />
|
||
<el-select v-model="modeFilter" placeholder="全部模式" clearable style="width: 130px">
|
||
<el-option label="回放" value="replay" />
|
||
<el-option label="实走" value="live" />
|
||
</el-select>
|
||
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 130px">
|
||
<el-option label="运行中" value="running" />
|
||
<el-option label="完成" value="done" />
|
||
<el-option label="失败" value="failed" />
|
||
</el-select>
|
||
<el-button link type="primary" @click="refresh">刷新</el-button>
|
||
</div>
|
||
|
||
<el-card class="table-card" shadow="never">
|
||
<el-table :data="filtered" size="small" empty-text="暂无模拟盘">
|
||
<el-table-column label="名称 / ID" min-width="160">
|
||
<template #default="{ row }">
|
||
<div class="cell-name">{{ row.name }}</div>
|
||
<div class="cell-id mono">#{{ row.id }}</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="模式" width="80">
|
||
<template #default="{ row }">
|
||
<span class="chip chip-paper">{{ modeLabel[row.mode] || row.mode }}</span>
|
||
<span v-if="row.strategy_type === 'portfolio'" class="chip chip-shadow" title="影子柜台=盘中实时本地撮合 / 日终回放=每晚全量重放">
|
||
{{ row.engine === 'shadow' ? '影子' : '日终' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="频率" width="70">
|
||
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column prop="instance" label="实例" min-width="160">
|
||
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="标的" min-width="120">
|
||
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="收益率" width="100" align="right">
|
||
<template #default="{ row }">
|
||
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最新净值" width="120" align="right">
|
||
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="起止" min-width="180">
|
||
<template #default="{ row }"><span class="mono muted">{{ row.start_date }} ~ {{ row.end_date }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="84">
|
||
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="200" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="open(row)">查看</el-button>
|
||
<el-button
|
||
v-if="row.mode === 'live' && row.status === 'running'"
|
||
link type="warning" :loading="actionLoading === row.id"
|
||
@click="onStop(row)"
|
||
>停止</el-button>
|
||
<el-button
|
||
v-if="row.mode === 'live' && row.status === 'stopped'"
|
||
link type="success" :loading="actionLoading === row.id"
|
||
@click="onResume(row)"
|
||
>恢复</el-button>
|
||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||
<el-button link type="danger" :loading="actionLoading === row.id" @click="onDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<el-dialog v-model="editVisible" title="编辑模拟盘" width="480px">
|
||
<el-form :model="editForm" label-width="90px">
|
||
<el-form-item label="名称">
|
||
<el-input v-model="editForm.name" />
|
||
</el-form-item>
|
||
<el-form-item label="标的">
|
||
<el-input v-model="editForm.symbols" placeholder="逗号分隔" />
|
||
</el-form-item>
|
||
<el-form-item label="初始资金">
|
||
<el-input-number v-model="editForm.initial_capital" :min="10000" :step="100000" :controls="false" style="width: 200px" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveEdit">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.paper-list { display: flex; flex-direction: column; gap: 16px; }
|
||
|
||
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||
.stat-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 14px 16px; }
|
||
.stat-label { font-size: 12px; color: var(--text-3); }
|
||
.stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; color: var(--text); }
|
||
|
||
.filter-row { display: flex; align-items: center; gap: 10px; }
|
||
.table-card { border: 1px solid var(--border-2); }
|
||
|
||
.cell-name { font-size: 13px; color: var(--text); font-weight: 500; }
|
||
.cell-id { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||
</style>
|