diff --git a/backend/app/routes/admin_routes.py b/backend/app/routes/admin_routes.py
index 8c0dc97..3238dbc 100644
--- a/backend/app/routes/admin_routes.py
+++ b/backend/app/routes/admin_routes.py
@@ -147,6 +147,114 @@ def stats():
)
+@admin_bp.get("/stats/report")
+@admin_required
+def generate_report():
+ """生成运营报告:垃圾信息变化、风险词排名、误判率趋势"""
+ now = datetime.utcnow()
+ week_ago = now - timedelta(days=13) # 近14天
+
+ # 1. 垃圾信息数量变化(近14天)
+ blocked_trend_rows = (
+ db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
+ .filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
+ .group_by(func.date(ContentPost.created_at))
+ .all()
+ )
+ published_trend_rows = (
+ db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
+ .filter(ContentPost.created_at >= week_ago, ContentPost.status == "published")
+ .group_by(func.date(ContentPost.created_at))
+ .all()
+ )
+
+ blocked_map = {_day_key(day): int(count or 0) for day, count in blocked_trend_rows}
+ published_map = {_day_key(day): int(count or 0) for day, count in published_trend_rows}
+
+ spam_trend = []
+ today = now.date()
+ for offset in range(13, -1, -1):
+ day = today - timedelta(days=offset)
+ key = day.isoformat()
+ spam_trend.append({
+ "date": key,
+ "label": day.strftime("%m-%d"),
+ "blocked": blocked_map.get(key, 0),
+ "published": published_map.get(key, 0),
+ "total": blocked_map.get(key, 0) + published_map.get(key, 0)
+ })
+
+ # 2. 高频风险词排名(近14天)
+ blocked_logs = (
+ ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
+ .order_by(ContentPost.id.desc())
+ .limit(1000)
+ .all()
+ )
+ token_counter = Counter()
+ for row in blocked_logs:
+ token_counter.update(_tokenize(row.text))
+ top_keywords = [{"token": token, "count": count} for token, count in token_counter.most_common(20)]
+
+ # 3. 误判率趋势(近14天,基于人工复核)
+ review_trend_rows = (
+ db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
+ .filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status != "none")
+ .group_by(func.date(ContentPost.manual_review_at))
+ .all()
+ )
+ approved_trend_rows = (
+ db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
+ .filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status == "approved_ham")
+ .group_by(func.date(ContentPost.manual_review_at))
+ .all()
+ )
+
+ review_map = {_day_key(day): int(count or 0) for day, count in review_trend_rows}
+ approved_map = {_day_key(day): int(count or 0) for day, count in approved_trend_rows}
+
+ misjudge_trend = []
+ for offset in range(13, -1, -1):
+ day = today - timedelta(days=offset)
+ key = day.isoformat()
+ reviewed = review_map.get(key, 0)
+ approved = approved_map.get(key, 0)
+ misjudge_rate = round(approved / reviewed, 4) if reviewed > 0 else 0
+ misjudge_trend.append({
+ "date": key,
+ "label": day.strftime("%m-%d"),
+ "reviewed": reviewed,
+ "approved": approved,
+ "misjudge_rate": misjudge_rate,
+ "misjudge_rate_text": f"{misjudge_rate * 100:.1f}%"
+ })
+
+ # 4. 汇总统计
+ total_blocked_14d = sum(blocked_map.values())
+ total_published_14d = sum(published_map.values())
+ total_reviews_14d = sum(review_map.values())
+ total_approved_14d = sum(approved_map.values())
+ avg_misjudge_rate = round(total_approved_14d / total_reviews_14d, 4) if total_reviews_14d > 0 else 0
+
+ return ok({
+ "report_date": now.isoformat(),
+ "period": "近14天",
+ "spam_trend": spam_trend,
+ "top_keywords": top_keywords,
+ "misjudge_trend": misjudge_trend,
+ "summary": {
+ "total_blocked": total_blocked_14d,
+ "total_published": total_published_14d,
+ "total_posts": total_blocked_14d + total_published_14d,
+ "blocked_ratio": round(total_blocked_14d / (total_blocked_14d + total_published_14d), 4) if (total_blocked_14d + total_published_14d) > 0 else 0,
+ "total_reviews": total_reviews_14d,
+ "total_approved": total_approved_14d,
+ "avg_misjudge_rate": avg_misjudge_rate,
+ "avg_misjudge_rate_text": f"{avg_misjudge_rate * 100:.1f}%"
+ }
+ })
+
+
@admin_bp.get("/detection/threshold")
@admin_required
def get_threshold():
diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss
index a9f2acd..8a89b22 100644
--- a/miniprogram/app.wxss
+++ b/miniprogram/app.wxss
@@ -486,6 +486,84 @@ button.btn::after {
color: #f2f7ff;
}
+/* 运营报告样式 */
+.report-modal {
+ margin-top: 20rpx;
+ padding: 24rpx;
+ border-radius: 24rpx;
+ background: linear-gradient(145deg, rgba(24, 47, 78, 0.95), rgba(16, 31, 53, 0.98));
+ border: 1rpx solid rgba(139, 177, 223, 0.35);
+ position: relative;
+}
+
+.report-header {
+ text-align: center;
+ margin-bottom: 16rpx;
+}
+
+.report-title {
+ font-size: 32rpx;
+ font-weight: 700;
+ color: #eff6ff;
+}
+
+.report-period {
+ font-size: 22rpx;
+ color: var(--sub);
+ margin-top: 6rpx;
+}
+
+.report-close {
+ position: absolute;
+ top: 16rpx;
+ right: 16rpx;
+ width: 40rpx;
+ height: 40rpx;
+ border-radius: 50%;
+ background: rgba(255, 91, 111, 0.2);
+ color: #ffdce1;
+ font-size: 28rpx;
+ text-align: center;
+ line-height: 40rpx;
+}
+
+.report-section {
+ margin-top: 16rpx;
+ padding: 16rpx;
+ border-radius: 16rpx;
+ background: rgba(17, 35, 58, 0.6);
+}
+
+.report-section-title {
+ font-size: 26rpx;
+ font-weight: 600;
+ color: #dceeff;
+ margin-bottom: 12rpx;
+}
+
+.report-kpi {
+ padding: 12rpx;
+ border-radius: 12rpx;
+ background: rgba(19, 40, 66, 0.7);
+ text-align: center;
+}
+
+.report-kpi-value {
+ font-size: 28rpx;
+ font-weight: 700;
+ color: #f2f7ff;
+}
+
+.report-kpi-label {
+ font-size: 20rpx;
+ color: var(--sub);
+ margin-top: 4rpx;
+}
+
+.report-trend-item {
+ margin-top: 8rpx;
+}
+
.chip-group {
display: flex;
flex-wrap: wrap;
diff --git a/miniprogram/pages/admin-dashboard/index.js b/miniprogram/pages/admin-dashboard/index.js
index 9d7c7d8..5dc11d6 100644
--- a/miniprogram/pages/admin-dashboard/index.js
+++ b/miniprogram/pages/admin-dashboard/index.js
@@ -7,7 +7,9 @@ Page({
kpis: [],
bars: [],
sourceDist: [],
- topKeywords: []
+ topKeywords: [],
+ report: null,
+ reportLoading: false
},
formatPercent(value, digits = 2) {
@@ -67,5 +69,74 @@ Page({
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
+ },
+
+ async generateReport() {
+ this.setData({ reportLoading: true })
+ try {
+ const report = await request({ url: '/admin/stats/report' })
+
+ // 处理趋势数据,计算进度条宽度
+ const spamTrend = (report.spam_trend || []).map((item) => {
+ const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
+ return {
+ ...item,
+ blocked_percent: `${Math.max(4, Math.round((item.blocked || 0) / maxBlocked * 100))}%`
+ }
+ })
+
+ const misjudgeTrend = (report.misjudge_trend || []).map((item) => ({
+ ...item,
+ rate_percent: `${Math.round((item.misjudge_rate || 0) * 100)}%`
+ }))
+
+ this.setData({
+ report: {
+ ...report,
+ spam_trend: spamTrend,
+ misjudge_trend: misjudgeTrend
+ }
+ })
+
+ wx.showToast({ title: '报告已生成', icon: 'success' })
+ } finally {
+ this.setData({ reportLoading: false })
+ }
+ },
+
+ closeReport() {
+ this.setData({ report: null })
+ },
+
+ copyReportText() {
+ const report = this.data.report
+ if (!report) return
+
+ const summary = report.summary || {}
+ const lines = [
+ `【垃圾信息运营报告】`,
+ `报告周期:${report.period}`,
+ `生成时间:${(report.report_date || '').replace('T', ' ').slice(0, 19)}`,
+ '',
+ `【汇总统计】`,
+ `总发布量:${summary.total_posts || 0} 条`,
+ `拦截量:${summary.total_blocked || 0} 条`,
+ `正常发布:${summary.total_published || 0} 条`,
+ `拦截率:${this.formatPercent(summary.blocked_ratio, 2)}`,
+ `复核总数:${summary.total_reviews || 0} 次`,
+ `误判放行:${summary.total_approved || 0} 次`,
+ `平均误判率:${summary.avg_misjudge_rate_text || '0%'}`,
+ '',
+ `【高频风险词 Top10】`,
+ (report.top_keywords || []).slice(0, 10).map((k) => `${k.token}(${k.count}次)`).join('、'),
+ '',
+ `【近7日趋势】`,
+ (report.spam_trend || []).slice(-7).map((t) => `${t.label}: 拦截${t.blocked}条,发布${t.published}条`).join('\n')
+ ]
+
+ wx.setClipboardData({
+ data: lines.join('\n'),
+ success: () => wx.showToast({ title: '报告已复制', icon: 'success' })
+ })
}
})
diff --git a/miniprogram/pages/admin-dashboard/index.wxml b/miniprogram/pages/admin-dashboard/index.wxml
index 6dae354..5a10da0 100644
--- a/miniprogram/pages/admin-dashboard/index.wxml
+++ b/miniprogram/pages/admin-dashboard/index.wxml
@@ -54,4 +54,83 @@
{{item.token}} × {{item.count}}
+
+
+
+
+
+
+
+
+
+
+ ×
+
+
+ 汇总统计
+
+
+ {{report.summary.total_posts}}
+ 总发布量
+
+
+ {{report.summary.total_blocked}}
+ 拦截量
+
+
+ {{report.summary.total_published}}
+ 正常发布
+
+
+
+ 拦截率
+ {{report.summary.blocked_ratio * 100}}%
+
+
+ 平均误判率
+ {{report.summary.avg_misjudge_rate_text}}
+
+
+
+
+ 垃圾信息数量变化(近14天)
+
+
+ {{item.label}}
+ 拦截 {{item.blocked}} / 发布 {{item.published}}
+
+
+
+
+
+
+
+
+ 高频风险词 Top10
+
+ {{kw.token}} × {{kw.count}}
+
+
+
+
+ 误判率趋势(近14天)
+
+
+ {{item.label}}
+ {{item.misjudge_rate_text}}
+
+
+
+
+
+
+
+
+
+
+
+