feat: 统计图表功能 - 人数时间线曲线图、事件类型分布、历史趋势

This commit is contained in:
2026-04-17 11:34:10 +08:00
parent 7dea6fa38f
commit a7e005bc65
4 changed files with 415 additions and 0 deletions

View File

@@ -194,6 +194,127 @@ async def rename_person(person_id: str, name: str):
raise HTTPException(status_code=404, detail="人员不存在") 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 ============== # ============== 图片 API ==============
@app.get("/api/images") @app.get("/api/images")

View File

@@ -436,6 +436,136 @@ function closeSettingsModal() {
document.getElementById('settings-modal').classList.remove('active'); 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 // Persons Management
function openPersonsModal() { function openPersonsModal() {
loadPersonsList(); loadPersonsList();

View File

@@ -34,6 +34,11 @@
<button onclick="openPersonsModal()" class="btn-settings">查看人员库</button> <button onclick="openPersonsModal()" class="btn-settings">查看人员库</button>
</div> </div>
<div class="panel-section">
<h3>📊 统计图表</h3>
<button onclick="openStatsModal()" class="btn-settings">查看统计</button>
</div>
<div class="panel-section"> <div class="panel-section">
<h3>⚙️ 系统设置</h3> <h3>⚙️ 系统设置</h3>
<button onclick="openSettingsModal()" class="btn-settings">🔧 设置参数</button> <button onclick="openSettingsModal()" class="btn-settings">🔧 设置参数</button>
@@ -235,6 +240,45 @@
</div> </div>
</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" id="persons-modal" onclick="closeModalOnBackground(event, 'persons-modal')">
<div class="modal-content settings-modal-content" onclick="event.stopPropagation()"> <div class="modal-content settings-modal-content" onclick="event.stopPropagation()">

View File

@@ -666,6 +666,126 @@ button {
gap: 8px; 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) { @media (max-width: 768px) {
.control-panel { .control-panel {