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="人员不存在")
|
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")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user