feat(api+web): 任务列表管理(验收四轮批1):历史任务→任务列表改名;DELETE /task/{id}单/批删除(进行中拒删);GET /task/{id}/params参数回放+点任务ID跳参数页预填;真创建时间+耗时列(提交即记,进行中活计时);进行中禁查看;组合任务params全量存 [nas]
This commit is contained in:
@@ -105,6 +105,9 @@ export interface TaskListItem {
|
||||
start: string
|
||||
end: string
|
||||
instance?: string
|
||||
created_at?: string
|
||||
finished_at?: string
|
||||
duration_s?: number | null
|
||||
}
|
||||
|
||||
export async function getTasks(type?: string): Promise<TaskListItem[]> {
|
||||
@@ -112,6 +115,27 @@ export async function getTasks(type?: string): Promise<TaskListItem[]> {
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
export interface TaskParamsInfo {
|
||||
task_id: string
|
||||
type: string
|
||||
status: string
|
||||
strategy: string
|
||||
symbol: string
|
||||
params: Record<string, unknown>
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export async function getTaskParams(taskId: string): Promise<TaskParamsInfo> {
|
||||
const { data } = await apiClient.get<TaskParamsInfo>(`/task/${taskId}/params`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string): Promise<{ deleted: number }> {
|
||||
const { data } = await apiClient.delete<{ deleted: number }>(`/task/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export interface OptimizeSubmit {
|
||||
symbol: string
|
||||
strategy: string
|
||||
|
||||
@@ -33,7 +33,7 @@ const quickActions = [
|
||||
{ label: '新建回测', desc: 'CTA 策略历史回测', path: '/backtest/new', mark: 'BT' },
|
||||
{ label: '因子分析', desc: 'IC / 分层回测', path: '/factor/new', mark: 'FA' },
|
||||
{ label: '新建模拟盘', desc: '回放 / 实盘模式', path: '/paper/new', mark: 'PA' },
|
||||
{ label: '历史任务', desc: '全部回测记录', path: '/backtest/history', mark: 'HI' },
|
||||
{ label: '任务列表', desc: '全部回测记录', path: '/backtest/history', mark: 'HI' },
|
||||
] as const
|
||||
|
||||
function go(path: string): void {
|
||||
|
||||
@@ -25,7 +25,7 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
|
||||
{ match: /^\/backtest\/new$/, group: '回测', title: '个股回测' },
|
||||
{ match: /^\/backtest\/portfolio$/, group: '回测', title: '组合回测' },
|
||||
{ match: /^\/backtest\/optimize$/, group: '回测', title: '参数优化' },
|
||||
{ match: /^\/backtest\/history$/, group: '回测', title: '历史任务' },
|
||||
{ match: /^\/backtest\/history$/, group: '回测', title: '任务列表' },
|
||||
{ match: /^\/backtest\/progress\//, group: '回测', title: '任务进度' },
|
||||
{ match: /^\/backtest\/result\//, group: '回测', title: '回测结果' },
|
||||
{ match: /^\/factor\/new$/, group: '投研', title: '因子分析' },
|
||||
@@ -64,7 +64,7 @@ const CMD_ITEMS: CmdItem[] = [
|
||||
{ label: '个股回测', group: '回测', path: '/backtest/new' },
|
||||
{ label: '组合回测', group: '回测', path: '/backtest/portfolio' },
|
||||
{ label: '参数优化', group: '回测', path: '/backtest/optimize' },
|
||||
{ label: '历史任务', group: '回测', path: '/backtest/history' },
|
||||
{ label: '任务列表', group: '回测', path: '/backtest/history' },
|
||||
{ label: '因子分析', group: '投研', path: '/factor/new' },
|
||||
{ label: '模拟盘列表', group: '模拟', path: '/paper' },
|
||||
{ label: '新建模拟盘', group: '模拟', path: '/paper/new' },
|
||||
@@ -164,7 +164,7 @@ function onLogout(): void {
|
||||
<el-menu-item index="/backtest/new">个股回测</el-menu-item>
|
||||
<el-menu-item index="/backtest/portfolio">组合回测</el-menu-item>
|
||||
<el-menu-item index="/backtest/optimize">参数优化</el-menu-item>
|
||||
<el-menu-item index="/backtest/history">历史任务</el-menu-item>
|
||||
<el-menu-item index="/backtest/history">任务列表</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-sub-menu index="factor">
|
||||
<template #title><span class="nav-label">投研</span></template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user