feat: 视觉记录系统 v1.0.0 - 摄像头定时拍照+智能分析+Web界面

This commit is contained in:
2026-04-16 09:42:53 +08:00
commit 49785191c3
14 changed files with 1997 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
// 视觉记录系统前端逻辑
const API_BASE = '';
let currentImageId = null;
// ============== 初始化 ==============
document.addEventListener('DOMContentLoaded', () => {
refreshAll();
setInterval(refreshAll, 5000); // 每5秒刷新
});
function refreshAll() {
loadStatus();
loadImages();
loadEvents();
loadStats();
}
// ============== 状态相关 ==============
async function loadStatus() {
try {
const res = await fetch(`${API_BASE}/api/status`);
const data = await res.json();
updateStatusBar(data.scheduler);
updateStats(data.stats);
// 更新按钮状态
const startBtn = document.getElementById('start-btn');
const stopBtn = document.getElementById('stop-btn');
if (data.scheduler.running) {
startBtn.disabled = true;
stopBtn.disabled = false;
document.getElementById('scheduler-status').innerHTML =
`状态: <span style="color: #4CAF50;">✓ 运行中</span>`;
} else {
startBtn.disabled = false;
stopBtn.disabled = true;
document.getElementById('scheduler-status').innerHTML =
`状态: <span style="color: #f44336;">⏹ 已停止</span>`;
}
document.getElementById('capture-count').textContent =
`拍照次数: ${data.scheduler.capture_count}`;
// 更新输入框
document.getElementById('interval-input').value = data.scheduler.interval;
document.getElementById('auto-analyze').checked = data.scheduler.auto_analyze;
} catch (e) {
console.error('加载状态失败:', e);
}
}
function updateStatusBar(status) {
// 已在 loadStatus 中处理
}
function updateStats(stats) {
document.getElementById('total-images').textContent = stats.total_images;
document.getElementById('analyzed-images').textContent = stats.analyzed_images;
document.getElementById('total-events').textContent = stats.total_events;
// 更新事件类型筛选
const eventFilter = document.getElementById('event-filter');
const currentValue = eventFilter.value;
// 清空并重建选项
eventFilter.innerHTML = '<option value="all">全部类型</option>';
stats.event_types.forEach(item => {
const opt = document.createElement('option');
opt.value = item.type;
opt.textContent = `${item.type} (${item.count})`;
eventFilter.appendChild(opt);
});
eventFilter.value = currentValue;
}
// ============== 控制相关 ==============
async function startScheduler() {
const interval = parseInt(document.getElementById('interval-input').value);
const autoAnalyze = document.getElementById('auto-analyze').checked;
try {
const res = await fetch(`${API_BASE}/api/scheduler/start?interval=${interval}&auto_analyze=${autoAnalyze}`, {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('启动成功!');
refreshAll();
} else {
alert('启动失败: ' + data.error);
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
async function stopScheduler() {
try {
const res = await fetch(`${API_BASE}/api/scheduler/stop`, {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('已停止!');
refreshAll();
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
async function setInterval() {
const interval = parseInt(document.getElementById('interval-input').value);
if (interval < 10) {
alert('间隔不能小于10秒');
return;
}
try {
const res = await fetch(`${API_BASE}/api/scheduler/interval?interval=${interval}`, {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('间隔已更新为 ' + interval + ' 秒');
refreshAll();
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
async function captureNow() {
try {
const res = await fetch(`${API_BASE}/api/capture`, {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('拍照成功!图片ID: ' + data.image_id);
refreshAll();
} else {
alert('拍照失败: ' + data.error);
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
async function analyzeUnanalyzed() {
try {
const res = await fetch(`${API_BASE}/api/analyze/unanalyzed`, {
method: 'POST'
});
const data = await res.json();
alert(`分析了 ${data.results.length} 张图片`);
refreshAll();
} catch (e) {
alert('请求失败: ' + e.message);
}
}
// ============== 图片相关 ==============
async function loadImages() {
const filter = document.getElementById('image-filter').value;
const analyzedOnly = filter === 'analyzed';
try {
const res = await fetch(`${API_BASE}/api/images?limit=50&analyzed_only=${analyzedOnly}`);
const data = await res.json();
renderImages(data.images);
} catch (e) {
console.error('加载图片失败:', e);
}
}
function renderImages(images) {
const grid = document.getElementById('images-grid');
grid.innerHTML = '';
if (images.length === 0) {
grid.innerHTML = '<p style="color: #888;">暂无图片</p>';
return;
}
images.forEach(img => {
const card = document.createElement('div');
card.className = 'image-card';
card.onclick = () => openImageModal(img.id);
const imgEl = document.createElement('img');
imgEl.src = `/images/${img.path.split('/').pop()}`;
imgEl.alt = `图片 ${img.id}`;
const info = document.createElement('div');
info.className = 'image-card-info';
const time = new Date(img.timestamp).toLocaleString('zh-CN');
info.innerHTML = `
<div class="time">${time}</div>
<div class="${img.analyzed ? 'analyzed' : 'unanalyzed'}">
${img.analyzed ? '✓ 已分析' : '○ 未分析'}
</div>
`;
card.appendChild(imgEl);
card.appendChild(info);
grid.appendChild(card);
});
}
async function openImageModal(imageId) {
currentImageId = imageId;
try {
const res = await fetch(`${API_BASE}/api/images/${imageId}`);
const data = await res.json();
const modal = document.getElementById('image-modal');
const modalImg = document.getElementById('modal-image');
const modalTitle = document.getElementById('modal-title');
const modalTime = document.getElementById('modal-time');
const modalEvents = document.getElementById('modal-events');
modalImg.src = `/images/${data.path.split('/').pop()}`;
modalTitle.textContent = `图片 #${data.id}`;
modalTime.textContent = `时间: ${new Date(data.timestamp).toLocaleString('zh-CN')}`;
modalEvents.innerHTML = '';
if (data.events && data.events.length > 0) {
data.events.forEach(event => {
const eventDiv = document.createElement('div');
eventDiv.className = 'modal-event';
eventDiv.innerHTML = `
<strong>${event.event_type}</strong>
<span class="event-confidence">${event.confidence}</span>
<div>${event.description}</div>
`;
modalEvents.appendChild(eventDiv);
});
} else {
modalEvents.innerHTML = '<p style="color: #888;">暂无事件记录</p>';
}
modal.classList.add('active');
} catch (e) {
console.error('加载图片详情失败:', e);
}
}
function closeModal() {
document.getElementById('image-modal').classList.remove('active');
currentImageId = null;
}
async function analyzeCurrentImage() {
if (!currentImageId) return;
try {
const res = await fetch(`${API_BASE}/api/analyze/${currentImageId}`, {
method: 'POST'
});
const data = await res.json();
if (data.success) {
alert('分析完成!');
openImageModal(currentImageId); // 刷新详情
refreshAll();
} else {
alert('分析失败: ' + data.error);
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
async function deleteCurrentImage() {
if (!currentImageId) return;
if (!confirm('确定删除此图片?')) return;
try {
const res = await fetch(`${API_BASE}/api/images/${currentImageId}`, {
method: 'DELETE'
});
const data = await res.json();
if (data.success) {
closeModal();
refreshAll();
}
} catch (e) {
alert('请求失败: ' + e.message);
}
}
// ============== 事件相关 ==============
async function loadEvents() {
const filter = document.getElementById('event-filter').value;
const eventType = filter === 'all' ? '' : filter;
try {
let url = `${API_BASE}/api/events?limit=50`;
if (eventType) {
url += `&event_type=${encodeURIComponent(eventType)}`;
}
const res = await fetch(url);
const data = await res.json();
renderEvents(data.events);
} catch (e) {
console.error('加载事件失败:', e);
}
}
function renderEvents(events) {
const list = document.getElementById('events-list');
list.innerHTML = '';
if (events.length === 0) {
list.innerHTML = '<p style="color: #888;">暂无事件</p>';
return;
}
events.forEach(event => {
const card = document.createElement('div');
card.className = `event-card type-${event.event_type}`;
const time = new Date(event.timestamp).toLocaleString('zh-CN');
card.innerHTML = `
<div class="event-header">
<span class="event-type">${event.event_type}</span>
<span class="event-time">${time}</span>
</div>
<div class="event-desc">${event.description}</div>
<div class="event-confidence">${event.confidence}</div>
`;
card.onclick = () => openImageModal(event.image_id);
list.appendChild(card);
});
}
// 筛选变化监听
document.getElementById('image-filter').addEventListener('change', loadImages);
document.getElementById('event-filter').addEventListener('change', loadEvents);
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>视觉记录系统</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<!-- 头部 -->
<header class="header">
<h1>📷 视觉记录系统</h1>
<div class="status-bar">
<span id="scheduler-status">状态: 加载中...</span>
<span id="capture-count">拍照次数: 0</span>
</div>
</header>
<!-- 控制面板 -->
<section class="control-panel">
<div class="panel-section">
<h3>📹 摄像头控制</h3>
<div class="control-row">
<label>拍照间隔 (秒):</label>
<input type="number" id="interval-input" value="60" min="10" max="3600">
<button onclick="setInterval()">设置</button>
</div>
<div class="control-row">
<label>自动分析:</label>
<input type="checkbox" id="auto-analyze" checked>
</div>
<div class="control-buttons">
<button onclick="startScheduler()" id="start-btn" class="btn-primary">▶ 启动</button>
<button onclick="stopScheduler()" id="stop-btn" class="btn-danger">⏹ 停止</button>
<button onclick="captureNow()" class="btn-secondary">📸 立即拍照</button>
</div>
</div>
<div class="panel-section">
<h3>📊 系统统计</h3>
<div class="stats-grid" id="stats-display">
<div class="stat-item">
<span class="stat-label">总图片数</span>
<span class="stat-value" id="total-images">0</span>
</div>
<div class="stat-item">
<span class="stat-label">已分析</span>
<span class="stat-value" id="analyzed-images">0</span>
</div>
<div class="stat-item">
<span class="stat-label">总事件数</span>
<span class="stat-value" id="total-events">0</span>
</div>
</div>
</div>
</section>
<!-- 主内容区 -->
<div class="main-content">
<!-- 图片时间线 -->
<section class="images-section">
<h2>📷 图片时间线</h2>
<div class="filter-bar">
<select id="image-filter">
<option value="all">全部</option>
<option value="analyzed">已分析</option>
<option value="unanalyzed">未分析</option>
</select>
<button onclick="analyzeUnanalyzed()" class="btn-small">分析未分析图片</button>
</div>
<div class="images-grid" id="images-grid">
<!-- 图片卡片将通过 JS 动态加载 -->
</div>
</section>
<!-- 事件列表 -->
<section class="events-section">
<h2>📋 事件列表</h2>
<div class="filter-bar">
<select id="event-filter">
<option value="all">全部类型</option>
<!-- 动态加载事件类型 -->
</select>
</div>
<div class="events-list" id="events-list">
<!-- 事件卡片将通过 JS 动态加载 -->
</div>
</section>
</div>
<!-- 图片详情模态框 -->
<div class="modal" id="image-modal">
<div class="modal-content">
<span class="modal-close" onclick="closeModal()">×</span>
<img id="modal-image" src="" alt="图片详情">
<div class="modal-info">
<h3 id="modal-title">图片详情</h3>
<p id="modal-time">时间: </p>
<div id="modal-events" class="modal-events">
<!-- 事件详情 -->
</div>
<div class="modal-actions">
<button onclick="analyzeCurrentImage()" class="btn-secondary">🔍 分析</button>
<button onclick="deleteCurrentImage()" class="btn-danger">🗑 删除</button>
</div>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+399
View File
@@ -0,0 +1,399 @@
/* 视觉记录系统样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* 头部 */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
border-radius: 10px;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
margin-bottom: 10px;
}
.status-bar {
display: flex;
gap: 20px;
font-size: 14px;
}
.status-bar span {
background: rgba(255,255,255,0.2);
padding: 5px 15px;
border-radius: 20px;
}
/* 控制面板 */
.control-panel {
background: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
gap: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.panel-section {
flex: 1;
}
.panel-section h3 {
margin-bottom: 15px;
color: #555;
font-size: 16px;
}
.control-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.control-row label {
min-width: 100px;
}
.control-row input[type="number"] {
width: 80px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 5px;
}
.control-row input[type="checkbox"] {
width: 20px;
height: 20px;
}
.control-buttons {
display: flex;
gap: 10px;
margin-top: 15px;
}
/* 按钮 */
button {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #4CAF50;
color: white;
}
.btn-primary:hover {
background: #45a049;
}
.btn-danger {
background: #f44336;
color: white;
}
.btn-danger:hover {
background: #da190b;
}
.btn-secondary {
background: #2196F3;
color: white;
}
.btn-secondary:hover {
background: #0b7dda;
}
.btn-small {
padding: 5px 15px;
font-size: 12px;
background: #9e9e9e;
color: white;
}
.btn-small:hover {
background: #757575;
}
/* 统计网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.stat-item {
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
text-align: center;
}
.stat-label {
display: block;
color: #888;
font-size: 12px;
margin-bottom: 5px;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: bold;
color: #333;
}
/* 主内容区 */
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* 图片区域 */
.images-section, .events-section {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.images-section h2, .events-section h2 {
margin-bottom: 15px;
color: #555;
}
.filter-bar {
display: flex;
gap: 10px;
margin-bottom: 15px;
align-items: center;
}
.filter-bar select {
padding: 8px 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
/* 图片网格 */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
max-height: 500px;
overflow-y: auto;
}
.image-card {
background: #f5f5f5;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.image-card:hover {
transform: scale(1.05);
}
.image-card img {
width: 100%;
height: 120px;
object-fit: cover;
}
.image-card-info {
padding: 8px;
font-size: 12px;
}
.image-card-info .time {
color: #888;
}
.image-card-info .analyzed {
color: #4CAF50;
font-weight: bold;
}
.image-card-info .unanalyzed {
color: #f44336;
}
/* 事件列表 */
.events-list {
max-height: 500px;
overflow-y: auto;
}
.event-card {
background: #f9f9f9;
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.event-card.type-人物活动 {
border-left-color: #e91e63;
}
.event-card.type-物体变化 {
border-left-color: #ff9800;
}
.event-card.type-环境变化 {
border-left-color: #4caf50;
}
.event-card.type-异常情况 {
border-left-color: #f44336;
}
.event-card.type-无事件 {
border-left-color: #9e9e9e;
opacity: 0.7;
}
.event-header {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.event-type {
font-weight: bold;
color: #333;
}
.event-time {
font-size: 12px;
color: #888;
}
.event-desc {
font-size: 14px;
color: #555;
}
.event-confidence {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #e0e0e0;
}
/* 模态框 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 10px;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
font-size: 24px;
cursor: pointer;
color: #888;
}
.modal-close:hover {
color: #333;
}
.modal-content img {
width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
margin-bottom: 15px;
}
.modal-info h3 {
margin-bottom: 10px;
}
.modal-events {
margin-top: 15px;
}
.modal-event {
background: #f5f5f5;
padding: 10px;
margin-bottom: 8px;
border-radius: 5px;
}
.modal-actions {
margin-top: 15px;
display: flex;
gap: 10px;
}
/* 响应式 */
@media (max-width: 768px) {
.control-panel {
flex-direction: column;
}
.main-content {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}