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.period}} + + + × + + + 汇总统计 + + + {{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}} + + + + + + + + + + + +