diff --git a/app/static/app.js b/app/static/app.js index 97f6824..1903e0e 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -91,6 +91,27 @@ function pollTaskDetailStatus(taskId) { window.setTimeout(poll, intervalMs); } +function pollTaskListStatus() { + const tableBody = document.querySelector("[data-task-list-auto-poll='true']"); + if (!tableBody) return; + + const intervalMs = 5000; + const poll = async () => { + try { + const resp = await fetch("/api/tasks", { cache: "no-store" }); + if (!resp.ok) return; + const tasks = await resp.json(); + const hasRunningTask = tasks.some((task) => task.status === "running"); + window.location.reload(); + if (!hasRunningTask) return; + } catch (_error) { + return; + } + window.setTimeout(poll, intervalMs); + }; + window.setTimeout(poll, intervalMs); +} + async function downloadExport(url, defaultFilename, event) { if (event) event.preventDefault(); const resp = await fetch(url); diff --git a/app/templates/index.html b/app/templates/index.html index ac5485e..488e3ab 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %} {% block title %}任务列表 - 热榜评论分析工具{% endblock %} {% block content %}
@@ -57,10 +58,13 @@ 操作 - + {% include "partials/task_rows.html" %}
+{% if has_running_tasks %} + +{% endif %} {% endblock %} diff --git a/app/templates/partials/task_rows.html b/app/templates/partials/task_rows.html index 513bdd9..d6b3c2c 100644 --- a/app/templates/partials/task_rows.html +++ b/app/templates/partials/task_rows.html @@ -1,16 +1,36 @@ {% for task in tasks %} {% set status_label, status_class = status_badge_config(task.status) %} + {% set percent = progress_percent(task.processed_items_count, task.total_items_count) %} + {% set ai_percent = rate_percent(task.analysis_success_rate) %} {{ task.id }} {{ task.platform | platform_label }} {{ task.created_at }} 热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }} - {{ task.successful_items_count }} / {{ task.total_items_count }} + +
+ 已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容 + {{ percent }}% +
+
+
+
+
成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}
+
+ AI 成功率 {{ ai_percent }}% + {% if task.analysis_status == "insufficient" %} + AI 样本不足 + {% endif %} +
+ {{ status_label }} {% if task.error_stage or task.error_type %}
{{ task.error_stage }} / {{ task.error_type }} {% endif %} + {% if task.error_message %} +
{{ task.error_message }} + {% endif %} 查看 diff --git a/app/templates/tasks/detail.html b/app/templates/tasks/detail.html index 65ea078..d983473 100644 --- a/app/templates/tasks/detail.html +++ b/app/templates/tasks/detail.html @@ -13,13 +13,29 @@
{% set label, cls = status_badge_config(task.status) %} + {% set percent = progress_percent(task.processed_items_count, task.total_items_count) %} + {% set ai_percent = rate_percent(task.analysis_success_rate) %}

平台:{{ task.platform | platform_label }} {{ label }}

-

成功 {{ task.successful_items_count }} / 共 {{ task.total_items_count }} 条内容

+
+
+ 已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容 + {{ percent }}% +
+
+
+
+
成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}
+
+
+ AI 成功率 {{ ai_percent }}% + {% if task.analysis_status == "insufficient" %} + AI 样本不足 + {% endif %} +
{% if task.error_message %}
{{ task.error_stage }} / {{ task.error_type }}:{{ task.error_message }}
{% endif %}
{% if task.status == "running" and not hotspots %}

正在抓取热点数据,请稍候...

