feat: 运营报告生成功能
- 后端新增 /admin/stats/report 接口,生成14天运营数据报告 - 报告内容:垃圾信息变化趋势、高频风险词Top10、误判率趋势 - 前端运营看板增加"生成报告"按钮,展示完整报告 - 支持复制报告文本到剪贴板 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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_bp.get("/detection/threshold")
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_threshold():
|
def get_threshold():
|
||||||
|
|||||||
@@ -486,6 +486,84 @@ button.btn::after {
|
|||||||
color: #f2f7ff;
|
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 {
|
.chip-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ Page({
|
|||||||
kpis: [],
|
kpis: [],
|
||||||
bars: [],
|
bars: [],
|
||||||
sourceDist: [],
|
sourceDist: [],
|
||||||
topKeywords: []
|
topKeywords: [],
|
||||||
|
report: null,
|
||||||
|
reportLoading: false
|
||||||
},
|
},
|
||||||
|
|
||||||
formatPercent(value, digits = 2) {
|
formatPercent(value, digits = 2) {
|
||||||
@@ -67,5 +69,74 @@ Page({
|
|||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
if (fromPullDown) wx.stopPullDownRefresh()
|
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' })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,4 +54,83 @@
|
|||||||
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 生成报告按钮 -->
|
||||||
|
<view class="card fade-up fade-up-delay-3">
|
||||||
|
<button class="btn btn-accent" loading="{{reportLoading}}" bindtap="generateReport">生成运营报告</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 报告展示区域 -->
|
||||||
|
<view class="report-modal" wx:if="{{report}}">
|
||||||
|
<view class="report-header">
|
||||||
|
<view class="report-title">垃圾信息运营报告</view>
|
||||||
|
<view class="report-period">{{report.period}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-close" bindtap="closeReport">×</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">汇总统计</view>
|
||||||
|
<view class="grid-3">
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_posts}}</view>
|
||||||
|
<view class="report-kpi-label">总发布量</view>
|
||||||
|
</view>
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_blocked}}</view>
|
||||||
|
<view class="report-kpi-label">拦截量</view>
|
||||||
|
</view>
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_published}}</view>
|
||||||
|
<view class="report-kpi-label">正常发布</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">拦截率</text>
|
||||||
|
<text class="value">{{report.summary.blocked_ratio * 100}}%</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">平均误判率</text>
|
||||||
|
<text class="value">{{report.summary.avg_misjudge_rate_text}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">垃圾信息数量变化(近14天)</view>
|
||||||
|
<view class="report-trend-item" wx:for="{{report.spam_trend}}" wx:key="date">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">{{item.label}}</text>
|
||||||
|
<text class="value">拦截 {{item.blocked}} / 发布 {{item.published}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" style="width: {{item.blocked_percent}};"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">高频风险词 Top10</view>
|
||||||
|
<view class="chip-group">
|
||||||
|
<text class="tag tag-danger" wx:for="{{report.topKeywords}}" wx:for-item="kw" wx:if="{{index < 10}}" wx:key="token">{{kw.token}} × {{kw.count}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">误判率趋势(近14天)</view>
|
||||||
|
<view class="report-trend-item" wx:for="{{report.misjudge_trend}}" wx:key="date">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">{{item.label}}</text>
|
||||||
|
<text class="value">{{item.misjudge_rate_text}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill-safe" style="width: {{item.rate_percent}};"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn-row">
|
||||||
|
<button class="btn btn-primary" bindtap="copyReportText">复制报告文本</button>
|
||||||
|
<button class="btn btn-ghost" bindtap="closeReport">关闭</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
Reference in New Issue
Block a user