Files
sanguo_vnpy_v2/frontend/src/views/paper/List.vue
T

275 lines
11 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, 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>