341 lines
12 KiB
Vue
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>
|