feat(factor): 进度页心跳判活+因子列表默认折叠(用户08-30反馈两项) [vps]
CI/CD / test (push) Successful in 4s
CI/CD / nas-deploy (push) Successful in 11s
CI/CD / nas-verify (push) Successful in 7s

痛点:因子分析分钟级运行,进度条停着看不出进行中还是死掉;240因子罗列乱。
①跨进程心跳链路:analyzer._report_progress 写 {output_dir}/{tid}.progress
(stage/detail/ts;IO故障静默——锦上添花不能伤主流程),埋点=行情加载i/N逐只
+因子特征+逐因子i/M+tears✓+完成;runner._factor_worker 透传 task_id
(默认空串兼容旧调用);GET /task/{id} 合并 _read_factor_progress(factor_
前缀才读,age=距上次活动秒数)。
②Progress.vue终端风重构:心跳区=呼吸灯(绿≤30s/琥珀≤180s静默期/红更久,
prefers-reduced-motion停动画)+「Xs前·detail」+运行时长秒表+步骤%大数字;
因子步骤=行情加载→因子计算→逐因子分析→完成(心跳stage驱动);回测/优化
保持原5步推导+进度条;useTask/TaskStatus 透传 progress。
③FactorPicker默认折叠:groups max-height 128px两行预览+渐隐底边+
「展开全部N个因子▾」按钮,搜索时自动展开。
+5测试(_report_progress写/静默/心跳读roundtrip/非factor/损坏JSON),323绿+build绿
This commit is contained in:
2026-08-30 10:17:19 +08:00
parent 557479b848
commit 8f4f564733
9 changed files with 315 additions and 86 deletions
+7
View File
@@ -19,6 +19,13 @@ export interface TaskStatus {
task_id: string
status: string
stage: string
/** 因子分析心跳(仅 factor_ 任务运行中有值) */
progress?: {
stage: string
detail: string
ts: number
age: number
} | null
}
export interface EquityPoint {
+11 -1
View File
@@ -4,6 +4,14 @@ import { useAuthStore } from '@/stores/auth'
export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'unknown'
export interface TaskProgress {
stage: string
detail: string
ts: number
/** 距上次心跳秒数(后端算) */
age: number
}
/**
* Track a task's status + stage via polling (2s) and WebSocket (real-time).
* Auto-stops on unmount.
@@ -11,6 +19,7 @@ export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'unknown'
export function useTask(taskId: string) {
const status = ref<TaskState>('unknown')
const stage = ref('')
const progress = ref<TaskProgress | null>(null)
let timer: ReturnType<typeof setInterval> | null = null
let ws: WebSocket | null = null
@@ -19,6 +28,7 @@ export function useTask(taskId: string) {
const s = await getStatus(taskId)
status.value = s.status as TaskState
stage.value = s.stage
progress.value = (s as { progress?: TaskProgress | null }).progress ?? null
} catch {
/* transient — keep last known state */
}
@@ -51,5 +61,5 @@ export function useTask(taskId: string) {
if (ws) ws.close()
})
return { status, stage, start }
return { status, stage, progress, start }
}
+162 -77
View File
@@ -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"> 3s 刷新</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"> 3s 刷新</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: 16px; }
.blk { border: 1px solid var(--border-2); }
.stage-text { margin-top: 10px; }
.progress-page { display: flex; flex-direction: column; gap: 14px; }
.pane { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-lg); min-width: 0; }
.pane .ph { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border-2); flex-wrap: wrap; }
.pane .pt { font-size: 12.5px; font-weight: 700; letter-spacing: .06em; }
.pane .tag { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; }
.pane .pb { padding: 14px; }
/* ===== 心跳区 ===== */
.heartbeat { display: flex; align-items: center; gap: 16px; padding: 16px 18px; }
.beacon { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.hb-ok .beacon { background: var(--lamp-ok); animation: breathe 1.6s ease-in-out infinite; }
.hb-slow .beacon { background: var(--lamp-warn); animation: breathe 3s ease-in-out infinite; }
.hb-stale .beacon { background: var(--lamp-crit); animation: breathe 3s ease-in-out infinite; }
.hb-none .beacon { background: var(--lamp-idle); }
.beacon.stopped { animation: none; }
.hb-ok .beacon { box-shadow: 0 0 10px rgba(46, 230, 138, .5); }
.hb-slow .beacon, .hb-stale .beacon { box-shadow: 0 0 10px 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: 13px; }
.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: 4px; font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
.hb-sub .dot { margin: 0 6px; }
.hb-pct { font-size: 30px; font-weight: 700; color: var(--brand); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.hb-pct .pct-sign { font-size: 14px; 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 8px; }
.tl-dot {
@@ -157,33 +253,22 @@ const statusChipClass: Record<string, string> = { running: 'st-running', done: '
border: 1px solid var(--border-2);
z-index: 1;
}
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); }
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); white-space: nowrap; }
.tl-bar { position: absolute; top: 13px; left: 50%; width: 100%; height: 2px; 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: 1px solid var(--border-2);
border-radius: var(--r-sm);
padding: 12px;
max-height: 360px;
overflow: auto;
}
.log-box pre {
margin: 0;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-2);
white-space: pre-wrap;
word-break: break-all;
}
.pct-bar { margin-top: 14px; height: 4px; background: var(--border-2); border-radius: var(--r-sm); overflow: hidden; }
.pct-fill { height: 100%; background: var(--brand); transition: width .4s ease; }
/* ===== 日志 ===== */
.log-box { background: var(--bg); border: 1px solid var(--border-2); border-radius: var(--r-sm); padding: 12px; max-height: 360px; overflow: auto; }
.log-box pre { margin: 0; font-family: var(--mono); font-size: 12px; line-height: 1.6; color: var(--text-2); white-space: pre-wrap; word-break: break-all; }
.empty { color: var(--text-3); font-size: 12px; }
</style>
+28 -2
View File
@@ -11,6 +11,16 @@ const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
const q = ref('')
const picked = ref<Set<string>>(new Set(props.modelValue))
// (1-2 );
const collapsed = ref(true)
function toggleCollapse(): void {
collapsed.value = !collapsed.value
}
function onSearch(): void {
if (q.value.trim()) collapsed.value = false
}
const grouped = computed(() => {
const map = new Map<string, FactorItem[]>()
@@ -68,11 +78,11 @@ defineExpose({ hydrate })
<template>
<div class="fp">
<div class="searchrow">
<input v-model="q" type="text" spellcheck="false" placeholder="搜索因子名 / 类别,如 alpha16、KMID、alpha158 …">
<input v-model="q" type="text" spellcheck="false" placeholder="搜索因子名 / 类别,如 alpha16、KMID、alpha158 …" @input="onSearch">
<span class="count">显示 {{ visibleCount() }} / {{ factors.length }}</span>
</div>
<div class="groups">
<div class="groups" :class="{ collapsed }">
<div v-for="g in grouped" :key="g.cat" class="grp">
<div class="gh">
<span class="cat-chip" :class="`cat-${g.cat}`">{{ g.cat }}</span>
@@ -87,6 +97,9 @@ defineExpose({ hydrate })
</div>
</div>
</div>
<button type="button" class="foldbtn" @click="toggleCollapse">
{{ collapsed ? `展开全部 ${factors.length} 个因子 ▾` : '收起因子列表 ▴' }}
</button>
<div class="picked">
<span class="pk">已选 {{ picked.size }}</span>
@@ -109,6 +122,19 @@ defineExpose({ hydrate })
.searchrow input:focus { outline: 1px solid rgba(0,229,255,.5); }
.count { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* 默认收起:两行分组预览(头部+两行 chips ≈ 128px);渐隐底边提示可展开 */
.groups.collapsed { max-height: 128px; overflow: hidden; position: relative; }
.groups.collapsed::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 34px;
background: linear-gradient(transparent, var(--bg-card));
pointer-events: none;
}
.foldbtn {
font-family: var(--mono); font-size: 10.5px; padding: 4px 12px; margin: 8px 0 0;
border-radius: var(--r-sm); border: 1px dashed var(--border); background: transparent;
color: var(--text-2); cursor: pointer; transition: all .12s ease;
}
.foldbtn:hover { color: var(--brand); border-color: rgba(0, 229, 255, .4); }
@media (max-width: 980px) { .groups { grid-template-columns: 1fr; } }
.grp .gh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.grp .gc { font-family: var(--mono); font-size: 10px; color: var(--text-3); }