diff --git a/app/schemas.py b/app/schemas.py
index c6202a0..523a239 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -34,6 +34,13 @@ class TaskResponse(BaseModel):
current_stage: str | None = None
current_stage_label: str | None = None
last_progress_at: datetime | None = None
+ running_seconds: int = 0
+ seconds_since_last_progress: int | None = None
+ running_duration_label: str = "刚刚"
+ last_progress_ago_label: str = "暂无记录"
+ is_progress_stale: bool = False
+ stale_threshold_minutes: int = 10
+ hotspots_count: int = 0
comments_count: int = 0
reports_count: int = 0
is_demo: bool = False
diff --git a/app/services/task_service.py b/app/services/task_service.py
index 69cc54d..add9092 100644
--- a/app/services/task_service.py
+++ b/app/services/task_service.py
@@ -2,6 +2,7 @@ import inspect
import json
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
+from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
@@ -19,6 +20,7 @@ from app.services.report_service import generate_hotspot_report, generate_item_r
RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
+STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
task_executor = ThreadPoolExecutor(max_workers=1)
@@ -148,6 +150,7 @@ def get_task(session: Session, task_id: str) -> Task | None:
def hydrate_task_progress(session: Session, task: Task) -> Task:
+ task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0
task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
task.reports_count = session.scalar(select(func.count(Report.id)).where(Report.task_id == task.id)) or 0
task.is_demo = task.id.startswith("demo-")
@@ -160,9 +163,51 @@ def hydrate_task_progress(session: Session, task: Task) -> Task:
elif not stage_code and task.status == "running":
stage_code = "queued"
task.current_stage_label = STAGE_LABELS.get(stage_code or "", stage_code or "等待启动")
+ now = ensure_utc_datetime(utc_now())
+ running_start = ensure_utc_datetime(task.started_at or task.created_at)
+ running_end = ensure_utc_datetime(task.finished_at) or now
+ last_progress_at = ensure_utc_datetime(task.last_progress_at)
+ running_seconds = max(0, int((running_end - running_start).total_seconds()))
+ seconds_since_progress = max(0, int((now - last_progress_at).total_seconds())) if last_progress_at else None
+ task.running_seconds = running_seconds
+ task.seconds_since_last_progress = seconds_since_progress
+ task.running_duration_label = format_duration_zh(running_seconds)
+ task.last_progress_ago_label = f"{format_duration_zh(seconds_since_progress)}前" if seconds_since_progress is not None else "暂无记录"
+ task.stale_threshold_minutes = STALE_PROGRESS_THRESHOLD_SECONDS // 60
+ task.is_progress_stale = (
+ task.status == "running"
+ and seconds_since_progress is not None
+ and seconds_since_progress >= STALE_PROGRESS_THRESHOLD_SECONDS
+ )
return task
+def format_duration_zh(total_seconds: int | None) -> str:
+ if total_seconds is None:
+ return "暂无记录"
+ total_seconds = max(0, int(total_seconds))
+ minutes = total_seconds // 60
+ hours = minutes // 60
+ days = hours // 24
+ if days > 0:
+ remaining_hours = hours % 24
+ return f"{days}天{remaining_hours}小时" if remaining_hours else f"{days}天"
+ if hours > 0:
+ remaining_minutes = minutes % 60
+ return f"{hours}小时{remaining_minutes}分钟" if remaining_minutes else f"{hours}小时"
+ if minutes > 0:
+ return f"{minutes}分钟"
+ return "刚刚"
+
+
+def ensure_utc_datetime(value: datetime | None) -> datetime | None:
+ if value is None:
+ return None
+ if value.tzinfo is None:
+ return value.replace(tzinfo=UTC)
+ return value.astimezone(UTC)
+
+
def update_task_stage(task: Task, stage: str) -> None:
task.current_stage = stage
task.last_progress_at = utc_now()
diff --git a/app/templates/tasks/detail.html b/app/templates/tasks/detail.html
index ef9d66e..30ace04 100644
--- a/app/templates/tasks/detail.html
+++ b/app/templates/tasks/detail.html
@@ -42,17 +42,27 @@
实际结果
实际评论 {{ task.comments_count or 0 }}
-
报告 {{ task.reports_count or 0 }} / 内容 {{ task.total_items_count }}
+
已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }} · 报告 {{ task.reports_count or 0 }}
最近进度
-
{{ task.last_progress_at or task.created_at }}
-
外部接口和 AI 响应较慢时,阶段可能短时间不变
+
{{ task.last_progress_ago_label or "暂无记录" }}
+
运行时长 {{ task.running_duration_label or "刚刚" }} · 评论 {{ task.comments_count or 0 }}
+ {% if task.status == "running" %}
+
+ 小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度。
+
+ {% endif %}
+ {% if task.is_progress_stale %}
+
+ 超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
+
+ {% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。
{% endif %}
diff --git a/docs/MVP-WorkOrders.md b/docs/MVP-WorkOrders.md
index 12723fd..0deda46 100644
--- a/docs/MVP-WorkOrders.md
+++ b/docs/MVP-WorkOrders.md
@@ -1130,6 +1130,29 @@ feat: 增强任务进度和运行阶段展示
- 是否需要显示“预计剩余时间”。如果需要,建议第一版只显示粗略区间,不做精确倒计时,避免误导。
+完成记录:
+
+- 完成日期:2026-07-03
+- 相关改动:
+ - 任务详情页新增运行时长、最近进度相对时间、热点 / 评论 / 报告数量和默认规模耗时提示。
+ - `GET /api/tasks/{task_id}` 新增运行秒数、最近进度秒数、中文时长文案、长时间无进度布尔值和 10 分钟阈值。
+ - running 任务超过 10 分钟无进度更新时,页面展示“可能仍在等待外部接口或 AI 响应”的提示。
+ - 兼容 SQLite 读出的无时区 datetime,避免运行时长计算因 naive / aware datetime 混用报错。
+- 验证命令:
+ - `docker cp app/. hot-comments-tool-app-1:/app/app/`
+ - `docker cp tests/. hot-comments-tool-app-1:/app/tests/`
+ - `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py::test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning /app/tests/integration/test_routes.py::test_task_api_includes_runtime_and_stale_progress_fields -q'`
+ - `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py -q'`
+- 验证结果:
+ - WO-13 聚焦测试:`2 passed, 1 warning`
+ - 路由集成测试:`21 passed, 1 warning`
+- 验收结论:
+ - 用户无需打开日志即可看到任务当前阶段、运行时长、最近进度、热点 / 评论 / 报告数量和长时间无更新提示。
+ - 本轮不显示精确预计剩余时间,只显示非承诺式耗时说明,避免误导。
+- 遗留问题:
+ - 轮询接口失败时的前端可见同步失败提示仍归入 WO-19。
+ - 当前仍通过运行容器执行测试,本机 `.venv` 依赖未恢复。
+
### WO-14 Demo 数据保留与新任务并存体验
优先级:P0
diff --git a/tests/integration/test_routes.py b/tests/integration/test_routes.py
index fee3dfe..b9c1a29 100644
--- a/tests/integration/test_routes.py
+++ b/tests/integration/test_routes.py
@@ -1,3 +1,5 @@
+from datetime import UTC, datetime, timedelta
+
from app.models import Comment, ContentItem, Hotspot, Report, Task
from tests.helpers import make_test_client
from sqlalchemy.exc import OperationalError
@@ -150,6 +152,74 @@ def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
assert "pollTaskDetailStatus" in response.text
+def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning(monkeypatch):
+ now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
+ monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
+
+ with make_test_client() as (client, engine):
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ task = Task(
+ id="task-stale",
+ platform="xiaohongshu",
+ status="running",
+ current_stage="ai_analysis",
+ created_at=now - timedelta(hours=2, minutes=5),
+ last_progress_at=now - timedelta(minutes=12),
+ hotspot_limit=5,
+ item_limit_per_hotspot=5,
+ comment_limit_per_item=50,
+ total_items_count=10,
+ processed_items_count=6,
+ successful_items_count=6,
+ failed_items_count=0,
+ analysis_success_rate=0.6,
+ analysis_status="insufficient",
+ )
+ session.add(task)
+ hotspot = Hotspot(id="hot-stale", task_id=task.id, platform="xiaohongshu", title="运行热点", rank=1, raw_data="{}")
+ session.add(hotspot)
+ item = ContentItem(
+ id="item-stale",
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ platform="xiaohongshu",
+ source_item_id="note-stale",
+ item_type="note",
+ title="运行内容",
+ status="pending",
+ raw_data="{}",
+ )
+ session.add(item)
+ for index in range(3):
+ session.add(
+ Comment(
+ task_id=task.id,
+ hotspot_id=hotspot.id,
+ content_item_id=item.id,
+ platform="xiaohongshu",
+ source_comment_id=f"c{index}",
+ content=f"评论 {index}",
+ raw_data="{}",
+ )
+ )
+ session.commit()
+
+ response = client.get("/tasks/task-stale")
+
+ assert response.status_code == 200
+ assert "阶段:AI 分析中" in response.text
+ assert "运行时长" in response.text
+ assert "2小时5分钟" in response.text
+ assert "最近进度" in response.text
+ assert "12分钟前" in response.text
+ assert "已获取热点 1 / 目标 5" in response.text
+ assert "评论 3" in response.text
+ assert "超过 10 分钟没有进度更新" in response.text
+ assert "可能仍在等待外部接口或 AI 响应" in response.text
+
+
def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
@@ -511,3 +581,36 @@ def test_task_detail_page_explains_target_and_actual_counts():
assert "实际结果" in response.text
assert "实际评论 1" in response.text
assert "少于理论上限通常是内容本身评论不足或平台返回不足" in response.text
+
+
+def test_task_api_includes_runtime_and_stale_progress_fields(monkeypatch):
+ now = datetime(2026, 7, 3, 10, 30, tzinfo=UTC)
+ monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
+
+ with make_test_client() as (client, engine):
+ from sqlalchemy.orm import Session
+
+ with Session(engine) as session:
+ session.add(
+ Task(
+ id="task-api-stale",
+ platform="douyin",
+ status="running",
+ current_stage="crawl_comments",
+ created_at=now - timedelta(minutes=45),
+ last_progress_at=now - timedelta(minutes=11),
+ )
+ )
+ session.commit()
+
+ response = client.get("/api/tasks/task-api-stale")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["current_stage_label"] == "抓取评论中"
+ assert data["running_seconds"] == 2700
+ assert data["seconds_since_last_progress"] == 660
+ assert data["running_duration_label"] == "45分钟"
+ assert data["last_progress_ago_label"] == "11分钟前"
+ assert data["is_progress_stale"] is True
+ assert data["stale_threshold_minutes"] == 10