- {% endif %}
{% for hotspot in hotspots %} @@ -45,4 +61,7 @@
{% endfor %} +{% if task.status == "running" %} + +{% endif %} {% endblock %} diff --git a/app/templating.py b/app/templating.py index 64f711a..3e3570a 100644 --- a/app/templating.py +++ b/app/templating.py @@ -28,6 +28,20 @@ def from_json_filter(value: str | None) -> list: return parsed if isinstance(parsed, list) else [] +def progress_percent(processed_count: int, total_count: int) -> int: + if total_count <= 0: + return 0 + return min(100, max(0, round((processed_count / total_count) * 100))) + + +def rate_percent(rate: float | None) -> int: + if rate is None: + return 0 + return min(100, max(0, round(rate * 100))) + + templates.env.globals["status_badge_config"] = status_badge_config +templates.env.globals["progress_percent"] = progress_percent +templates.env.globals["rate_percent"] = rate_percent templates.env.filters["platform_label"] = platform_label templates.env.filters["from_json"] = from_json_filter diff --git a/tests/integration/test_routes.py b/tests/integration/test_routes.py index d92c652..01f63ee 100644 --- a/tests/integration/test_routes.py +++ b/tests/integration/test_routes.py @@ -30,6 +30,12 @@ def test_index_page_lists_existing_tasks(): hotspot_limit=3, item_limit_per_hotspot=4, comment_limit_per_item=50, + total_items_count=4, + processed_items_count=2, + successful_items_count=1, + failed_items_count=1, + analysis_success_rate=0.75, + analysis_status="insufficient", ) ) session.commit() @@ -40,6 +46,40 @@ def test_index_page_lists_existing_tasks(): assert "task-visible" in response.text assert "抖音" in response.text assert "运行中" in response.text + assert "已处理 2 / 共 4 条内容" in response.text + assert "成功 1 / 失败 1" in response.text + assert 'style="width: 50%"' in response.text + assert "AI 成功率 75%" in response.text + assert "AI 样本不足" in response.text + assert 'data-task-list-auto-poll="true"' in response.text + assert "pollTaskListStatus" in response.text + assert "DOMContentLoaded" in response.text + + +def test_index_page_does_not_auto_poll_without_running_tasks(): + with make_test_client() as (client, engine): + from sqlalchemy.orm import Session + + with Session(engine) as session: + session.add( + Task( + id="task-done", + platform="xiaohongshu", + status="success", + total_items_count=1, + processed_items_count=1, + successful_items_count=1, + analysis_success_rate=1.0, + ) + ) + session.commit() + + response = client.get("/") + + assert response.status_code == 200 + assert "task-done" in response.text + assert 'data-task-list-auto-poll="true"' not in response.text + assert "pollTaskListStatus" not in response.text def test_running_task_detail_page_auto_polls_current_task(): @@ -55,6 +95,11 @@ def test_running_task_detail_page_auto_polls_current_task(): hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10, + total_items_count=2, + processed_items_count=1, + successful_items_count=1, + failed_items_count=0, + analysis_success_rate=1.0, ) ) session.commit() @@ -65,6 +110,69 @@ def test_running_task_detail_page_auto_polls_current_task(): assert 'data-task-id="task-running"' in response.text assert "pollTaskDetailStatus" in response.text assert 'onclick="window.location.reload()"' in response.text + assert "已处理 1 / 共 2 条内容" in response.text + assert "AI 成功率 100%" in response.text + + +def test_running_task_detail_page_keeps_polling_after_hotspots_exist(): + with make_test_client() as (client, engine): + from sqlalchemy.orm import Session + + with Session(engine) as session: + task = Task( + id="task-running-with-hotspot", + platform="xiaohongshu", + status="running", + total_items_count=2, + processed_items_count=1, + successful_items_count=1, + analysis_success_rate=1.0, + ) + session.add(task) + session.add( + Hotspot( + id="hot-running", + task_id=task.id, + platform="xiaohongshu", + title="运行中的热点", + rank=1, + raw_data="{}", + ) + ) + session.commit() + + response = client.get("/tasks/task-running-with-hotspot") + + assert response.status_code == 200 + assert "运行中的热点" in response.text + assert "正在抓取热点数据" not in response.text + assert "pollTaskDetailStatus" 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 + + with Session(engine) as session: + session.add( + Task( + id="task-failed", + platform="douyin", + status="failed", + error_stage="recover", + error_type="interrupted", + error_message="系统重启,任务被中断", + ) + ) + session.commit() + + response = client.get("/tasks/task-failed") + + assert response.status_code == 200 + assert "失败" in response.text + assert "recover / interrupted:系统重启,任务被中断" in response.text + assert "正在抓取热点数据" not in response.text + assert "pollTaskDetailStatus" not in response.text def seed_result_data(engine): diff --git a/tests/unit/test_template_filters.py b/tests/unit/test_template_filters.py index 6d52973..b12cca6 100644 --- a/tests/unit/test_template_filters.py +++ b/tests/unit/test_template_filters.py @@ -1,4 +1,4 @@ -from app.templating import from_json_filter, status_badge_config +from app.templating import from_json_filter, progress_percent, rate_percent, status_badge_config def test_status_badge_config_centralizes_task_status_copy(): @@ -11,3 +11,15 @@ def test_status_badge_config_centralizes_task_status_copy(): def test_from_json_filter_returns_empty_list_for_invalid_json(): assert from_json_filter('["认可"]') == ["认可"] assert from_json_filter("bad-json") == [] + + +def test_progress_percent_clamps_invalid_and_overflow_values(): + assert progress_percent(0, 0) == 0 + assert progress_percent(1, 2) == 50 + assert progress_percent(5, 2) == 100 + + +def test_rate_percent_formats_ai_success_rate(): + assert rate_percent(None) == 0 + assert rate_percent(0.754) == 75 + assert rate_percent(2.0) == 100