feat(api+web): 任务列表管理(验收四轮批1):历史任务→任务列表改名;DELETE /task/{id}单/批删除(进行中拒删);GET /task/{id}/params参数回放+点任务ID跳参数页预填;真创建时间+耗时列(提交即记,进行中活计时);进行中禁查看;组合任务params全量存 [nas]
CI/CD / test (push) Failing after 10m39s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-08-14 08:29:20 +08:00
parent 94de7191f5
commit 5850a60561
10 changed files with 499 additions and 27 deletions
+139 -15
View File
@@ -1,20 +1,25 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getTasks, type TaskListItem } from '@/api/backtest'
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')
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: '全部' },
@@ -28,12 +33,14 @@ 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') list = list.filter((t) => t.status === statusFilter.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(
@@ -57,6 +64,18 @@ const counts = computed(() => ({
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
@@ -64,16 +83,18 @@ watch([typeFilter, statusFilter, keyword], () => {
onMounted(async () => {
await load()
startAutoRefresh()
})
onUnmounted(stopAutoRefresh)
onUnmounted(() => {
stopAutoRefresh()
stopTick()
})
async function load(): Promise<void> {
loading.value = true
try {
tasks.value = await getTasks()
} catch {
ElMessage.error('历史加载失败')
ElMessage.error('任务列表加载失败')
} finally {
loading.value = false
startAutoRefresh()
@@ -83,7 +104,7 @@ async function load(): Promise<void> {
// 自动刷新:有 running/pending 任务时每 3s 静默拉取,全部终态后停(GitHub Actions 模式)
let refreshTimer: ReturnType<typeof setInterval> | null = null
function hasActive(): boolean {
return tasks.value.some((t) => t.status === 'running' || t.status === 'pending')
return tasks.value.some((t) => isActive(t))
}
async function silentRefresh(): Promise<void> {
try {
@@ -91,12 +112,20 @@ async function silentRefresh(): Promise<void> {
} catch {
/* ignore */
}
if (!hasActive()) stopAutoRefresh()
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 {
@@ -105,14 +134,65 @@ function stopAutoRefresh(): void {
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
@@ -121,6 +201,21 @@ const statusLabelMap: Record<string, string> = { done: '完成', failed: '失败
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>
@@ -128,11 +223,14 @@ function statusLabel(s: string): string {
<div class="page-head">
<div>
<h2 class="page-title">
历史任务<span class="count-badge">{{ counts.total }}</span>
任务列表<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>
@@ -150,9 +248,13 @@ function statusLabel(s: string): string {
</div>
<el-card v-loading="loading" class="table-card" shadow="never">
<el-table :data="paged" size="small" empty-text="暂无符合条件的任务">
<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 class="mono">{{ row.task_id }}</span></template>
<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>
@@ -165,12 +267,20 @@ function statusLabel(s: string): string {
<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="150">
<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="76" fixed="right">
<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-button link type="primary" @click="open(row)">查看</el-button>
<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>
@@ -191,6 +301,9 @@ function statusLabel(s: string): string {
<style scoped>
.head-actions {
margin-left: auto;
display: flex;
gap: 0;
align-items: center;
}
.filter-bar {
display: flex;
@@ -213,4 +326,15 @@ function statusLabel(s: string): string {
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>
+16 -3
View File
@@ -3,7 +3,7 @@ import { ref, reactive, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
import { submitCta } from '@/api/backtest'
import { submitCta, getTaskParams } from '@/api/backtest'
import { apiClient } from '@/api/client'
interface InstLike {
@@ -110,11 +110,24 @@ onMounted(async () => {
if (!form.strategy && route.query.class) {
form.strategy = String(route.query.class)
}
// 从任务列表「点任务ID」跳转:按该任务参数完整预填
if (route.query.from_task) {
try {
const tp = await getTaskParams(String(route.query.from_task))
form.strategy = tp.strategy || form.strategy
form.symbol = tp.symbol || form.symbol
if (tp.start) form.start = tp.start
if (tp.end) form.end = tp.end
await loadParams(form.strategy, tp.params)
} catch {
/* 预填失败,走默认 */
}
}
if (!form.strategy && strategies.value.length) {
form.strategy = strategies.value[0].name
}
inited = true
if (!route.query.instance) await loadParams(form.strategy, null)
if (!route.query.instance && !route.query.from_task) await loadParams(form.strategy, null)
} catch {
ElMessage.error('策略列表加载失败')
} finally {
@@ -170,7 +183,7 @@ async function onSubmit(): Promise<void> {
<div class="page-head">
<div>
<h2 class="page-title">个股回测</h2>
<p class="page-subtitle">CTA 策略历史回测 · 提交后可在历史任务跟踪进度与结果</p>
<p class="page-subtitle">CTA 策略历史回测 · 提交后可在任务列表跟踪进度与结果</p>
</div>
</div>
@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
import { getTaskParams } from '@/api/backtest'
import {
postPortfolioBacktest,
getPortfolioResult,
@@ -368,14 +369,39 @@ async function onSubmit(): Promise<void> {
}
}
const fromTaskId = computed(() => (route.query.from_task as string) || '')
onMounted(() => {
if (viewTaskId.value) {
loadResult(viewTaskId.value)
} else if (route.query.strategy) {
// 从代码编辑页「运行回测」跳转:按策略预填
form.strategy = String(route.query.strategy)
} else if (fromTaskId.value) {
// 从任务列表「点任务ID」跳转:按该任务参数完整预填
void prefillFromTask(fromTaskId.value)
}
})
async function prefillFromTask(tid: string): Promise<void> {
try {
const tp = await getTaskParams(tid)
if (tp.strategy) form.strategy = tp.strategy
if (tp.start) form.start = tp.start
if (tp.end) form.end = tp.end
if (tp.symbol) form.benchmark = tp.symbol
const p = tp.params || {}
if (p.max_pool != null) form.max_pool = Number(p.max_pool)
if (p.cash != null) form.cash = Number(p.cash)
if (p.benchmark != null) form.benchmark = String(p.benchmark)
if (p.commission_rate != null) form.commission_rate = Number(p.commission_rate)
if (p.stamp_duty_rate != null) form.stamp_duty_rate = Number(p.stamp_duty_rate)
if (p.min_commission != null) form.min_commission = Number(p.min_commission)
if (p.slippage != null) form.slippage = Number(p.slippage)
} catch {
/* 预填失败,走默认 */
}
}
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' })
@@ -466,7 +492,7 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
<div class="submit-bar">
<el-button type="primary" size="large" @click="onSubmit">开始回测</el-button>
<span class="muted form-hint">提交后跳转历史任务看进度,可继续发起多个回测</span>
<span class="muted form-hint">提交后跳转任务列表看进度,可继续发起多个回测</span>
</div>
<el-alert v-if="errorMsg" type="error" :title="`提交失败:${errorMsg}`" :closable="false" show-icon class="blk" />