diff --git a/web/app.py b/web/app.py index 83b55d9..747487c 100644 --- a/web/app.py +++ b/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") diff --git a/web/static/app.js b/web/static/app.js index e38726e..90a58c4 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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 = '
' + + 'Date: ' + data.date + '' + + 'Images: ' + data.total_images + '' + + 'Events: ' + data.total_events + '' + + '
'; + + // Person count timeline chart (using simple CSS bars) + var timeline = data.timeline; + var chartCanvas = document.getElementById('person-chart'); + + if (timeline.length === 0) { + chartCanvas.innerHTML = '

No data

'; + return; + } + + // Build simple bar chart + var chartHtml = '
'; + + 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 += '
' + + '
' + + '
' + timeLabel + '
' + + '
' + count + '
' + + '
'; + }); + + chartHtml += '
'; + chartCanvas.innerHTML = chartHtml; + + // Event types distribution + var eventTypesDiv = document.getElementById('event-types-chart'); + eventTypesDiv.innerHTML = ''; + + if (data.event_types.length === 0) { + eventTypesDiv.innerHTML = '

No events

'; + 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 = '
' + + '' + et.type + '' + + '
' + + '' + et.count + '' + + '
'; + 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 = '

No history data

'; + return; + } + + var maxImages = Math.max.apply(Math, data.history.map(function(h) { return h.total_images })); + maxImages = Math.max(maxImages, 1); + + var html = '
'; + + data.history.reverse().forEach(function(h) { + var dateLabel = h.date.slice(5); // MM-DD + var height = (h.total_images / maxImages) * 100; + + html += '
' + + '
' + + '
' + dateLabel + '
' + + '
' + h.total_images + '
' + + '
'; + }); + + html += '
'; + historyDiv.innerHTML = html; +} + // Persons Management function openPersonsModal() { loadPersonsList(); diff --git a/web/static/index.html b/web/static/index.html index 11bdd55..5e3c13a 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -34,6 +34,11 @@ +
+

📊 统计图表

+ +
+

⚙️ 系统设置

@@ -235,6 +240,45 @@
+ + +