Files
sanguo_vnpy_v2/frontend/src/views/backtest/History.vue
T

341 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getTasks, getTaskParams, deleteTask, type TaskListItem } from '@/api/backtest'
const router = useRouter()
const tasks = ref<TaskListItem[]>([])
const loading = ref(false)
const deleting = ref(false)
const selection = ref<TaskListItem[]>([])
// 筛选
const typeFilter = ref<'all' | 'cta' | 'portfolio' | 'optimize' | 'factor'>('all')
const statusFilter = ref<'all' | 'done' | 'failed' | 'all-active'>('all')
const keyword = ref('')
// 分页
const page = ref(1)
const pageSize = 20
// 耗时列的活秒表(进行中任务实时计时)
const nowTick = ref(Date.now())
let tickTimer: ReturnType<typeof setInterval> | null = null
const typeOptions = [
{ key: 'all', label: '全部' },
{ key: 'cta', label: '个股回测' },
{ key: 'portfolio', label: '组合回测' },
{ key: 'optimize', label: '参数优化' },
{ key: 'factor', label: '因子分析' },
] as const
const statusOptions = [
{ key: 'all', label: '全部状态' },
{ key: 'done', label: '完成' },
{ key: 'failed', label: '失败' },
{ key: 'all-active', label: '进行中' },
] as const
const filtered = computed(() => {
let list = tasks.value
if (typeFilter.value !== 'all') list = list.filter((t) => t.type === typeFilter.value)
if (statusFilter.value === 'all-active') list = list.filter((t) => isActive(t))
else if (statusFilter.value !== 'all') list = list.filter((t) => t.status === statusFilter.value)
const kw = keyword.value.trim().toLowerCase()
if (kw) {
list = list.filter(
(t) =>
t.task_id.toLowerCase().includes(kw) ||
(t.strategy || '').toLowerCase().includes(kw) ||
(t.symbol || '').toLowerCase().includes(kw),
)
}
return list
})
const paged = computed(() => {
const start = (page.value - 1) * pageSize
return filtered.value.slice(start, start + pageSize)
})
const counts = computed(() => ({
total: tasks.value.length,
done: tasks.value.filter((t) => t.status === 'done').length,
failed: tasks.value.filter((t) => t.status === 'failed').length,
}))
function isActive(t: TaskListItem): boolean {
return t.status === 'running' || t.status === 'pending'
}
// 可跳参数页回放的类型(其余类型无对应表单)
function canReplay(t: TaskListItem): boolean {
return !isActive(t) && (t.type === 'cta' || t.type === 'portfolio')
}
// 终态(可删/可看结果)
function isTerminal(t: TaskListItem): boolean {
return t.status === 'done' || t.status === 'failed'
}
// 筛选变化回到第一页
watch([typeFilter, statusFilter, keyword], () => {
page.value = 1
})
onMounted(async () => {
await load()
})
onUnmounted(() => {
stopAutoRefresh()
stopTick()
})
async function load(): Promise<void> {
loading.value = true
try {
tasks.value = await getTasks()
} catch {
ElMessage.error('任务列表加载失败')
} finally {
loading.value = false
startAutoRefresh()
}
}
// 自动刷新:有 running/pending 任务时每 3s 静默拉取,全部终态后停(GitHub Actions 模式)
let refreshTimer: ReturnType<typeof setInterval> | null = null
function hasActive(): boolean {
return tasks.value.some((t) => isActive(t))
}
async function silentRefresh(): Promise<void> {
try {
tasks.value = await getTasks()
} catch {
/* ignore */
}
if (!hasActive()) {
stopAutoRefresh()
stopTick()
}
}
function startAutoRefresh(): void {
stopAutoRefresh()
if (hasActive()) {
refreshTimer = setInterval(silentRefresh, 3000)
// 耗时列活计时(1s 一跳,仅视觉)
nowTick.value = Date.now()
tickTimer = setInterval(() => {
nowTick.value = Date.now()
}, 1000)
}
}
function stopAutoRefresh(): void {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
function stopTick(): void {
if (tickTimer) {
clearInterval(tickTimer)
tickTimer = null
}
}
function open(row: TaskListItem): void {
if (!isTerminal(row)) {
ElMessage.info('任务进行中,完成后可查看结果')
return
}
if (row.type === 'factor') router.push(`/factor/result/${row.task_id}`)
else if (row.type === 'optimize') router.push(`/backtest/optimize-result/${row.task_id}`)
else if (row.type === 'portfolio') router.push(`/backtest/portfolio?task_id=${row.task_id}`)
else router.push(`/backtest/result/${row.task_id}`)
}
// 点任务 ID → 跳对应参数页并预填该任务的参数
async function replayParams(row: TaskListItem): Promise<void> {
if (!canReplay(row)) return
try {
await getTaskParams(row.task_id)
} catch {
ElMessage.error('读取任务参数失败')
return
}
if (row.type === 'portfolio') router.push(`/backtest/portfolio?from_task=${row.task_id}`)
else router.push(`/backtest/new?from_task=${row.task_id}`)
}
// ===== 删除(单个/批量) =====
function onSelectionChange(rows: TaskListItem[]): void {
selection.value = rows
}
async function doDelete(rows: TaskListItem[]): Promise<void> {
if (!rows.length) return
const label = rows.length === 1 ? rows[0].task_id : `${rows.length} 个任务`
try {
await ElMessageBox.confirm(`删除 ${label}?结果数据与曲线文件将一并清除,不可恢复。`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
deleting.value = true
const ids = rows.map((r) => r.task_id)
const results = await Promise.allSettled(ids.map((id) => deleteTask(id)))
const failed = results.filter((r) => r.status === 'rejected').length
if (failed) ElMessage.warning(`${ids.length - failed} 个已删除,${failed} 个删除失败`)
else ElMessage.success(`已删除 ${ids.length} 个任务`)
deleting.value = false
selection.value = []
await load()
}
// ===== 展示 =====
const typeLabelMap: Record<string, string> = { cta: '个股回测', portfolio: '组合回测', optimize: '参数优化', factor: '因子分析' }
function typeLabel(t: string): string {
return typeLabelMap[t] || t
}
const statusLabelMap: Record<string, string> = { done: '完成', failed: '失败', running: '运行中', pending: '排队' }
function statusLabel(s: string): string {
return statusLabelMap[s] || s
}
function fmtDuration(s: number): string {
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m${s % 60 ? ` ${Math.round(s % 60)}s` : ''}`
if (s < 86400) return `${Math.floor(s / 3600)}h${Math.round((s % 3600) / 60) ? ` ${Math.round((s % 3600) / 60)}m` : ''}`
return `${Math.floor(s / 86400)}d${Math.round((s % 86400) / 3600) ? ` ${Math.round((s % 86400) / 3600)}h` : ''}`
}
function durationText(row: TaskListItem): string {
// 终态:后端算好的耗时;进行中:从创建时间活计时
if (row.duration_s != null) return fmtDuration(row.duration_s)
if (isActive(row) && row.created_at) {
const start = new Date(row.created_at.replace(' ', 'T')).getTime()
if (!Number.isNaN(start) && start <= nowTick.value) return fmtDuration(Math.max(0, Math.floor((nowTick.value - start) / 1000)))
}
return '—'
}
</script>
<template>
<div class="page">
<div class="page-head">
<div>
<h2 class="page-title">
任务列表<span class="count-badge">{{ counts.total }}</span>
</h2>
<p class="page-subtitle">回测 / 参数优化 / 因子分析 的全部任务 · 完成 {{ counts.done }} · 失败 {{ counts.failed }}</p>
</div>
<div class="head-actions">
<el-button type="danger" plain :disabled="!selection.length" :loading="deleting" @click="doDelete(selection)">
删除选中({{ selection.length }})
</el-button>
<el-button type="primary" @click="router.push('/backtest/portfolio')">发起新回测</el-button>
<el-button :loading="loading" :icon="'Refresh'" @click="load">刷新</el-button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-radio-group v-model="typeFilter" size="small">
<el-radio-button v-for="o in typeOptions" :key="o.key" :value="o.key">{{ o.label }}</el-radio-button>
</el-radio-group>
<el-select v-model="statusFilter" size="small" class="status-sel">
<el-option v-for="o in statusOptions" :key="o.key" :label="o.label" :value="o.key" />
</el-select>
<el-input v-model="keyword" placeholder="搜任务ID / 策略 / 标的" clearable size="small" class="search" />
</div>
<el-card v-loading="loading" class="table-card" shadow="never">
<el-table :data="paged" size="small" empty-text="暂无符合条件的任务" @selection-change="onSelectionChange">
<el-table-column type="selection" width="42" :selectable="isTerminal" />
<el-table-column label="任务 ID" min-width="170">
<template #default="{ row }">
<span v-if="canReplay(row)" class="mono task-link" title="点击跳到参数页(预填该任务参数)" @click="replayParams(row)">{{ row.task_id }}</span>
<span v-else class="mono">{{ row.task_id }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="96">
<template #default="{ row }"><span class="chip" :class="`chip-${row.type}`">{{ typeLabel(row.type) }}</span></template>
</el-table-column>
<el-table-column prop="strategy" label="策略 / 因子" min-width="130" show-overflow-tooltip />
<el-table-column prop="symbol" label="标的" width="130" />
<el-table-column label="状态" width="88">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel(row.status) }}</span></template>
</el-table-column>
<el-table-column label="回测区间" min-width="150">
<template #default="{ row }"><span class="mono muted">{{ row.start }} ~ {{ row.end }}</span></template>
</el-table-column>
<el-table-column label="创建时间" width="140">
<template #default="{ row }"><span class="mono muted">{{ row.created_at ? row.created_at.slice(0, 16) : '—' }}</span></template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }"><span class="mono muted">{{ durationText(row) }}</span></template>
</el-table-column>
<el-table-column label="操作" width="104" fixed="right">
<template #default="{ row }">
<el-tooltip :disabled="isTerminal(row)" content="任务进行中,完成后可查看" placement="top">
<span class="op-wrap">
<el-button link type="primary" :disabled="!isTerminal(row)" @click="open(row)">查看</el-button>
</span>
</el-tooltip>
<el-button v-if="isTerminal(row)" link type="danger" @click="doDelete([row])">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="filtered.length"
layout="prev, pager, next, total"
background
small
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.head-actions {
margin-left: auto;
display: flex;
gap: 0;
align-items: center;
}
.filter-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.status-sel {
width: 130px;
}
.search {
width: 260px;
margin-left: auto;
}
.table-card {
border: 1px solid var(--border-2);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.task-link {
color: var(--accent, #4b7bce);
cursor: pointer;
}
.task-link:hover {
text-decoration: underline;
}
/* disabled 链接按钮在 tooltip 的 span 内,占位保持行内 */
.op-wrap {
margin-right: 8px;
}
</style>