@@ -1,4 +1,12 @@
< script setup lang = "ts" >
/**
* 任务进度页(终端风重构,2026-08-30 用户反馈:因子分析分钟级运行,
* 进度条停着看不出是进行中还是死掉)。
*
* 因子任务核心=心跳:worker 跨进程写 progress 文件(stage/detail/ts),
* useTask 轮询透传 → 本页显示「最近活动 Xs 前 · detail」+ 运行时长,
* age 分档(绿/琥珀)告知活着 vs 长任务静默期。回测/优化走原 5 步推导。
*/
import { ref , computed , watch , onMounted , onUnmounted , nextTick } from 'vue'
import { useRoute , useRouter } from 'vue-router'
import { useTask } from '@/composables/useTask'
@@ -7,12 +15,16 @@ import { getLog } from '@/api/backtest'
const route = useRoute ( )
const router = useRouter ( )
const taskId = String ( route . params . id )
const isFactor = computed ( ( ) => route . path . startsWith ( '/factor' ) )
const isFactor = computed ( ( ) => route . path . startsWith ( '/factor' ) || taskId . startsWith ( 'factor_' ) )
const isOptimize = computed ( ( ) => taskId . startsWith ( 'opt_' ) )
const { status , stage , start } = useTask ( taskId )
const { status , stage , progress , start } = useTask ( taskId )
// 同时拉日志(3s)
// —— 运行时长(本地秒表,页面挂载起) ——
const elapsed = ref ( 0 )
let tickTimer : ReturnType < typeof setInterval > | null = null
// —— 日志(3s) ——
const logText = ref ( '' )
const logBox = ref < HTMLElement | null > ( null )
let logTimer : ReturnType < typeof setInterval > | null = null
@@ -31,9 +43,11 @@ onMounted(() => {
start ( )
void pullLog ( )
logTimer = setInterval ( pullLog , 3000 )
tickTimer = setInterval ( ( ) => { elapsed . value ++ } , 1000 )
} )
onUnmounted ( ( ) => {
if ( logTimer ) clearInterval ( logTimer )
if ( tickTimer ) clearInterval ( tickTimer )
} )
watch ( status , ( s ) => {
@@ -48,9 +62,9 @@ watch(status, (s) => {
}
} )
// 步骤推导
// —— 通用(回测/优化) 步骤推导 ——
interface Step { key : string ; label : string }
const steps : Step [ ] = [
const btSteps : Step [ ] = [
{ key : 'submit' , label : '任务提交' } ,
{ key : 'data' , label : '数据加载' } ,
{ key : 'run' , label : '回测执行' } ,
@@ -58,22 +72,18 @@ const steps: Step[] = [
{ key : 'done' , label : '完成' } ,
]
function stepState ( idx : number ) : 'done' | 'active' | 'pending' {
function btStepState ( idx : number ) : 'done' | 'active' | 'pending' {
const s = status . value
if ( s === 'done' ) return 'done'
if ( s === 'failed' ) {
// 失败:当前进行到的步骤标 active(红),之前标 done
return idx < currentIdx . value ? 'done' : idx === currentIdx . value ? 'active' : 'pending'
}
if ( idx < currentIdx . value ) return 'done'
if ( idx === currentIdx . value ) return 'active'
if ( idx < btCurrentIdx . value ) return 'done'
if ( idx === btCurrentIdx . value ) return 'active'
return 'pending'
}
const currentIdx = computed ( ( ) => {
const btCurrentIdx = computed ( ( ) => {
const st = ( stage . value || '' ) . toLowerCase ( )
const s = status . value
if ( s === 'done' ) return steps . length - 1
if ( s === 'done' ) return btSteps . length - 1
if ( s === 'pending' || s === 'unknown' ) return 0
if ( st . includes ( 'metric' ) || st . includes ( '指标' ) ) return 3
if ( st . includes ( 'run' ) || st . includes ( 'backtest' ) || st . includes ( '回测' ) ) return 2
@@ -81,72 +91,158 @@ const currentIdx = computed(() => {
return 1
} )
const pct = computed ( ( ) => {
if ( status . value === 'done' ) return 100
if ( status . value === 'failed' ) return Math . round ( ( currentIdx . value / ( steps . length - 1 ) ) * 100 )
return Math . round ( ( currentIdx . value / ( steps . length - 1 ) ) * 100 )
const btPct = computed ( ( ) => Math . round ( ( btCurrentIdx . value / ( btSteps . length - 1 ) ) * 100 ) )
// —— 因子任务步骤(由心跳 stage 驱动) ——
const fxSteps : Step [ ] = [
{ key : 'data' , label : '行情加载' } ,
{ key : 'compute' , label : '因子计算' } ,
{ key : 'analyze' , label : '逐因子分析' } ,
{ key : 'done' , label : '完成' } ,
]
const fxCurrentIdx = computed ( ( ) => {
const s = status . value
if ( s === 'done' ) return fxSteps . length - 1
const ps = progress . value ? . stage
if ( ps === 'done' ) return 3
if ( ps === 'analyze' ) return 2
if ( ps === 'compute' ) return 1
if ( ps === 'data' ) return 0
return 0 // 无心跳(排队/刚起步)
} )
function fxStepState ( idx : number ) : 'done' | 'active' | 'pending' {
if ( status . value === 'done' ) return 'done'
if ( idx < fxCurrentIdx . value ) return 'done'
if ( idx === fxCurrentIdx . value ) return 'active'
return 'pending'
}
const fxPct = computed ( ( ) => Math . round ( ( fxCurrentIdx . value / ( fxSteps . length - 1 ) ) * 100 ) )
// —— 心跳分档 ——
const heartbeat = computed ( ( ) : { level : 'ok' | 'slow' | 'stale' | 'none' ; text : string } => {
if ( status . value === 'done' ) return { level : 'ok' , text : '已完成' }
if ( status . value === 'failed' ) return { level : 'stale' , text : '失败' }
if ( status . value === 'pending' ) return { level : 'none' , text : '排队中,等待 worker 接活' }
const p = progress . value
if ( ! p ) return { level : 'none' , text : '等待首个心跳(分析进程启动中)…' }
const a = p . age
if ( a <= 30 ) return { level : 'ok' , text : ` ${ p . age } s 前 · ${ p . detail } ` }
if ( a <= 180 ) return { level : 'slow' , text : ` ${ p . age } s 前 · ${ p . detail } (大窗口因子计算静默期,仍在运行) ` }
return { level : 'stale' , text : ` ${ p . age } s 无新进展 · ${ p . detail } ——仍在运行,超长可考虑缩小标的池/区间 ` }
} )
function fmtElapsed ( sec : number ) : string {
if ( sec < 60 ) return ` ${ sec } s `
const m = Math . floor ( sec / 60 )
if ( m < 60 ) return ` ${ m } m ${ sec % 60 } s `
return ` ${ Math . floor ( m / 60 ) } h ${ m % 60 } m `
}
const statusLabel : Record < string , string > = { running : '运行中' , done : '完成' , failed : '失败' , pending : '排队中' , unknown : '初始化' }
const statusChipClass : Record < string , string > = { running : 'st-running' , done : 'st-done' , failed : 'st-failed' , pending : 'st-pending' , unknown : 'st-pending' }
const pageTitle = computed ( ( ) => ( isFactor . value ? '因子分析' : isOptimize . value ? '参数优化' : '回测' ) )
< / script >
< template >
< div class = "page progress-page" >
< div class = "page-head" >
< div >
< h2 class = "page-title" > { { isFactor ? '因子分析' : isOptimize ? '参数优化' : '回测' } } 进行中 < / h2 >
< h2 class = "page-title" > { { pageTitle } } 进行中 < / h2 >
< p class = "page-subtitle" > 任务 ID : < span class = "mono" > { { taskId } } < / span > < / p >
< / div >
< span class = "chip" :class = "statusChipClass[status]" > { { statusLabel [ status ] } } < / span >
< / div >
<!-- 进度条 -- >
< el-card class = "blk" shadow = "never ">
< el-progress
:percentage = "pct"
: status = "status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
:stroke-width = "10"
/ >
< div class = "stage-text muted "> 当前阶段 : { { stage || '—' } } < / div >
< / el-card >
<!-- 步骤时间线 -- >
< el-card class = "blk" shadow = "never" >
< template # header > < span class = "section-title" > 执行步骤 < / span > < / template >
< div class = "timeline" >
< div
v-for = "(s, i) in steps" :key="s.key"
class = "tl-node"
: class = "[stepState(i), { failed: status === 'failed' && i === currentIdx }]"
: style = "{ flex: i === steps.length - 1 ? '0 0 auto' : '1' }"
>
< div class = "tl-dot" >
< span v-if = "stepState(i) === 'done'" > ✓ < / span >
< span v-else > {{ i + 1 }} < / span >
< / div >
< div class = "tl-label" > { { s . label } } < / div >
< div v-if = "i < steps.length - 1" class="tl-bar" / >
<!-- 心跳区 ( 因子任务 : 判活核心 ) -- >
< div v-if = "isFactor" class="pane heartbeat" :class="`hb-${heartbeat.level}` " >
< div class = "beacon" : class = "{ stopped: status === 'done' || status === 'failed' }" / >
< div class = "hb-main" >
< div class = "hb-text" :class = "`t-${heartbeat.level}`" > { { heartbeat . text } } < / div >
< div class = "hb-sub" >
已运行 < span class = "mono" > { { fmtElapsed ( elapsed ) } } < / span >
< span v-if = "progress" class="dot "> · < / span >
< span v-if = "progress" class="mono" > step {{ progress.stage }} < / span >
< / div >
< / div >
< / el-card >
< div class = "hb-pct mono" > { { fxPct } } < span class = "pct-sign" > % < / span > < / div >
< / div >
<!-- 步骤时间线 -- >
< div class = "pane" >
< div class = "ph" > < span class = "pt" > { { isFactor ? '分析步骤' : '执行步骤' } } < / span > < span class = "tag" > { { isFactor ? '由 worker 心跳驱动' : '按阶段推导' } } < / span > < / div >
< div class = "pb" >
< div class = "timeline" >
< div
v-for = "(s, i) in (isFactor ? fxSteps : btSteps)" :key="s.key"
class = "tl-node"
: class = "[isFactor ? fxStepState(i) : btStepState(i), { failed: status === 'failed' && i === (isFactor ? fxCurrentIdx : btCurrentIdx) }]"
: style = "{ flex: i === (isFactor ? fxSteps : btSteps).length - 1 ? '0 0 auto' : '1' }"
>
< div class = "tl-dot" >
< span v-if = "(isFactor ? fxStepState(i) : btStepState(i)) === 'done'" > ✓ < / span >
< span v-else > {{ i + 1 }} < / span >
< / div >
< div class = "tl-label" > { { s . label } } < / div >
< div v-if = "i < (isFactor ? fxSteps : btSteps).length - 1" class="tl-bar" / >
< / div >
< / div >
< div v-if = "!isFactor" class="pct-bar"><div class="pct-fill" :style="{ width: btPct + '%' }" / > < / div >
< / div >
< / div >
<!-- 日志 -- >
< el-card class = "blk" shadow = "never ">
< template # header > < span class = "section-title " > 运行日志 < em class = "muted " > ( 每 3 s 刷新 ) < / em > < / span > < / template >
< div ref = "logBox" class = "log-box ">
< pre v-if = "logText" > {{ logText }} < / pre >
< span v-else class = "muted "> 暂无日志输出 … < / span >
< div class = "pane " >
< div class = "ph" > < span class = "pt " > 运行日志 < / span > < span class = "tag " > 每 3 s 刷新 < / span > < / div >
< div class = "pb ">
< div ref = "logBox" class = "log-box" >
< pre v-if = "logText "> {{ logText }} < / pre >
< span v-else class = "empty" > 暂无日志输出 … < / span >
< / div >
< / div >
< / el-card >
< / div >
< / div >
< / template >
< style scoped >
. progress - page { display : flex ; flex - direction : column ; gap : 16 px ; }
. blk { border : 1 px solid var ( -- border - 2 ) ; }
. stage - text { margin - top : 10 px ; }
. progress - page { display : flex ; flex - direction : column ; gap : 14 px ; }
. pane { background : var ( -- bg - card ) ; border : 1 px solid var ( -- border ) ; border - radius : var ( -- r - lg ) ; min - width : 0 ; }
. pane . ph { display : flex ; align - items : center ; gap : 10 px ; padding : 10 px 14 px ; border - bottom : 1 px solid var ( -- border - 2 ) ; flex - wrap : wrap ; }
. pane . pt { font - size : 12.5 px ; font - weight : 700 ; letter - spacing : .06 em ; }
. pane . tag { font - family : var ( -- mono ) ; font - size : 9.5 px ; color : var ( -- text - 3 ) ; letter - spacing : .1 em ; }
. pane . pb { padding : 14 px ; }
/* ===== 心跳区 ===== */
. heartbeat { display : flex ; align - items : center ; gap : 16 px ; padding : 16 px 18 px ; }
. beacon { width : 10 px ; height : 10 px ; border - radius : 50 % ; flex - shrink : 0 ; }
. hb - ok . beacon { background : var ( -- lamp - ok ) ; animation : breathe 1.6 s ease - in - out infinite ; }
. hb - slow . beacon { background : var ( -- lamp - warn ) ; animation : breathe 3 s ease - in - out infinite ; }
. hb - stale . beacon { background : var ( -- lamp - crit ) ; animation : breathe 3 s ease - in - out infinite ; }
. hb - none . beacon { background : var ( -- lamp - idle ) ; }
. beacon . stopped { animation : none ; }
. hb - ok . beacon { box - shadow : 0 0 10 px rgba ( 46 , 230 , 138 , .5 ) ; }
. hb - slow . beacon , . hb - stale . beacon { box - shadow : 0 0 10 px rgba ( 255 , 176 , 0 , .4 ) ; }
@ media ( prefers - reduced - motion : reduce ) { . beacon { animation : none ; } }
@ keyframes breathe {
0 % , 100 % { opacity : .35 ; }
50 % { opacity : 1 ; }
}
. hb - main { flex : 1 ; min - width : 0 ; }
. hb - text { font - family : var ( -- mono ) ; font - size : 13 px ; }
. t - ok { color : var ( -- down ) ; }
. t - slow { color : var ( -- amber ) ; }
. t - stale { color : var ( -- danger ) ; }
. t - none { color : var ( -- text - 2 ) ; }
. hb - sub { margin - top : 4 px ; font - family : var ( -- mono ) ; font - size : 10.5 px ; color : var ( -- text - 3 ) ; }
. hb - sub . dot { margin : 0 6 px ; }
. hb - pct { font - size : 30 px ; font - weight : 700 ; color : var ( -- brand ) ; font - variant - numeric : tabular - nums ; flex - shrink : 0 ; }
. hb - pct . pct - sign { font - size : 14 px ; color : var ( -- text - 3 ) ; }
/* ===== 时间线 ===== */
. timeline { display : flex ; align - items : flex - start ; }
. tl - node { display : flex ; flex - direction : column ; align - items : center ; position : relative ; padding : 0 8 px ; }
. tl - dot {
@@ -157,33 +253,22 @@ const statusChipClass: Record<string, string> = { running: 'st-running', done: '
border : 1 px solid var ( -- border - 2 ) ;
z - index : 1 ;
}
. tl - label { margin - top : 8 px ; font - size : 12 px ; color : var ( -- text - 3 ) ; }
. tl - label { margin - top : 8 px ; font - size : 12 px ; color : var ( -- text - 3 ) ; white - space : nowrap ; }
. tl - bar { position : absolute ; top : 13 px ; left : 50 % ; width : 100 % ; height : 2 px ; background : var ( -- border - 2 ) ; z - index : 0 ; }
. tl - node . done . tl - dot { background : var ( -- down ) ; color : # fff ; border - color : var ( -- down ) ; }
. tl - node . done . tl - label { color : var ( -- text ) ; }
. tl - node . done + . tl - node . tl - bar ,
. tl - node . done . tl - bar { background : var ( -- down ) ; }
. tl - node . active . tl - dot { background : var ( -- brand ) ; color : # fff ; border - color : var ( -- brand ) ; }
. tl - node . done + . tl - node . tl - bar { background : var ( -- down ) ; }
. tl - node . active . tl - dot { background : var ( -- brand ) ; color : # 032027 ; border - color : var ( -- brand ) ; }
. tl - node . active . tl - label { color : var ( -- brand ) ; }
. tl - node . active . failed . tl - dot { background : var ( -- danger ) ; border - color : var ( -- danger ) ; }
. tl - node . active . failed . tl - dot { background : var ( -- danger ) ; border - color : var ( -- danger ) ; color : # fff ; }
. tl - node . active . failed . tl - label { color : var ( -- danger ) ; }
. log - box {
background : var ( -- bg ) ;
border : 1 px solid var ( -- border - 2 ) ;
border - radius : var ( -- r - sm ) ;
padding : 12 px ;
max - height : 360 px ;
overflow : auto ;
}
. log - box pre {
margin : 0 ;
font - family : var ( -- mono ) ;
font - size : 12 px ;
line - height : 1.6 ;
color : var ( -- text - 2 ) ;
white - space : pre - wrap ;
word - break : break - all ;
}
. pct - bar { margin - top : 14 px ; height : 4 px ; background : var ( -- border - 2 ) ; border - radius : var ( -- r - sm ) ; overflow : hidden ; }
. pct - fill { height : 100 % ; background : var ( -- brand ) ; transition : width .4 s ease ; }
/* ===== 日志 ===== */
. log - box { background : var ( -- bg ) ; border : 1 px solid var ( -- border - 2 ) ; border - radius : var ( -- r - sm ) ; padding : 12 px ; max - height : 360 px ; overflow : auto ; }
. log - box pre { margin : 0 ; font - family : var ( -- mono ) ; font - size : 12 px ; line - height : 1.6 ; color : var ( -- text - 2 ) ; white - space : pre - wrap ; word - break : break - all ; }
. empty { color : var ( -- text - 3 ) ; font - size : 12 px ; }
< / style >