feat: 打磨报告页和内容详情页
This commit is contained in:
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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") == "未知"
|
||||||
|
|||||||
Reference in New Issue
Block a user