feat: 打磨报告页和内容详情页

This commit is contained in:
meijiali
2026-07-03 14:45:22 +08:00
parent 82d103757b
commit eedb1db4da
6 changed files with 211 additions and 15 deletions
+5 -5
View File
@@ -16,11 +16,11 @@
</div> </div>
</div> </div>
{% if report %} {% if report %}
<section class="card"><div class="card-body"> {% include "partials/report_panel.html" %}
<p class="lead">{{ report.summary }}</p>
<pre class="bg-light p-3">{{ report.markdown_content }}</pre>
</div></section>
{% else %} {% else %}
<div class="alert alert-secondary">报告生成中,请稍候...</div> <div class="text-center text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+6 -4
View File
@@ -17,10 +17,12 @@
</div> </div>
</div> </div>
{% if report %} {% if report %}
<section class="card mb-4"><div class="card-body"> {% include "partials/report_panel.html" %}
<p class="lead">{{ report.summary }}</p> {% else %}
<pre class="bg-light p-3">{{ report.markdown_content }}</pre> <div class="text-center text-muted py-5">
</div></section> <div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
</div>
{% endif %} {% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive"> <section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
<table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody> <table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
+91
View File
@@ -0,0 +1,91 @@
{% set metrics = report.metrics_json | from_json_object %}
{% set typical = report.typical_comments_json | from_json_object %}
{% set sentiment = metrics.get("sentiment", {}) %}
{% set top_labels = metrics.get("top_labels", []) %}
{% set sample_count = metrics.get("sample_count", 0) %}
{% set item_count = metrics.get("item_count") %}
<section class="card mb-4">
<div class="card-body">
{% if task.analysis_status == "insufficient" %}
<div class="alert alert-warning">当前有效评论样本不足,AI 总结暂不可用。建议增加评论抓取数量后重新分析。</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">评论样本</div>
<div class="h4 mb-0">{{ sample_count }}</div>
</div>
</div>
{% if item_count is not none %}
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">关联内容</div>
<div class="h4 mb-0">{{ item_count }}</div>
</div>
</div>
{% endif %}
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<div class="text-muted small">AI 成功率</div>
<div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div>
</div>
</div>
</div>
<div class="p-3 bg-info-subtle border border-info-subtle rounded mb-4">
<div class="fw-semibold mb-2">AI 总结</div>
<p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p>
</div>
<div class="row g-4">
<div class="col-lg-6">
<h2 class="h5">情绪分布</h2>
{% for key, bar_class in [("positive", "bg-success"), ("neutral", "bg-secondary"), ("negative", "bg-danger"), ("unknown", "bg-warning")] %}
{% set item = sentiment.get(key, {"count": 0, "pct": 0}) %}
<div class="d-flex justify-content-between mb-1">
<span>{{ sentiment_label(key) }}</span>
<span class="text-muted">{{ item.get("count", 0) }} 条({{ item.get("pct", 0) }}%</span>
</div>
<div class="progress mb-3 report-progress" role="progressbar" aria-label="{{ sentiment_label(key) }}情绪占比" aria-valuenow="{{ item.get("pct", 0) }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {{ bar_class }}" style="width: {{ item.get("pct", 0) }}%"></div>
</div>
{% endfor %}
</div>
<div class="col-lg-6">
<h2 class="h5">Top 标签</h2>
{% if top_labels %}
<div class="d-flex flex-wrap gap-2">
{% for label in top_labels %}
<span class="badge text-bg-light border">{{ label.get("name") }} ({{ label.get("count", 0) }})</span>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">暂无标签数据</p>
{% endif %}
</div>
</div>
</div>
</section>
<section class="card mb-4">
<div class="card-header">典型评论</div>
<div class="card-body">
<div class="row g-3">
{% for key in ["positive", "neutral", "negative"] %}
<div class="col-md-4">
<h3 class="h6">{{ sentiment_label(key) }}</h3>
{% for comment in typical.get(key, []) %}
<div class="border rounded p-2 mb-2">
<p class="mb-1">{{ comment.get("content") }}</p>
<small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small>
</div>
{% else %}
<p class="text-muted small mb-0">暂无{{ sentiment_label(key) }}代表评论</p>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
</section>
+19
View File
@@ -28,6 +28,14 @@ def from_json_filter(value: str | None) -> list:
return parsed if isinstance(parsed, list) else [] return parsed if isinstance(parsed, list) else []
def from_json_object_filter(value: str | None) -> dict:
try:
parsed = json.loads(value) if value else {}
except (TypeError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def progress_percent(processed_count: int, total_count: int) -> int: def progress_percent(processed_count: int, total_count: int) -> int:
if total_count <= 0: if total_count <= 0:
return 0 return 0
@@ -40,8 +48,19 @@ def rate_percent(rate: float | None) -> int:
return min(100, max(0, round(rate * 100))) return min(100, max(0, round(rate * 100)))
def sentiment_label(sentiment: str) -> str:
return {
"positive": "正向",
"neutral": "中性",
"negative": "负向",
"unknown": "未知",
}.get(sentiment, sentiment)
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["progress_percent"] = progress_percent
templates.env.globals["rate_percent"] = rate_percent templates.env.globals["rate_percent"] = rate_percent
templates.env.globals["sentiment_label"] = sentiment_label
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
templates.env.filters["from_json_object"] = from_json_object_filter
+76 -5
View File
@@ -179,7 +179,14 @@ def seed_result_data(engine):
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
with Session(engine) as session: with Session(engine) as session:
task = Task(id="task-result", platform="douyin", status="success", total_items_count=1, successful_items_count=1) task = Task(
id="task-result",
platform="douyin",
status="success",
total_items_count=1,
successful_items_count=1,
analysis_success_rate=1.0,
)
session.add(task) session.add(task)
hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}") hotspot = Hotspot(id="hot-result", task_id=task.id, platform="douyin", title="热点标题", rank=1, raw_data="{}")
session.add(hotspot) session.add(hotspot)
@@ -216,8 +223,8 @@ def seed_result_data(engine):
hotspot_id=hotspot.id, hotspot_id=hotspot.id,
report_type="hotspot", report_type="hotspot",
title="热点标题", title="热点标题",
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}', metrics_json='{"sample_count":1,"item_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}', typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
summary="热点总结", summary="热点总结",
markdown_content="# 热点标题\n\n热点总结", markdown_content="# 热点标题\n\n热点总结",
data="{}", data="{}",
@@ -231,8 +238,8 @@ def seed_result_data(engine):
content_item_id=item.id, content_item_id=item.id,
report_type="item", report_type="item",
title="视频标题", title="视频标题",
metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100}},"top_labels":[{"name":"认可","count":1}]}', metrics_json='{"sample_count":1,"sentiment":{"positive":{"count":1,"pct":100},"neutral":{"count":0,"pct":0},"negative":{"count":0,"pct":0},"unknown":{"count":0,"pct":0}},"top_labels":[{"name":"认可","count":1}]}',
typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}]}', typical_comments_json='{"positive":[{"content":"评论内容","like_count":3}],"neutral":[],"negative":[]}',
summary="内容总结", summary="内容总结",
markdown_content="# 视频标题\n\n内容总结", markdown_content="# 视频标题\n\n内容总结",
data="{}", data="{}",
@@ -255,10 +262,74 @@ def test_result_pages_render_seeded_data():
assert 'onclick="window.location.reload()"' in task_response.text assert 'onclick="window.location.reload()"' in task_response.text
assert hotspot_response.status_code == 200 assert hotspot_response.status_code == 200
assert "热点总结" in hotspot_response.text assert "热点总结" in hotspot_response.text
assert "评论样本" in hotspot_response.text
assert "关联内容" in hotspot_response.text
assert "正向" in hotspot_response.text
assert "1 条(100%" in hotspot_response.text
assert "认可 (1)" in hotspot_response.text
assert "典型评论" in hotspot_response.text
assert "评论内容" in hotspot_response.text
assert "<pre" not in hotspot_response.text
assert item_response.status_code == 200 assert item_response.status_code == 200
assert "内容总结" in item_response.text
assert "评论样本" in item_response.text
assert "Top 标签" in item_response.text
assert "评论内容" in item_response.text assert "评论内容" in item_response.text
def test_report_pages_render_empty_state_when_report_missing():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
task = Task(id="task-no-report", platform="xiaohongshu", status="running")
session.add(task)
hotspot = Hotspot(id="hot-no-report", task_id=task.id, platform="xiaohongshu", title="缺报告热点", rank=1, raw_data="{}")
session.add(hotspot)
session.add(
ContentItem(
id="item-no-report",
task_id=task.id,
hotspot_id=hotspot.id,
platform="xiaohongshu",
source_item_id="n1",
item_type="note",
title="缺报告内容",
status="pending",
raw_data="{}",
)
)
session.commit()
hotspot_response = client.get("/hotspots/hot-no-report/report")
item_response = client.get("/items/item-no-report")
assert hotspot_response.status_code == 200
assert "报告生成中,请稍候" in hotspot_response.text
assert item_response.status_code == 200
assert "报告生成中,请稍候" in item_response.text
def test_report_pages_warn_when_ai_sample_is_insufficient():
with make_test_client() as (client, engine):
seed_result_data(engine)
from sqlalchemy.orm import Session
with Session(engine) as session:
task = session.get(Task, "task-result")
task.analysis_status = "insufficient"
task.analysis_success_rate = 0.5
session.commit()
hotspot_response = client.get("/hotspots/hot-result/report")
item_response = client.get("/items/item-result")
assert hotspot_response.status_code == 200
assert "当前有效评论样本不足" in hotspot_response.text
assert item_response.status_code == 200
assert "当前有效评论样本不足" in item_response.text
def test_export_routes_return_csv_and_markdown(): def test_export_routes_return_csv_and_markdown():
with make_test_client() as (client, engine): with make_test_client() as (client, engine):
seed_result_data(engine) seed_result_data(engine)
+14 -1
View File
@@ -1,4 +1,4 @@
from app.templating import from_json_filter, progress_percent, rate_percent, status_badge_config from app.templating import from_json_filter, from_json_object_filter, progress_percent, rate_percent, sentiment_label, status_badge_config
def test_status_badge_config_centralizes_task_status_copy(): def test_status_badge_config_centralizes_task_status_copy():
@@ -13,6 +13,12 @@ def test_from_json_filter_returns_empty_list_for_invalid_json():
assert from_json_filter("bad-json") == [] assert from_json_filter("bad-json") == []
def test_from_json_object_filter_returns_empty_dict_for_invalid_json():
assert from_json_object_filter('{"sample_count": 1}') == {"sample_count": 1}
assert from_json_object_filter("[]") == {}
assert from_json_object_filter("bad-json") == {}
def test_progress_percent_clamps_invalid_and_overflow_values(): def test_progress_percent_clamps_invalid_and_overflow_values():
assert progress_percent(0, 0) == 0 assert progress_percent(0, 0) == 0
assert progress_percent(1, 2) == 50 assert progress_percent(1, 2) == 50
@@ -23,3 +29,10 @@ def test_rate_percent_formats_ai_success_rate():
assert rate_percent(None) == 0 assert rate_percent(None) == 0
assert rate_percent(0.754) == 75 assert rate_percent(0.754) == 75
assert rate_percent(2.0) == 100 assert rate_percent(2.0) == 100
def test_sentiment_label_maps_report_copy():
assert sentiment_label("positive") == "正向"
assert sentiment_label("neutral") == "中性"
assert sentiment_label("negative") == "负向"
assert sentiment_label("unknown") == "未知"