diff --git a/frontend/src/views/backtest/Progress.vue b/frontend/src/views/backtest/Progress.vue
index 983f44a..dbc9caa 100644
--- a/frontend/src/views/backtest/Progress.vue
+++ b/frontend/src/views/backtest/Progress.vue
@@ -180,7 +180,6 @@ const pageTitle = computed(() => (isFactor.value ? '因子分析' : isOptimize.v
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' }"
>
✓
@@ -242,19 +241,22 @@ const pageTitle = computed(() => (isFactor.value ? '因子分析' : isOptimize.v
.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); }
-/* ===== 时间线 ===== */
+/* ===== 时间线 =====
+ 全节点等宽(flex:1)→dot 落在各等分区中心;连接线从本区 dot 右 16px 到
+ 下一区 dot 左 16px(等宽下精确对齐;原「末节点不伸缩+bar 宽 100% 越界」
+ 会把 4 顶到屏幕右缘且 bar 伸出容器)。 */
.timeline { display: flex; align-items: flex-start; }
-.tl-node { display: flex; flex-direction: column; align-items: center; position: relative; padding: 0 8px; }
+.tl-node { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; padding: 0 8px; min-width: 0; }
.tl-dot {
width: 26px; height: 26px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
background: var(--bg-hover); color: var(--text-3);
border: 1px solid var(--border-2);
- z-index: 1;
+ z-index: 1; flex-shrink: 0;
}
.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-bar { position: absolute; top: 13px; left: calc(50% + 16px); width: calc(100% - 32px); 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-bar { background: var(--down); }
diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py
index 54cd753..a6d9184 100644
--- a/sanguo_api/routes.py
+++ b/sanguo_api/routes.py
@@ -730,7 +730,21 @@ def daily_holdings(task_id: str):
@router.get("/task/{task_id}/log", dependencies=[Depends(verify_token)])
def log_endpoint(task_id: str):
- """Get backtest log text."""
+ """Get backtest log text.
+
+ 因子任务无传统日志文件 → 返回心跳流水(progress.log,由 analyzer
+ _report_progress 逐条 append),行情加载 i/N / 因子 i/M / tears ✓
+ 即用户要看的"过程"。
+ """
+ if task_id.startswith("factor_"):
+ try:
+ lp = os.path.join(_FACTOR_PROGRESS_DIR, f"{task_id}.progress.log")
+ if os.path.exists(lp):
+ with open(lp, encoding="utf-8") as f:
+ return {"task_id": task_id, "log": f.read()}
+ except Exception:
+ pass
+ return {"task_id": task_id, "log": ""}
orch = get_orchestrator()
if hasattr(orch, 'db_path') and orch.db_path:
file_dir = os.path.dirname(os.path.abspath(orch.db_path))
diff --git a/sanguo_factor/analyzer.py b/sanguo_factor/analyzer.py
index 1d98a25..975c82d 100644
--- a/sanguo_factor/analyzer.py
+++ b/sanguo_factor/analyzer.py
@@ -62,7 +62,9 @@ class FactorReport:
def _report_progress(task_id: str, output_dir: str, stage: str, detail: str) -> None:
"""跨进程进度心跳:worker 写 {output_dir}/{task_id}.progress,
- GET /task/{id} 读它合并返回(前端显示"最近活动 Xs 前 · detail")。
+ GET /task/{id} 读它合并返回(前端显示"最近活动 Xs 前 · detail");
+ 同时 append 一行到 {task_id}.progress.log 作活动流水——因子任务
+ 没有传统日志文件,心跳流水就是 /task/{id}/log 的内容。
失败静默——进度是可观测性锦上添花,不能影响分析主流程。"""
if not task_id:
return
@@ -72,6 +74,10 @@ def _report_progress(task_id: str, output_dir: str, stage: str, detail: str) ->
p = os.path.join(output_dir, f"{task_id}.progress")
with open(p, "w", encoding="utf-8") as f:
f.write(_j.dumps({"stage": stage, "detail": detail, "ts": _t.time()}))
+ lp = os.path.join(output_dir, f"{task_id}.progress.log")
+ stamp = _t.strftime("%H:%M:%S")
+ with open(lp, "a", encoding="utf-8") as f:
+ f.write(f"[{stamp}] {stage} · {detail}\n")
except Exception:
pass