feat: add real detail views for stats pages
This commit is contained in:
@@ -76,6 +76,37 @@ def api_group_stats():
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@stats_bp.route('/api/user_stats/<path:user_id>')
|
||||
@login_required
|
||||
def api_user_stats_detail(user_id):
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
days = request.args.get('days', 30, type=int)
|
||||
summary = server.stats_db.get_user_plugin_summary(user_id, days)
|
||||
plugin_stats = server.stats_db.get_user_plugin_stats(user_id, days, limit=10)
|
||||
success_rate = round(
|
||||
(summary["success_calls"] / summary["total_calls"] * 100),
|
||||
2
|
||||
) if summary["total_calls"] else 0.0
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"user_id": user_id,
|
||||
"user_name": server.contact_manager.get_nickname(user_id) or user_id,
|
||||
"days": days,
|
||||
"summary": {
|
||||
**summary,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"plugin_stats": plugin_stats,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户统计详情失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@stats_bp.route('/api/plugin_stats')
|
||||
@login_required
|
||||
def api_plugin_stats():
|
||||
|
||||
@@ -35,6 +35,56 @@
|
||||
<el-table-column label="操作" width="120" align="center"><template slot-scope="scope"><el-button size="mini" type="primary" plain @click="viewGroupDetail(scope.row)">查看详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog title="群组详情" :visible.sync="detailDialogVisible" width="76%" top="5vh">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detailData">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="群组">{% raw %}{{ detailData.group_name || detailData.group_id }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="群ID">{% raw %}{{ detailData.group_id }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="健康度">{% raw %}{{ detailData.health_score }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="启用功能数">{% raw %}{{ detailData.permissions.enabled_count }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="近30天消息">{% raw %}{{ detailData.overview.message_count_30d }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="近30天插件调用">{% raw %}{{ detailData.overview.plugin_call_count_30d }}{% endraw %}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">启用功能</div>
|
||||
<div class="detail-chip-list">
|
||||
<el-tag v-for="item in detailData.permissions.enabled_features" :key="item" size="small" effect="plain">
|
||||
{% raw %}{{ item }}{% endraw %}
|
||||
</el-tag>
|
||||
<span v-if="!detailData.permissions.enabled_features.length" class="empty-inline">暂无启用功能</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">运营建议</div>
|
||||
<el-alert
|
||||
v-for="(item, index) in detailData.operation_suggestions"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:description="item.desc"
|
||||
:type="item.level"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="suggestion-item">
|
||||
</el-alert>
|
||||
<div v-if="!detailData.operation_suggestions.length" class="empty-inline">当前暂无额外运营建议</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">插件调用 Top</div>
|
||||
<el-table :data="detailData.plugin_summary.top_plugins" size="mini">
|
||||
<el-table-column prop="plugin_name" label="插件"></el-table-column>
|
||||
<el-table-column prop="command" label="命令" min-width="120"></el-table-column>
|
||||
<el-table-column prop="total_calls" label="调用" width="90"></el-table-column>
|
||||
<el-table-column prop="unique_users" label="用户数" width="90"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -42,7 +92,7 @@
|
||||
<script>
|
||||
new Vue({
|
||||
el:'#app', mixins:[baseApp],
|
||||
data(){ return { groupStats:[] } },
|
||||
data(){ return { groupStats:[], detailDialogVisible:false, detailLoading:false, detailData:null } },
|
||||
computed:{
|
||||
totalCalls(){ return this.groupStats.reduce((s,i)=>s+(parseInt(i.total_calls)||0),0); },
|
||||
averageSuccessRate(){ if(!this.groupStats.length) return '0.00'; const sum=this.groupStats.reduce((s,i)=>s+(i.total_calls?((i.success_calls/i.total_calls)*100):0),0); return (sum/this.groupStats.length).toFixed(2); }
|
||||
@@ -51,11 +101,28 @@ new Vue({
|
||||
methods:{
|
||||
loadData(){ this.loadGroupStats(parseInt(this.timeRange)); },
|
||||
loadGroupStats(days){ axios.get(`/api/group_stats?days=${days}&limit=20`).then(r=>{ if(r.data.success) this.groupStats=r.data.data||[]; }).catch(e=>{ console.error('加载群组统计数据出错:',e); this.$message.error('加载群组统计数据出错'); }); },
|
||||
viewGroupDetail(){ this.$message.info('群组详情功能开发中'); }
|
||||
viewGroupDetail(group){
|
||||
if(!group || !group.group_id) return;
|
||||
this.detailDialogVisible = true;
|
||||
this.detailLoading = true;
|
||||
this.detailData = null;
|
||||
axios.get(`/robot/api/group/${encodeURIComponent(group.group_id)}/detail`).then(r=>{
|
||||
if(r.data.success){
|
||||
this.detailData = r.data.data || null;
|
||||
}else{
|
||||
this.$message.error(r.data.error || '加载群组详情失败');
|
||||
}
|
||||
}).catch(e=>{
|
||||
console.error('加载群组详情出错:',e);
|
||||
this.$message.error('加载群组详情出错');
|
||||
}).finally(()=>{
|
||||
this.detailLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}.page-hero{padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}.page-hero-copy p{color:#64748b;font-size:14px}.overview-grid .el-col{margin-bottom:16px}.overview-card{min-height:112px}.overview-card--primary{background:linear-gradient(180deg, rgba(79,70,229,.10), rgba(255,255,255,.94)) !important}.overview-card--soft{background:linear-gradient(180deg, rgba(59,130,246,.08), rgba(255,255,255,.94)) !important}.overview-label{font-size:13px;color:#64748b;margin-bottom:14px}.overview-value{font-size:30px;font-weight:700;color:#0f172a;margin-bottom:10px}.overview-note{font-size:12px;color:#94a3b8}.workspace-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.workspace-header h3{font-size:18px;margin-bottom:4px}.workspace-header p{font-size:13px;color:#64748b}.entity-cell{display:flex;align-items:center;gap:12px}.entity-badge{width:30px;height:30px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:rgba(79,70,229,.10);color:#4f46e5;font-size:12px;font-weight:700;flex-shrink:0}.entity-title{font-size:14px;font-weight:600;color:#0f172a}.entity-subtitle{margin-top:4px;font-size:12px;color:#94a3b8}
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}.page-hero{padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}.page-hero-copy p{color:#64748b;font-size:14px}.overview-grid .el-col{margin-bottom:16px}.overview-card{min-height:112px}.overview-card--primary{background:linear-gradient(180deg, rgba(79,70,229,.10), rgba(255,255,255,.94)) !important}.overview-card--soft{background:linear-gradient(180deg, rgba(59,130,246,.08), rgba(255,255,255,.94)) !important}.overview-label{font-size:13px;color:#64748b;margin-bottom:14px}.overview-value{font-size:30px;font-weight:700;color:#0f172a;margin-bottom:10px}.overview-note{font-size:12px;color:#94a3b8}.workspace-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.workspace-header h3{font-size:18px;margin-bottom:4px}.workspace-header p{font-size:13px;color:#64748b}.entity-cell{display:flex;align-items:center;gap:12px}.entity-badge{width:30px;height:30px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:rgba(79,70,229,.10);color:#4f46e5;font-size:12px;font-weight:700;flex-shrink:0}.entity-title{font-size:14px;font-weight:600;color:#0f172a}.entity-subtitle{margin-top:4px;font-size:12px;color:#94a3b8}.detail-section{margin-top:18px}.section-title{font-size:15px;font-weight:700;color:#0f172a;margin-bottom:10px}.detail-chip-list{display:flex;gap:8px;flex-wrap:wrap}.suggestion-item{margin-bottom:10px}.empty-inline{font-size:13px;color:#94a3b8}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,14 +21,42 @@
|
||||
<el-table-column label="操作" width="120" align="center"><template slot-scope="scope"><el-button size="mini" type="primary" plain @click="viewUserDetail(scope.row)">查看详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog title="用户详情" :visible.sync="detailDialogVisible" width="72%" top="6vh">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detailData">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户">{% raw %}{{ detailData.user_name || detailData.user_id }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{% raw %}{{ detailData.user_id }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="近30天调用">{% raw %}{{ detailData.summary.total_calls }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="成功率">{% raw %}{{ detailData.summary.success_rate }}{% endraw %}%</el-descriptions-item>
|
||||
<el-descriptions-item label="成功次数">{% raw %}{{ detailData.summary.success_calls }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="失败次数">{% raw %}{{ detailData.summary.failed_calls }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用插件数">{% raw %}{{ detailData.summary.plugin_count }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近活跃">{% raw %}{{ detailData.summary.last_used_at || '暂无' }}{% endraw %}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">插件使用 Top</div>
|
||||
<el-table :data="detailData.plugin_stats" size="mini">
|
||||
<el-table-column prop="plugin_name" label="插件"></el-table-column>
|
||||
<el-table-column prop="command" label="命令" min-width="120"></el-table-column>
|
||||
<el-table-column prop="total_calls" label="调用" width="90"></el-table-column>
|
||||
<el-table-column prop="success_calls" label="成功" width="90"></el-table-column>
|
||||
<el-table-column prop="failed_calls" label="失败" width="90"></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({ el:'#app', mixins:[baseApp], data(){return{userStats:[]}}, computed:{ totalCalls(){return this.userStats.reduce((s,i)=>s+(parseInt(i.total_calls)||0),0)}, averageSuccessRate(){ if(!this.userStats.length) return '0.00'; const sum=this.userStats.reduce((s,i)=>s+(i.total_calls?((i.success_calls/i.total_calls)*100):0),0); return (sum/this.userStats.length).toFixed(2)} }, mounted(){ this.currentView='3'; this.loadData(); }, methods:{ loadData(){ this.loadUserStats(parseInt(this.timeRange)); }, loadUserStats(days){ axios.get(`/api/user_stats?days=${days}&limit=20`).then(r=>{ if(r.data.success) this.userStats=r.data.data||[]; }).catch(e=>{ console.error('加载用户统计数据出错:',e); this.$message.error('加载用户统计数据出错'); }); }, viewUserDetail(){ this.$message.info('用户详情功能开发中'); } } });
|
||||
new Vue({ el:'#app', mixins:[baseApp], data(){return{userStats:[],detailDialogVisible:false,detailLoading:false,detailData:null}}, computed:{ totalCalls(){return this.userStats.reduce((s,i)=>s+(parseInt(i.total_calls)||0),0)}, averageSuccessRate(){ if(!this.userStats.length) return '0.00'; const sum=this.userStats.reduce((s,i)=>s+(i.total_calls?((i.success_calls/i.total_calls)*100):0),0); return (sum/this.userStats.length).toFixed(2)} }, mounted(){ this.currentView='3'; this.loadData(); }, methods:{ loadData(){ this.loadUserStats(parseInt(this.timeRange)); }, loadUserStats(days){ axios.get(`/api/user_stats?days=${days}&limit=20`).then(r=>{ if(r.data.success) this.userStats=r.data.data||[]; }).catch(e=>{ console.error('加载用户统计数据出错:',e); this.$message.error('加载用户统计数据出错'); }); }, viewUserDetail(user){ if(!user||!user.user_id)return; this.detailDialogVisible=true; this.detailLoading=true; this.detailData=null; axios.get(`/api/user_stats/${encodeURIComponent(user.user_id)}?days=30`).then(r=>{ if(r.data.success){ this.detailData=r.data.data||null; } else { this.$message.error(r.data.error||'加载用户详情失败'); } }).catch(e=>{ console.error('加载用户详情出错:',e); this.$message.error('加载用户详情出错'); }).finally(()=>{ this.detailLoading=false; }); } } });
|
||||
</script>
|
||||
<style>
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}.page-hero{padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}.page-hero-copy p{color:#64748b;font-size:14px}.overview-grid .el-col{margin-bottom:16px}.overview-card{min-height:112px}.overview-card--primary{background:linear-gradient(180deg, rgba(79,70,229,.10), rgba(255,255,255,.94)) !important}.overview-card--soft{background:linear-gradient(180deg, rgba(59,130,246,.08), rgba(255,255,255,.94)) !important}.overview-label{font-size:13px;color:#64748b;margin-bottom:14px}.overview-value{font-size:30px;font-weight:700;color:#0f172a;margin-bottom:10px}.overview-note{font-size:12px;color:#94a3b8}.workspace-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.workspace-header h3{font-size:18px;margin-bottom:4px}.workspace-header p{font-size:13px;color:#64748b}.entity-cell{display:flex;align-items:center;gap:12px}.entity-badge{width:30px;height:30px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:rgba(79,70,229,.10);color:#4f46e5;font-size:12px;font-weight:700;flex-shrink:0}.entity-title{font-size:14px;font-weight:600;color:#0f172a}.entity-subtitle{margin-top:4px;font-size:12px;color:#94a3b8}
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}.page-hero{padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}.page-hero-copy p{color:#64748b;font-size:14px}.overview-grid .el-col{margin-bottom:16px}.overview-card{min-height:112px}.overview-card--primary{background:linear-gradient(180deg, rgba(79,70,229,.10), rgba(255,255,255,.94)) !important}.overview-card--soft{background:linear-gradient(180deg, rgba(59,130,246,.08), rgba(255,255,255,.94)) !important}.overview-label{font-size:13px;color:#64748b;margin-bottom:14px}.overview-value{font-size:30px;font-weight:700;color:#0f172a;margin-bottom:10px}.overview-note{font-size:12px;color:#94a3b8}.workspace-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.workspace-header h3{font-size:18px;margin-bottom:4px}.workspace-header p{font-size:13px;color:#64748b}.entity-cell{display:flex;align-items:center;gap:12px}.entity-badge{width:30px;height:30px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:rgba(79,70,229,.10);color:#4f46e5;font-size:12px;font-weight:700;flex-shrink:0}.entity-title{font-size:14px;font-weight:600;color:#0f172a}.entity-subtitle{margin-top:4px;font-size:12px;color:#94a3b8}.detail-section{margin-top:18px}.section-title{font-size:15px;font-weight:700;color:#0f172a;margin-bottom:10px}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -608,3 +608,54 @@ class StatsDBOperator(BaseDBOperator):
|
||||
"plugin_count": int(result.get("plugin_count") or 0),
|
||||
"last_used_at": result.get("last_used_at") or "",
|
||||
}
|
||||
|
||||
def get_user_plugin_stats(self, user_id: str, days: int = 30, limit: int = 10) -> List[Dict]:
|
||||
"""获取指定用户的插件调用统计"""
|
||||
sql = """
|
||||
SELECT
|
||||
plugin_name,
|
||||
command,
|
||||
SUM(total_calls) AS total_calls,
|
||||
SUM(success_calls) AS success_calls,
|
||||
SUM(failed_calls) AS failed_calls,
|
||||
MIN(first_used_at) AS first_used_at,
|
||||
MAX(last_used_at) AS last_used_at
|
||||
FROM t_user_stats
|
||||
WHERE user_id = %s
|
||||
AND last_used_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
|
||||
GROUP BY plugin_name, command
|
||||
ORDER BY total_calls DESC, last_used_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
rows = self.execute_query(sql, (user_id, days, limit)) or []
|
||||
for row in rows:
|
||||
for key in ("first_used_at", "last_used_at"):
|
||||
dt = row.get(key)
|
||||
if isinstance(dt, datetime):
|
||||
row[key] = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return rows
|
||||
|
||||
def get_user_plugin_summary(self, user_id: str, days: int = 30) -> Dict:
|
||||
"""获取指定用户的插件调用摘要"""
|
||||
sql = """
|
||||
SELECT
|
||||
SUM(total_calls) AS total_calls,
|
||||
SUM(success_calls) AS success_calls,
|
||||
SUM(failed_calls) AS failed_calls,
|
||||
COUNT(DISTINCT plugin_name) AS plugin_count,
|
||||
MAX(last_used_at) AS last_used_at
|
||||
FROM t_user_stats
|
||||
WHERE user_id = %s
|
||||
AND last_used_at >= DATE_SUB(NOW(), INTERVAL %s DAY)
|
||||
"""
|
||||
result = self.execute_query(sql, (user_id, days), fetch_one=True) or {}
|
||||
dt = result.get("last_used_at")
|
||||
if isinstance(dt, datetime):
|
||||
result["last_used_at"] = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return {
|
||||
"total_calls": int(result.get("total_calls") or 0),
|
||||
"success_calls": int(result.get("success_calls") or 0),
|
||||
"failed_calls": int(result.get("failed_calls") or 0),
|
||||
"plugin_count": int(result.get("plugin_count") or 0),
|
||||
"last_used_at": result.get("last_used_at") or "",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user