merge: 合并移除导出功能分支

# Conflicts:
#	tests/integration/test_routes.py
This commit is contained in:
meijiali
2026-07-03 19:28:59 +08:00
9 changed files with 31 additions and 289 deletions
-47
View File
@@ -1,11 +1,9 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import logging
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy import select
@@ -14,7 +12,6 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal, backup_sqlite_files, get_db_session, init_db
from app.models import Comment, ContentItem, Hotspot, Report
from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse
from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown
from app.services.task_service import (
RUNNING_TASK_MESSAGE,
create_task,
@@ -146,47 +143,3 @@ def item_detail_page(item_id: str, request: Request, session: Session = Depends(
"items/detail.html",
{"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments},
)
@app.get("/api/export/items/{item_id}/comments.csv")
def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_item_comments_csv(session, item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}/comments.csv")
def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_hotspot_comments_csv(session, hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}.md")
def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
@app.get("/api/export/items/{item_id}.md")
def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="item", item_id=item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
-104
View File
@@ -1,104 +0,0 @@
import csv
import io
import json
import re
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report
def labels_to_text(labels_json: str | None) -> str:
try:
labels = json.loads(labels_json or "[]")
except json.JSONDecodeError:
return ""
return "".join(str(label) for label in labels) if isinstance(labels, list) else ""
def safe_csv_field(value) -> str:
text = "" if value is None else str(value)
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
if text.startswith(("=", "+", "-", "@")):
return f"'{text}"
return text
def safe_filename(platform: str, task_id: str, hotspot_keyword: str, *, extension: str = "csv") -> str:
keyword = hotspot_keyword[:20] + ("..." if len(hotspot_keyword) > 20 else "")
name = f"{platform}_{task_id}_{keyword}"
name = re.sub(r'[/\\:*?"<>|]+', "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return f"{name}.{extension}"
def export_item_comments_csv(session: Session, item_id: str) -> tuple[bytes, str]:
item = session.get(ContentItem, item_id)
if item is None:
raise ValueError("content item not found")
hotspot = session.get(Hotspot, item.hotspot_id)
comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
writer.writerow(
[
item.platform,
item.task_id,
hotspot.id if hotspot else "",
hotspot.title if hotspot else "",
item.id,
item.title or "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
filename = safe_filename(item.platform, item.task_id, hotspot.title if hotspot else item.title or "comments")
return output.getvalue().encode("utf-8-sig"), filename
def export_hotspot_comments_csv(session: Session, hotspot_id: str) -> tuple[bytes, str]:
hotspot = session.get(Hotspot, hotspot_id)
if hotspot is None:
raise ValueError("hotspot not found")
comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
item = session.get(ContentItem, comment.content_item_id)
writer.writerow(
[
comment.platform,
comment.task_id,
hotspot.id,
hotspot.title,
item.id if item else "",
item.title if item else "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
return output.getvalue().encode("utf-8-sig"), safe_filename(hotspot.platform, hotspot.task_id, hotspot.title)
def export_report_markdown(session: Session, *, report_type: str, hotspot_id: str | None = None, item_id: str | None = None) -> tuple[str, str]:
query = select(Report).where(Report.report_type == report_type)
if hotspot_id:
query = query.where(Report.hotspot_id == hotspot_id)
if item_id:
query = query.where(Report.content_item_id == item_id)
report = session.scalar(query)
if report is None:
raise ValueError("report not found")
return report.markdown_content or report.markdown, safe_filename("report", report.task_id, report.title, extension="md")
-43
View File
@@ -111,46 +111,3 @@ function pollTaskListStatus() {
};
window.setTimeout(poll, intervalMs);
}
async function downloadExport(url, defaultFilename, event) {
if (event) event.preventDefault();
const button = event ? event.currentTarget : null;
const originalText = button ? button.textContent : "";
if (button) {
button.disabled = true;
button.textContent = "下载中...";
}
try {
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
let message = "导出失败,请稍后重试。";
try {
const data = await resp.json();
if (data.detail) message = data.detail;
} catch (_error) {
message = resp.status === 503 ? "数据库暂时不可用,请稍后重试。" : message;
}
alert(message);
return;
}
const blob = await resp.blob();
const disposition = resp.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
const filename = match ? decodeURIComponent(match[1]) : defaultFilename;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (_error) {
alert("网络异常,导出未完成。");
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
-14
View File
@@ -10,19 +10,6 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary"
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}.md', 'hotspot-report.md', event)"
{% if not report %}disabled title="报告尚未生成"{% endif %}>
导出 Markdown 报告
</button>
<button
class="btn btn-outline-secondary"
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}/comments.csv', 'hotspot-comments.csv', event)">
导出热点评论 CSV
</button>
</div>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
@@ -30,7 +17,6 @@
<div class="text-center text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
</div>
{% endif %}
{% endblock %}
-14
View File
@@ -11,19 +11,6 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary"
onclick="downloadExport('/api/export/items/{{ item.id }}.md', 'item-report.md', event)"
{% if not report %}disabled title="报告尚未生成"{% endif %}>
导出 Markdown 报告
</button>
<button
class="btn btn-outline-secondary"
onclick="downloadExport('/api/export/items/{{ item.id }}/comments.csv', 'item-comments.csv', event)">
导出评论 CSV
</button>
</div>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
@@ -31,7 +18,6 @@
<div class="text-center text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
</div>
{% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">