feat: 优化任务状态与进度体验
This commit is contained in:
@@ -91,6 +91,27 @@ function pollTaskDetailStatus(taskId) {
|
|||||||
window.setTimeout(poll, intervalMs);
|
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) {
|
async function downloadExport(url, defaultFilename, event) {
|
||||||
if (event) event.preventDefault();
|
if (event) event.preventDefault();
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %}
|
||||||
{% block title %}任务列表 - 热榜评论分析工具{% endblock %}
|
{% block title %}任务列表 - 热榜评论分析工具{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -57,10 +58,13 @@
|
|||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="task-table-body">
|
<tbody id="task-table-body" {% if has_running_tasks %}data-task-list-auto-poll="true"{% endif %}>
|
||||||
{% include "partials/task_rows.html" %}
|
{% include "partials/task_rows.html" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% if has_running_tasks %}
|
||||||
|
<script>document.addEventListener("DOMContentLoaded", () => pollTaskListStatus());</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
{% set status_label, status_class = status_badge_config(task.status) %}
|
{% 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) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ task.id }}</code></td>
|
<td><code>{{ task.id }}</code></td>
|
||||||
<td>{{ task.platform | platform_label }}</td>
|
<td>{{ task.platform | platform_label }}</td>
|
||||||
<td>{{ task.created_at }}</td>
|
<td>{{ task.created_at }}</td>
|
||||||
<td><small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}</small></td>
|
<td><small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}</small></td>
|
||||||
<td>{{ task.successful_items_count }} / {{ task.total_items_count }}</td>
|
<td class="task-progress-cell">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
|
||||||
|
<span class="small text-muted">{{ percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress task-progress mt-1" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="progress-bar" style="width: {{ percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-1">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
|
||||||
|
<div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">
|
||||||
|
AI 成功率 {{ ai_percent }}%
|
||||||
|
{% if task.analysis_status == "insufficient" %}
|
||||||
|
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge {{ status_class }}">{{ status_label }}</span>
|
<span class="badge {{ status_class }}">{{ status_label }}</span>
|
||||||
{% if task.error_stage or task.error_type %}
|
{% if task.error_stage or task.error_type %}
|
||||||
<br><small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
|
<br><small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if task.error_message %}
|
||||||
|
<br><small class="text-danger">{{ task.error_message }}</small>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
|
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -13,13 +13,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
|
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
|
||||||
{% set label, cls = status_badge_config(task.status) %}
|
{% 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) %}
|
||||||
<p>平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span></p>
|
<p>平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span></p>
|
||||||
<p>成功 {{ task.successful_items_count }} / 共 {{ task.total_items_count }} 条内容</p>
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
|
||||||
|
<span class="text-muted">{{ percent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="progress-bar" style="width: {{ percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-2">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="{% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">AI 成功率 {{ ai_percent }}%</span>
|
||||||
|
{% if task.analysis_status == "insufficient" %}
|
||||||
|
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if task.error_message %}<div class="alert alert-danger">{{ task.error_stage }} / {{ task.error_type }}:{{ task.error_message }}</div>{% endif %}
|
{% if task.error_message %}<div class="alert alert-danger">{{ task.error_stage }} / {{ task.error_type }}:{{ task.error_message }}</div>{% endif %}
|
||||||
</div></section>
|
</div></section>
|
||||||
{% if task.status == "running" and not hotspots %}
|
{% if task.status == "running" and not hotspots %}
|
||||||
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
|
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
|
||||||
<script>pollTaskDetailStatus("{{ task.id }}");</script>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="accordion" id="hotspot-list">
|
<div class="accordion" id="hotspot-list">
|
||||||
{% for hotspot in hotspots %}
|
{% for hotspot in hotspots %}
|
||||||
@@ -45,4 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if task.status == "running" %}
|
||||||
|
<script>document.addEventListener("DOMContentLoaded", () => pollTaskDetailStatus("{{ task.id }}"));</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ def from_json_filter(value: str | None) -> list:
|
|||||||
return parsed if isinstance(parsed, list) else []
|
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["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["platform_label"] = platform_label
|
||||||
templates.env.filters["from_json"] = from_json_filter
|
templates.env.filters["from_json"] = from_json_filter
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ def test_index_page_lists_existing_tasks():
|
|||||||
hotspot_limit=3,
|
hotspot_limit=3,
|
||||||
item_limit_per_hotspot=4,
|
item_limit_per_hotspot=4,
|
||||||
comment_limit_per_item=50,
|
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()
|
session.commit()
|
||||||
@@ -40,6 +46,40 @@ def test_index_page_lists_existing_tasks():
|
|||||||
assert "task-visible" in response.text
|
assert "task-visible" in response.text
|
||||||
assert "抖音" in response.text
|
assert "抖音" 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():
|
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,
|
hotspot_limit=1,
|
||||||
item_limit_per_hotspot=1,
|
item_limit_per_hotspot=1,
|
||||||
comment_limit_per_item=10,
|
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()
|
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 'data-task-id="task-running"' in response.text
|
||||||
assert "pollTaskDetailStatus" in response.text
|
assert "pollTaskDetailStatus" in response.text
|
||||||
assert 'onclick="window.location.reload()"' 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):
|
def seed_result_data(engine):
|
||||||
|
|||||||
@@ -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():
|
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():
|
def test_from_json_filter_returns_empty_list_for_invalid_json():
|
||||||
assert from_json_filter('["认可"]') == ["认可"]
|
assert from_json_filter('["认可"]') == ["认可"]
|
||||||
assert from_json_filter("bad-json") == []
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user