feat: 统计图表功能 - 人数时间线曲线图、事件类型分布、历史趋势
This commit is contained in:
121
web/app.py
121
web/app.py
@@ -194,6 +194,127 @@ async def rename_person(person_id: str, name: str):
|
||||
raise HTTPException(status_code=404, detail="人员不存在")
|
||||
|
||||
|
||||
@app.get("/api/stats/daily")
|
||||
async def get_daily_stats(date: str = None):
|
||||
"""获取每日统计数据"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 默认昨天的数据
|
||||
if date is None:
|
||||
yesterday = datetime.now() - timedelta(days=1)
|
||||
date = yesterday.strftime('%Y-%m-%d')
|
||||
|
||||
conn = db._get_conn()
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# 获取当天所有图片
|
||||
cursor = conn.execute(
|
||||
"SELECT id, timestamp, analyzed FROM images WHERE date_folder = ? ORDER BY timestamp",
|
||||
(date,)
|
||||
)
|
||||
images = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# 获取每个时间点的人数
|
||||
timeline = []
|
||||
for img in images:
|
||||
# 获取该图片的事件
|
||||
events = db.get_events_by_image(img['id'])
|
||||
|
||||
# 计算人数
|
||||
person_count = 0
|
||||
for event in events:
|
||||
if '人物活动' in event['event_type'] or '人员进出' in event['event_type']:
|
||||
# 从描述中提取人数
|
||||
desc = event['description']
|
||||
if '共 ' in desc and ' 人' in desc:
|
||||
try:
|
||||
count_str = desc.split('共 ')[1].split(' 人')[0]
|
||||
person_count = int(count_str)
|
||||
except:
|
||||
person_count = 1
|
||||
elif '#1' in desc or '#2' in desc or '#3' in desc:
|
||||
person_count = max(person_count, 1)
|
||||
|
||||
timeline.append({
|
||||
'time': img['timestamp'],
|
||||
'image_id': img['id'],
|
||||
'person_count': person_count,
|
||||
'analyzed': img['analyzed']
|
||||
})
|
||||
|
||||
# 统计汇总
|
||||
total_images = len(images)
|
||||
total_events = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE image_id IN (SELECT id FROM images WHERE date_folder = ?)",
|
||||
(date,)
|
||||
).fetchone()[0]
|
||||
|
||||
# 事件类型统计
|
||||
cursor = conn.execute(
|
||||
"""SELECT event_type, COUNT(*) as count
|
||||
FROM events WHERE image_id IN
|
||||
(SELECT id FROM images WHERE date_folder = ?)
|
||||
GROUP BY event_type""",
|
||||
(date,)
|
||||
)
|
||||
event_types = [{'type': row[0], 'count': row[1]} for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'date': date,
|
||||
'timeline': timeline,
|
||||
'total_images': total_images,
|
||||
'total_events': total_events,
|
||||
'event_types': event_types
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/stats/history")
|
||||
async def get_history_stats(days: int = 7):
|
||||
"""获取历史统计(最近N天)"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
conn = db._get_conn()
|
||||
|
||||
history = []
|
||||
|
||||
for i in range(days):
|
||||
date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
|
||||
# 统计当天的数据
|
||||
total_images = conn.execute(
|
||||
"SELECT COUNT(*) FROM images WHERE date_folder = ?",
|
||||
(date,)
|
||||
).fetchone()[0]
|
||||
|
||||
total_events = conn.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE image_id IN (SELECT id FROM images WHERE date_folder = ?)",
|
||||
(date,)
|
||||
).fetchone()[0]
|
||||
|
||||
# 人数变化统计
|
||||
cursor = conn.execute(
|
||||
"""SELECT e.event_type, COUNT(*) as count
|
||||
FROM events e JOIN images i ON e.image_id = i.id
|
||||
WHERE i.date_folder = ? AND e.event_type LIKE '%人员进出%'
|
||||
GROUP BY e.event_type""",
|
||||
(date,)
|
||||
)
|
||||
person_changes = sum(row[1] for row in cursor.fetchall())
|
||||
|
||||
history.append({
|
||||
'date': date,
|
||||
'total_images': total_images,
|
||||
'total_events': total_events,
|
||||
'person_changes': person_changes
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return {'history': history, 'days': days}
|
||||
|
||||
|
||||
# ============== 图片 API ==============
|
||||
|
||||
@app.get("/api/images")
|
||||
|
||||
@@ -436,6 +436,136 @@ function closeSettingsModal() {
|
||||
document.getElementById('settings-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Stats Management
|
||||
function openStatsModal() {
|
||||
loadDailyStats();
|
||||
loadHistoryStats();
|
||||
document.getElementById('stats-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeStatsModal() {
|
||||
document.getElementById('stats-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
function loadDailyStats() {
|
||||
var dateSelect = document.getElementById('stats-date-select');
|
||||
var dateValue = dateSelect.value;
|
||||
|
||||
var targetDate;
|
||||
if (dateValue === 'yesterday') {
|
||||
var yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
targetDate = yesterday.toISOString().split('T')[0];
|
||||
} else {
|
||||
targetDate = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
fetch(API_BASE + '/api/stats/daily?date=' + targetDate)
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
renderDailyStats(data);
|
||||
})
|
||||
.catch(function(e) { console.error('Load stats failed:', e); });
|
||||
}
|
||||
|
||||
function renderDailyStats(data) {
|
||||
// Summary
|
||||
var summaryDiv = document.getElementById('stats-summary');
|
||||
summaryDiv.innerHTML = '<div class="stats-summary">' +
|
||||
'<span>Date: ' + data.date + '</span>' +
|
||||
'<span>Images: ' + data.total_images + '</span>' +
|
||||
'<span>Events: ' + data.total_events + '</span>' +
|
||||
'</div>';
|
||||
|
||||
// Person count timeline chart (using simple CSS bars)
|
||||
var timeline = data.timeline;
|
||||
var chartCanvas = document.getElementById('person-chart');
|
||||
|
||||
if (timeline.length === 0) {
|
||||
chartCanvas.innerHTML = '<p style="color:#888;text-align:center;">No data</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build simple bar chart
|
||||
var chartHtml = '<div class="timeline-chart">';
|
||||
|
||||
var maxCount = Math.max.apply(Math, timeline.map(function(t) { return t.person_count || 0 }));
|
||||
maxCount = Math.max(maxCount, 1);
|
||||
|
||||
timeline.slice(-20).forEach(function(t) {
|
||||
var timeLabel = new Date(t.time).toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'});
|
||||
var count = t.person_count || 0;
|
||||
var height = (count / maxCount) * 100;
|
||||
|
||||
chartHtml += '<div class="timeline-bar">' +
|
||||
'<div class="bar-fill" style="height:' + height + '%"></div>' +
|
||||
'<div class="bar-label">' + timeLabel + '</div>' +
|
||||
'<div class="bar-value">' + count + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
chartHtml += '</div>';
|
||||
chartCanvas.innerHTML = chartHtml;
|
||||
|
||||
// Event types distribution
|
||||
var eventTypesDiv = document.getElementById('event-types-chart');
|
||||
eventTypesDiv.innerHTML = '';
|
||||
|
||||
if (data.event_types.length === 0) {
|
||||
eventTypesDiv.innerHTML = '<p style="color:#888;">No events</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var maxEventCount = Math.max.apply(Math, data.event_types.map(function(e) { return e.count }));
|
||||
|
||||
data.event_types.forEach(function(et) {
|
||||
var width = (et.count / maxEventCount) * 100;
|
||||
var barHtml = '<div class="event-type-bar">' +
|
||||
'<span class="event-type-label">' + et.type + '</span>' +
|
||||
'<div class="event-type-fill" style="width:' + width + '%">' +
|
||||
'<span class="event-type-count">' + et.count + '</span>' +
|
||||
'</div></div>';
|
||||
eventTypesDiv.innerHTML += barHtml;
|
||||
});
|
||||
}
|
||||
|
||||
function loadHistoryStats() {
|
||||
fetch(API_BASE + '/api/stats/history?days=7')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
renderHistoryStats(data);
|
||||
})
|
||||
.catch(function(e) { console.error('Load history failed:', e); });
|
||||
}
|
||||
|
||||
function renderHistoryStats(data) {
|
||||
var historyDiv = document.getElementById('history-chart');
|
||||
|
||||
if (data.history.length === 0) {
|
||||
historyDiv.innerHTML = '<p style="color:#888;">No history data</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var maxImages = Math.max.apply(Math, data.history.map(function(h) { return h.total_images }));
|
||||
maxImages = Math.max(maxImages, 1);
|
||||
|
||||
var html = '<div class="history-chart">';
|
||||
|
||||
data.history.reverse().forEach(function(h) {
|
||||
var dateLabel = h.date.slice(5); // MM-DD
|
||||
var height = (h.total_images / maxImages) * 100;
|
||||
|
||||
html += '<div class="history-bar">' +
|
||||
'<div class="bar-fill images" style="height:' + height + '%"></div>' +
|
||||
'<div class="bar-label">' + dateLabel + '</div>' +
|
||||
'<div class="bar-value">' + h.total_images + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
historyDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// Persons Management
|
||||
function openPersonsModal() {
|
||||
loadPersonsList();
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
<button onclick="openPersonsModal()" class="btn-settings">查看人员库</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3>📊 统计图表</h3>
|
||||
<button onclick="openStatsModal()" class="btn-settings">查看统计</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h3>⚙️ 系统设置</h3>
|
||||
<button onclick="openSettingsModal()" class="btn-settings">🔧 设置参数</button>
|
||||
@@ -235,6 +240,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计图表模态框 -->
|
||||
<div class="modal" id="stats-modal" onclick="closeModalOnBackground(event, 'stats-modal')">
|
||||
<div class="modal-content stats-modal-content" onclick="event.stopPropagation()">
|
||||
<span class="modal-close" onclick="closeStatsModal()">×</span>
|
||||
<h2>📊 统计图表</h2>
|
||||
|
||||
<div class="stats-controls">
|
||||
<select id="stats-date-select">
|
||||
<option value="yesterday">Yesterday</option>
|
||||
<option value="today">Today</option>
|
||||
</select>
|
||||
<button onclick="loadDailyStats()" class="btn-small">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-summary-box" id="stats-summary">
|
||||
<!-- 统计摘要 -->
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h4>👥 Person Count Timeline</h4>
|
||||
<canvas id="person-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h4>📋 Event Types Distribution</h4>
|
||||
<div id="event-types-chart">
|
||||
<!-- 事件类型图表 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-container">
|
||||
<h4>📈 History (Last 7 Days)</h4>
|
||||
<div id="history-chart">
|
||||
<!-- 历史趋势 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 人员库模态框 -->
|
||||
<div class="modal" id="persons-modal" onclick="closeModalOnBackground(event, 'persons-modal')">
|
||||
<div class="modal-content settings-modal-content" onclick="event.stopPropagation()">
|
||||
|
||||
@@ -666,6 +666,126 @@ button {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 统计图表样式 */
|
||||
.stats-modal-content {
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-summary-box {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-container h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #667eea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.timeline-chart {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 150px;
|
||||
align-items: flex-end;
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.timeline-bar {
|
||||
flex: 1;
|
||||
min-width: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
background: #667eea;
|
||||
width: 80%;
|
||||
min-height: 5px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 9px;
|
||||
color: #888;
|
||||
margin-top: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 10px;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.history-chart {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
height: 120px;
|
||||
align-items: flex-end;
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.history-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-fill.images {
|
||||
background: #4CAF50;
|
||||
}
|
||||
|
||||
.event-type-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-type-label {
|
||||
min-width: 100px;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.event-type-fill {
|
||||
background: #667eea;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.event-type-count {
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.control-panel {
|
||||
|
||||
Reference in New Issue
Block a user