feat: 增强任务进度透明化

This commit is contained in:
meijiali
2026-07-03 18:14:58 +08:00
parent 61bcb2ef86
commit 9244f360be
5 changed files with 191 additions and 3 deletions
+7
View File
@@ -34,6 +34,13 @@ class TaskResponse(BaseModel):
current_stage: str | None = None current_stage: str | None = None
current_stage_label: str | None = None current_stage_label: str | None = None
last_progress_at: datetime | 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 comments_count: int = 0
reports_count: int = 0 reports_count: int = 0
is_demo: bool = False is_demo: bool = False
+45
View File
@@ -2,6 +2,7 @@ import inspect
import json import json
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker 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 = "当前有正在运行的任务,请稍后再试" RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断" RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
task_executor = ThreadPoolExecutor(max_workers=1) 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: 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.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.reports_count = session.scalar(select(func.count(Report.id)).where(Report.task_id == task.id)) or 0
task.is_demo = task.id.startswith("demo-") 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": elif not stage_code and task.status == "running":
stage_code = "queued" stage_code = "queued"
task.current_stage_label = STAGE_LABELS.get(stage_code or "", stage_code or "等待启动") 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 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: def update_task_stage(task: Task, stage: str) -> None:
task.current_stage = stage task.current_stage = stage
task.last_progress_at = utc_now() task.last_progress_at = utc_now()
+13 -3
View File
@@ -42,17 +42,27 @@
<div class="metric-box"> <div class="metric-box">
<div class="text-muted small">实际结果</div> <div class="text-muted small">实际结果</div>
<strong>实际评论 {{ task.comments_count or 0 }}</strong> <strong>实际评论 {{ task.comments_count or 0 }}</strong>
<div class="text-muted small">报告 {{ task.reports_count or 0 }} / 内容 {{ task.total_items_count }}</div> <div class="text-muted small">已获取热点 {{ task.hotspots_count or 0 }} / 目标 {{ task.hotspot_limit }} · 报告 {{ task.reports_count or 0 }}</div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="metric-box"> <div class="metric-box">
<div class="text-muted small">最近进度</div> <div class="text-muted small">最近进度</div>
<strong>{{ task.last_progress_at or task.created_at }}</strong> <strong>{{ task.last_progress_ago_label or "暂无记录" }}</strong>
<div class="text-muted small">外部接口和 AI 响应较慢时,阶段可能短时间不变</div> <div class="text-muted small">运行时长 {{ task.running_duration_label or "刚刚" }} · 评论 {{ task.comments_count or 0 }}</div>
</div> </div>
</div> </div>
</div> </div>
{% if task.status == "running" %}
<div class="alert alert-secondary">
小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度。
</div>
{% endif %}
{% if task.is_progress_stale %}
<div class="alert alert-warning">
超过 {{ task.stale_threshold_minutes }} 分钟没有进度更新,可能仍在等待外部接口或 AI 响应;如果长时间不恢复,请刷新状态或查看后端日志。
</div>
{% endif %}
{% if task.status == "success" and (task.comments_count or 0) < target_comments %} {% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div> <div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %} {% endif %}
+23
View File
@@ -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 数据保留与新任务并存体验 ### WO-14 Demo 数据保留与新任务并存体验
优先级:P0 优先级:P0
+103
View File
@@ -1,3 +1,5 @@
from datetime import UTC, datetime, timedelta
from app.models import Comment, ContentItem, Hotspot, Report, Task from app.models import Comment, ContentItem, Hotspot, Report, Task
from tests.helpers import make_test_client from tests.helpers import make_test_client
from sqlalchemy.exc import OperationalError 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 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(): def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
with make_test_client() as (client, engine): with make_test_client() as (client, engine):
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -511,3 +581,36 @@ def test_task_detail_page_explains_target_and_actual_counts():
assert "实际结果" in response.text assert "实际结果" in response.text
assert "实际评论 1" in response.text assert "实际评论 1" in response.text
assert "少于理论上限通常是内容本身评论不足或平台返回不足" 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