feat: v1.1.0 - 图片按日期分文件夹保存、设置面板、序号显示模式、自动刷新

This commit is contained in:
2026-04-16 10:42:36 +08:00
parent 3503ee35ab
commit f703c0491c
9 changed files with 639 additions and 220 deletions

View File

@@ -6,16 +6,16 @@ import requests
import json
import re
from pathlib import Path
from config import LLM_API_URL, LLM_API_KEY, LLM_MODEL, ANALYSIS_PROMPT
from config import config_mgr, ANALYSIS_PROMPT
class ImageAnalyzer:
"""图片分析器"""
def __init__(self):
self.api_url = LLM_API_URL
self.api_key = LLM_API_KEY
self.model = LLM_MODEL
self.api_url = config_mgr.get('vision_api_url')
self.api_key = config_mgr.get('vision_api_key')
self.model = config_mgr.get('vision_model')
def encode_image(self, image_path):
"""将图片转为 base64"""
@@ -29,6 +29,11 @@ class ImageAnalyzer:
dict: {'success': bool, 'events': list, 'error': str}
"""
try:
# 更新配置(可能已被修改)
self.api_url = config_mgr.get('vision_api_url')
self.api_key = config_mgr.get('vision_api_key')
self.model = config_mgr.get('vision_model')
# 编码图片
image_base64 = self.encode_image(image_path)
@@ -105,10 +110,6 @@ class ImageAnalyzer:
}]
# 尝试解析结构化格式
# 事件类型: xxx
# 描述: xxx
# 置信度: xxx
pattern = r"事件类型[:]\s*(.+?)\s*描述[:]\s*(.+?)\s*置信度[:]\s*(.+)"
matches = re.findall(pattern, content, re.DOTALL)
@@ -137,11 +138,11 @@ def analyze_image(image_path):
if __name__ == "__main__":
# 测试分析(需要先有一张测试图片)
# 测试分析
test_image = Path(__file__).parent / "data" / "images" / "test.jpg"
if test_image.exists():
print(f"分析图片: {test_image}")
result = analyze_image(test_image)
print(f"结果: {json.dumps(result, ensure_ascii=False, indent=2)}")
else:
print("没有测试图片,请先拍照")
print("没有测试图片")

View File

@@ -2,22 +2,22 @@
摄像头拍照模块 - Windows 兼容
"""
import cv2
import os
import datetime
from pathlib import Path
from config import IMAGES_DIR, CAMERA_INDEX
from config import config_mgr
class CameraCapture:
"""摄像头拍照管理"""
def __init__(self, camera_index=CAMERA_INDEX):
self.camera_index = camera_index
def __init__(self):
self.camera_index = config_mgr.get('camera_index', 0)
self.cap = None
def open(self):
"""打开摄像头"""
if self.cap is None or not self.cap.isOpened():
self.camera_index = config_mgr.get('camera_index', 0)
self.cap = cv2.VideoCapture(self.camera_index, cv2.CAP_DSHOW) # Windows DirectShow
if not self.cap.isOpened():
raise Exception(f"无法打开摄像头 {self.camera_index}")
@@ -33,10 +33,10 @@ class CameraCapture:
self.cap = None
def capture(self, save_path=None):
"""拍照并保存
"""拍照并保存(按日期文件夹组织)
Returns:
dict: {'success': bool, 'path': str, 'timestamp': str, 'error': str}
dict: {'success': bool, 'path': str, 'timestamp': str, 'date_folder': str, 'error': str}
"""
try:
self.open()
@@ -49,11 +49,17 @@ class CameraCapture:
if not ret:
return {'success': False, 'error': '无法捕获图像'}
# 生成文件名
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
# 获取当前日期
now = datetime.datetime.now()
date_str = now.strftime('%Y-%m-%d')
timestamp = now.strftime('%Y%m%d_%H%M%S')
# 获取按日期的保存目录
date_folder = config_mgr.get_images_dir(date_str)
if save_path is None:
filename = f"capture_{timestamp}.jpg"
save_path = IMAGES_DIR / filename
save_path = date_folder / filename
# 保存图片
cv2.imwrite(str(save_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
@@ -62,6 +68,7 @@ class CameraCapture:
'success': True,
'path': str(save_path),
'timestamp': timestamp,
'date_folder': date_str,
'filename': save_path.name
}
@@ -112,7 +119,7 @@ if __name__ == "__main__":
if cams:
print("\n测试拍照...")
camera = CameraCapture(cams[0])
camera = CameraCapture()
result = camera.capture()
print(f"结果: {result}")
camera.close()

View File

@@ -2,30 +2,35 @@
配置管理模块
"""
import os
import json
from pathlib import Path
from datetime import datetime
# 基础路径
BASE_DIR = Path(__file__).parent.absolute()
DATA_DIR = BASE_DIR / "data"
IMAGES_DIR = DATA_DIR / "images"
DB_PATH = DATA_DIR / "events.db"
CONFIG_PATH = DATA_DIR / "config.json"
# 确保目录存在
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Web 服务配置
WEB_PORT = 19016
WEB_HOST = "0.0.0.0"
# 摄像头配置
CAMERA_INDEX = 0 # 默认摄像头
CAPTURE_INTERVAL = 60 # 拍照间隔(秒)
# 大模型配置(用于图片分析)
LLM_PROVIDER = "bailian" # bailian / openai / local
LLM_API_URL = os.environ.get("VISION_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
LLM_API_KEY = os.environ.get("VISION_API_KEY", "sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j")
LLM_MODEL = os.environ.get("VISION_MODEL", "qwen-vl-plus") # 视觉模型
# 默认配置
DEFAULT_CONFIG = {
"images_dir": str(DATA_DIR / "images"),
"camera_index": 0,
"capture_interval": 60,
"auto_analyze": True,
"refresh_interval": 5, # 页面刷新间隔(秒)
"display_limit": 20, # 显示最近多少条
"vision_api_url": os.environ.get("VISION_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
"vision_api_key": os.environ.get("VISION_API_KEY", "sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j"),
"vision_model": os.environ.get("VISION_MODEL", "qwen-vl-plus")
}
# 分析配置
ANALYSIS_PROMPT = """请分析这张图片,识别其中的重要事件或变化。
@@ -46,12 +51,18 @@ ANALYSIS_PROMPT = """请分析这张图片,识别其中的重要事件或变
# 数据库配置
DB_SCHEMA = """
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
camera_id INTEGER DEFAULT 0,
analyzed BOOLEAN DEFAULT 0
analyzed BOOLEAN DEFAULT 0,
date_folder TEXT
);
CREATE TABLE IF NOT EXISTS events (
@@ -65,5 +76,66 @@ CREATE TABLE IF NOT EXISTS events (
);
CREATE INDEX IF NOT EXISTS idx_images_timestamp ON images(timestamp);
CREATE INDEX IF NOT EXISTS idx_images_date_folder ON images(date_folder);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
"""
"""
class ConfigManager:
"""配置管理器"""
def __init__(self):
self.config = DEFAULT_CONFIG.copy()
self.load()
def load(self):
"""加载配置"""
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
saved = json.load(f)
self.config.update(saved)
except:
pass
def save(self):
"""保存配置"""
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
def get(self, key, default=None):
"""获取配置项"""
return self.config.get(key, default)
def set(self, key, value):
"""设置配置项"""
self.config[key] = value
self.save()
def get_images_dir(self, date=None):
"""获取图片保存目录(按日期)"""
base_dir = Path(self.config['images_dir'])
if date is None:
date = datetime.now().strftime('%Y-%m-%d')
# 每天的图片放在单独文件夹
date_folder = base_dir / date
date_folder.mkdir(parents=True, exist_ok=True)
return date_folder
def get_all(self):
"""获取所有配置"""
return self.config.copy()
def update(self, updates):
"""批量更新配置"""
for key, value in updates.items():
if key in self.config:
self.config[key] = value
self.save()
# 全局配置实例
config_mgr = ConfigManager()

View File

@@ -25,7 +25,7 @@ class Database:
"""获取数据库连接"""
return sqlite3.connect(self.db_path)
def add_image(self, path, camera_id=0):
def add_image(self, path, camera_id=0, date_folder=None):
"""添加图片记录
Returns:
@@ -34,8 +34,8 @@ class Database:
conn = self._get_conn()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO images (path, camera_id, timestamp) VALUES (?, ?, ?)",
(path, camera_id, datetime.datetime.now().isoformat())
"INSERT INTO images (path, camera_id, timestamp, date_folder) VALUES (?, ?, ?, ?)",
(path, camera_id, datetime.datetime.now().isoformat(), date_folder)
)
image_id = cursor.lastrowid
conn.commit()
@@ -59,22 +59,28 @@ class Database:
conn.commit()
conn.close()
def get_images(self, limit=50, offset=0, analyzed_only=False):
def get_images(self, limit=50, offset=0, analyzed_only=False, date_folder=None):
"""获取图片列表"""
conn = self._get_conn()
conn.row_factory = sqlite3.Row
if analyzed_only:
cursor = conn.execute(
"SELECT * FROM images WHERE analyzed = 1 ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset)
)
else:
cursor = conn.execute(
"SELECT * FROM images ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset)
)
query = "SELECT * FROM images"
params = []
conditions = []
if analyzed_only:
conditions.append("analyzed = 1")
if date_folder:
conditions.append("date_folder = ?")
params.append(date_folder)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor = conn.execute(query, params)
rows = cursor.fetchall()
conn.close()
@@ -87,7 +93,7 @@ class Database:
if event_type:
cursor = conn.execute(
"""SELECT e.*, i.path as image_path, i.timestamp as image_timestamp
"""SELECT e.*, i.path as image_path, i.timestamp as image_timestamp, i.id as image_id
FROM events e JOIN images i ON e.image_id = i.id
WHERE e.event_type = ?
ORDER BY e.timestamp DESC LIMIT ? OFFSET ?""",
@@ -95,7 +101,7 @@ class Database:
)
else:
cursor = conn.execute(
"""SELECT e.*, i.path as image_path, i.timestamp as image_timestamp
"""SELECT e.*, i.path as image_path, i.timestamp as image_timestamp, i.id as image_id
FROM events e JOIN images i ON e.image_id = i.id
ORDER BY e.timestamp DESC LIMIT ? OFFSET ?""",
(limit, offset)
@@ -144,6 +150,12 @@ class Database:
)
event_types = [{'type': row[0], 'count': row[1]} for row in cursor.fetchall()]
# 日期文件夹统计
cursor = conn.execute(
"SELECT date_folder, COUNT(*) as count FROM images WHERE date_folder IS NOT NULL GROUP BY date_folder ORDER BY date_folder DESC"
)
date_folders = [{'date': row[0], 'count': row[1]} for row in cursor.fetchall()]
conn.close()
return {
@@ -151,7 +163,8 @@ class Database:
'analyzed_images': analyzed_images,
'unanalyzed_images': total_images - analyzed_images,
'total_events': total_events,
'event_types': event_types
'event_types': event_types,
'date_folders': date_folders
}
def delete_image(self, image_id):
@@ -190,6 +203,16 @@ class Database:
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
def get_date_folders(self):
"""获取所有日期文件夹"""
conn = self._get_conn()
cursor = conn.execute(
"SELECT DISTINCT date_folder FROM images WHERE date_folder IS NOT NULL ORDER BY date_folder DESC"
)
folders = [row[0] for row in cursor.fetchall()]
conn.close()
return folders
# 全局实例

View File

@@ -7,7 +7,7 @@ import datetime
from camera import CameraCapture
from analyzer import ImageAnalyzer
from database import db
from config import CAPTURE_INTERVAL
from config import config_mgr
class VisionScheduler:
@@ -17,9 +17,7 @@ class VisionScheduler:
self.camera = CameraCapture()
self.analyzer = ImageAnalyzer()
self.running = False
self.interval = CAPTURE_INTERVAL
self.timer = None
self.auto_analyze = True # 自动分析
# 统计
self.capture_count = 0
@@ -27,28 +25,19 @@ class VisionScheduler:
self.last_analyze_time = None
self.errors = []
def start(self, interval=None, auto_analyze=None):
"""启动定时拍照
Args:
interval: 拍照间隔(秒)
auto_analyze: 是否自动分析
"""
def start(self):
"""启动定时拍照"""
if self.running:
return {'success': False, 'error': '已在运行中'}
if interval:
self.interval = interval
if auto_analyze is not None:
self.auto_analyze = auto_analyze
self.running = True
interval = config_mgr.get('capture_interval', 60)
self._schedule_next()
return {
'success': True,
'interval': self.interval,
'auto_analyze': self.auto_analyze
'interval': interval,
'auto_analyze': config_mgr.get('auto_analyze', True)
}
def stop(self):
@@ -64,7 +53,8 @@ class VisionScheduler:
"""安排下一次拍照"""
if not self.running:
return
self.timer = threading.Timer(self.interval, self._capture_task)
interval = config_mgr.get('capture_interval', 60)
self.timer = threading.Timer(interval, self._capture_task)
self.timer.start()
def _capture_task(self):
@@ -78,12 +68,15 @@ class VisionScheduler:
if result['success']:
# 记录到数据库
image_id = db.add_image(result['path'])
image_id = db.add_image(
result['path'],
date_folder=result.get('date_folder')
)
self.capture_count += 1
self.last_capture_time = datetime.datetime.now().isoformat()
# 自动分析
if self.auto_analyze:
if config_mgr.get('auto_analyze', True):
self._analyze_task(image_id, result['path'])
else:
self.errors.append({
@@ -133,12 +126,15 @@ class VisionScheduler:
result = self.camera.capture()
if result['success']:
image_id = db.add_image(result['path'])
image_id = db.add_image(
result['path'],
date_folder=result.get('date_folder')
)
self.capture_count += 1
self.last_capture_time = datetime.datetime.now().isoformat()
# 如果自动分析开启,立即分析
if self.auto_analyze:
if config_mgr.get('auto_analyze', True):
threading.Thread(
target=self._analyze_task,
args=(image_id, result['path'])
@@ -148,7 +144,8 @@ class VisionScheduler:
'success': True,
'image_id': image_id,
'path': result['path'],
'timestamp': result['timestamp']
'timestamp': result['timestamp'],
'date_folder': result.get('date_folder')
}
return result
@@ -201,8 +198,8 @@ class VisionScheduler:
"""获取调度器状态"""
return {
'running': self.running,
'interval': self.interval,
'auto_analyze': self.auto_analyze,
'interval': config_mgr.get('capture_interval', 60),
'auto_analyze': config_mgr.get('auto_analyze', True),
'capture_count': self.capture_count,
'last_capture_time': self.last_capture_time,
'last_analyze_time': self.last_analyze_time,
@@ -211,7 +208,7 @@ class VisionScheduler:
def set_interval(self, interval):
"""设置拍照间隔"""
self.interval = interval
config_mgr.set('capture_interval', interval)
if self.running:
# 重启定时器
self.stop()

View File

@@ -3,12 +3,11 @@ Web 后端 - FastAPI
"""
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pathlib import Path
import uvicorn
import json
from config import WEB_PORT, WEB_HOST, IMAGES_DIR
from config import WEB_PORT, WEB_HOST, config_mgr, DATA_DIR
from database import db
from scheduler import scheduler
from camera import list_available_cameras, CameraCapture
@@ -20,7 +19,17 @@ app = FastAPI(title="视觉记录系统")
# 静态文件目录
STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images")
# 图片目录 - 使用配置中的目录
@app.get("/images/{filename}")
async def get_image(filename: str):
"""获取图片(需要在实际目录中查找)"""
# 从数据库查找图片路径
images = db.get_images(limit=1000)
for img in images:
if img['path'].endswith(filename):
return FileResponse(img['path'])
raise HTTPException(status_code=404, detail="图片不存在")
# ============== 页面路由 ==============
@@ -34,17 +43,55 @@ async def index():
return HTMLResponse("<h1>请创建 index.html</h1>")
# ============== API 路由 ==============
# ============== 配置 API ==============
@app.get("/api/config")
async def get_config():
"""获取所有配置"""
return config_mgr.get_all()
@app.post("/api/config")
async def update_config(data: dict):
"""更新配置"""
config_mgr.update(data)
return {"success": True, "config": config_mgr.get_all()}
@app.get("/api/config/images-dir")
async def get_images_dir():
"""获取图片保存目录"""
return {
"images_dir": config_mgr.get('images_dir'),
"today_folder": str(config_mgr.get_images_dir())
}
@app.post("/api/config/images-dir")
async def set_images_dir(path: str):
"""设置图片保存目录"""
from pathlib import Path
try:
Path(path).mkdir(parents=True, exist_ok=True)
config_mgr.set('images_dir', path)
return {"success": True, "images_dir": path}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ============== 状态 API ==============
@app.get("/api/status")
async def get_status():
"""获取系统状态"""
stats = db.get_stats()
scheduler_status = scheduler.get_status()
config = config_mgr.get_all()
return {
"scheduler": scheduler_status,
"stats": stats
"stats": stats,
"config": config
}
@@ -54,16 +101,19 @@ async def get_cameras():
cameras = list_available_cameras()
details = []
for cam_index in cameras:
cam = CameraCapture(cam_index)
cam = CameraCapture()
cam.camera_index = cam_index
details.append(cam.get_camera_info())
cam.close()
return {"cameras": details}
# ============== 调度控制 API ==============
@app.post("/api/scheduler/start")
async def start_scheduler(interval: int = None, auto_analyze: bool = None):
async def start_scheduler():
"""启动定时拍照"""
result = scheduler.start(interval=interval, auto_analyze=auto_analyze)
result = scheduler.start()
if not result['success']:
raise HTTPException(status_code=400, detail=result['error'])
return result
@@ -78,6 +128,8 @@ async def stop_scheduler():
@app.post("/api/scheduler/interval")
async def set_interval(interval: int):
"""设置拍照间隔"""
if interval < 10:
raise HTTPException(status_code=400, detail="间隔不能小于10秒")
return scheduler.set_interval(interval)
@@ -90,6 +142,8 @@ async def capture_now():
return result
# ============== 分析 API ==============
@app.post("/api/analyze/{image_id}")
async def analyze_image(image_id: int):
"""分析指定图片"""
@@ -106,10 +160,29 @@ async def analyze_unanalyzed():
return {"results": results}
# ============== 图片 API ==============
@app.get("/api/images")
async def get_images(limit: int = 50, offset: int = 0, analyzed_only: bool = False):
async def get_images(
limit: int = 50,
offset: int = 0,
analyzed_only: bool = False,
date_folder: str = None
):
"""获取图片列表"""
images = db.get_images(limit=limit, offset=offset, analyzed_only=analyzed_only)
# 使用配置中的显示数量
config_limit = config_mgr.get('display_limit', 20)
if limit > config_limit:
limit = config_limit
images = db.get_images(limit=limit, offset=offset, analyzed_only=analyzed_only, date_folder=date_folder)
# 为每张图片添加事件摘要
for img in images:
events = db.get_events_by_image(img['id'])
img['events_summary'] = ', '.join([e['event_type'] for e in events]) if events else ''
img['events_count'] = len(events)
return {"images": images, "total": len(images)}
@@ -134,9 +207,22 @@ async def delete_image(image_id: int):
return {"success": True}
@app.get("/api/date-folders")
async def get_date_folders():
"""获取日期文件夹列表"""
stats = db.get_stats()
return {"folders": stats['date_folders']}
# ============== 事件 API ==============
@app.get("/api/events")
async def get_events(limit: int = 50, offset: int = 0, event_type: str = None):
"""获取事件列表"""
config_limit = config_mgr.get('display_limit', 20)
if limit > config_limit:
limit = config_limit
events = db.get_events(limit=limit, offset=offset, event_type=event_type)
return {"events": events, "total": len(events)}

View File

@@ -2,20 +2,119 @@
const API_BASE = '';
let currentImageId = null;
let refreshTimer = null;
let refreshInterval = 5;
// ============== 初始化 ==============
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
refreshAll();
setInterval(refreshAll, 5000); // 每5秒刷新
startAutoRefresh();
});
function startAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
}
refreshTimer = setInterval(refreshAll, refreshInterval * 1000);
document.getElementById('refresh-info').textContent = `刷新间隔: ${refreshInterval}`;
}
function refreshAll() {
loadStatus();
loadDateFolders();
loadImages();
loadEvents();
}
// ============== 配置相关 ==============
async function loadConfig() {
try {
const res = await fetch(`${API_BASE}/api/config`);
const config = await res.json();
refreshInterval = config.refresh_interval || 5;
startAutoRefresh();
// 更新显示数量提示
document.getElementById('display-limit-note')?.textContent =
`显示最近 ${config.display_limit || 20}`;
} catch (e) {
console.error('加载配置失败:', e);
}
}
function openSettingsModal() {
loadSettingsForm();
document.getElementById('settings-modal').classList.add('active');
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.remove('active');
}
async function loadSettingsForm() {
try {
const res = await fetch(`${API_BASE}/api/config`);
const config = await res.json();
document.getElementById('setting-images-dir').value = config.images_dir || '';
document.getElementById('setting-interval').value = config.capture_interval || 60;
document.getElementById('setting-camera-index').value = config.camera_index || 0;
document.getElementById('setting-auto-analyze').checked = config.auto_analyze !== false;
document.getElementById('setting-display-limit').value = config.display_limit || 20;
document.getElementById('setting-refresh-interval').value = config.refresh_interval || 5;
document.getElementById('setting-api-url').value = config.vision_api_url || '';
document.getElementById('setting-api-key').value = config.vision_api_key || '';
document.getElementById('setting-model').value = config.vision_model || '';
} catch (e) {
console.error('加载设置失败:', e);
}
}
async function saveSettings() {
const settings = {
images_dir: document.getElementById('setting-images-dir').value,
capture_interval: parseInt(document.getElementById('setting-interval').value),
camera_index: parseInt(document.getElementById('setting-camera-index').value),
auto_analyze: document.getElementById('setting-auto-analyze').checked,
display_limit: parseInt(document.getElementById('setting-display-limit').value),
refresh_interval: parseInt(document.getElementById('setting-refresh-interval').value),
vision_api_url: document.getElementById('setting-api-url').value,
vision_api_key: document.getElementById('setting-api-key').value,
vision_model: document.getElementById('setting-model').value
};
try {
const res = await fetch(`${API_BASE}/api/config`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settings)
});
const data = await res.json();
if (data.success) {
alert('设置已保存!');
// 更新刷新间隔
refreshInterval = settings.refresh_interval;
startAutoRefresh();
closeSettingsModal();
refreshAll();
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
function browseImagesDir() {
// 提示用户手动输入路径
alert('请手动输入图片保存目录路径\n例如: D:\\vision-images\n每天的图片会自动保存到日期子文件夹');
}
// ============== 状态相关 ==============
async function loadStatus() {
@@ -45,19 +144,11 @@ async function loadStatus() {
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;
@@ -67,7 +158,6 @@ function updateStats(stats) {
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');
@@ -82,11 +172,8 @@ function updateStats(stats) {
// ============== 控制相关 ==============
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}`, {
const res = await fetch(`${API_BASE}/api/scheduler/start`, {
method: 'POST'
});
const data = await res.json();
@@ -118,29 +205,6 @@ async function stopScheduler() {
}
}
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`, {
@@ -149,7 +213,7 @@ async function captureNow() {
const data = await res.json();
if (data.success) {
alert('拍照成功!图片ID: ' + data.image_id);
alert(`拍照成功!\n图片ID: #${data.image_id}\n保存到: ${data.date_folder}`);
refreshAll();
} else {
alert('拍照失败: ' + data.error);
@@ -173,54 +237,78 @@ async function analyzeUnanalyzed() {
}
}
// ============== 日期文件夹相关 ==============
async function loadDateFolders() {
try {
const res = await fetch(`${API_BASE}/api/date-folders`);
const data = await res.json();
const dateFilter = document.getElementById('date-filter');
const currentValue = dateFilter.value;
dateFilter.innerHTML = '<option value="all">全部日期</option>';
data.folders.forEach(item => {
const opt = document.createElement('option');
opt.value = item.date;
opt.textContent = `${item.date} (${item.count}张)`;
dateFilter.appendChild(opt);
});
dateFilter.value = currentValue;
} catch (e) {
console.error('加载日期文件夹失败:', e);
}
}
// ============== 图片相关 ==============
async function loadImages() {
const filter = document.getElementById('image-filter').value;
const analyzedOnly = filter === 'analyzed';
const dateFilter = document.getElementById('date-filter').value;
const imageFilter = document.getElementById('image-filter').value;
const analyzedOnly = imageFilter === 'analyzed';
try {
const res = await fetch(`${API_BASE}/api/images?limit=50&analyzed_only=${analyzedOnly}`);
let url = `${API_BASE}/api/images?limit=100&analyzed_only=${analyzedOnly}`;
if (dateFilter !== 'all') {
url += `&date_folder=${dateFilter}`;
}
const res = await fetch(url);
const data = await res.json();
renderImages(data.images);
renderImagesList(data.images);
} catch (e) {
console.error('加载图片失败:', e);
}
}
function renderImages(images) {
const grid = document.getElementById('images-grid');
grid.innerHTML = '';
function renderImagesList(images) {
const list = document.getElementById('images-list');
list.innerHTML = '';
if (images.length === 0) {
grid.innerHTML = '<p style="color: #888;">暂无图片</p>';
list.innerHTML = '<p style="color: #888; text-align: center;">暂无图片记录</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';
images.forEach((img, index) => {
const item = document.createElement('div');
item.className = `image-item ${img.analyzed ? 'analyzed' : 'unanalyzed'}`;
item.onclick = () => openImageModal(img.id);
const time = new Date(img.timestamp).toLocaleString('zh-CN');
info.innerHTML = `
<div class="time">${time}</div>
<div class="${img.analyzed ? 'analyzed' : 'unanalyzed'}">
item.innerHTML = `
<span class="image-number">#${img.id}</span>
<span class="image-time">${time}</span>
<span class="image-status ${img.analyzed ? 'analyzed' : 'unanalyzed'}">
${img.analyzed ? '✓ 已分析' : '○ 未分析'}
</div>
</span>
<span class="image-events-summary">${img.events_summary || '无事件'}</span>
`;
card.appendChild(imgEl);
card.appendChild(info);
grid.appendChild(card);
list.appendChild(item);
});
}
@@ -235,11 +323,15 @@ async function openImageModal(imageId) {
const modalImg = document.getElementById('modal-image');
const modalTitle = document.getElementById('modal-title');
const modalTime = document.getElementById('modal-time');
const modalFolder = document.getElementById('modal-folder');
const modalEvents = document.getElementById('modal-events');
modalImg.src = `/images/${data.path.split('/').pop()}`;
// 获取图片文件名
const filename = data.path.split('/').pop().split('\\').pop();
modalImg.src = `/images/${filename}`;
modalTitle.textContent = `图片 #${data.id}`;
modalTime.textContent = `时间: ${new Date(data.timestamp).toLocaleString('zh-CN')}`;
modalFolder.textContent = `日期文件夹: ${data.date_folder || '未知'}`;
modalEvents.innerHTML = '';
if (data.events && data.events.length > 0) {
@@ -263,7 +355,7 @@ async function openImageModal(imageId) {
}
}
function closeModal() {
function closeImageModal() {
document.getElementById('image-modal').classList.remove('active');
currentImageId = null;
}
@@ -279,7 +371,7 @@ async function analyzeCurrentImage() {
if (data.success) {
alert('分析完成!');
openImageModal(currentImageId); // 刷新详情
openImageModal(currentImageId);
refreshAll();
} else {
alert('分析失败: ' + data.error);
@@ -301,7 +393,7 @@ async function deleteCurrentImage() {
const data = await res.json();
if (data.success) {
closeModal();
closeImageModal();
refreshAll();
}
} catch (e) {
@@ -316,7 +408,7 @@ async function loadEvents() {
const eventType = filter === 'all' ? '' : filter;
try {
let url = `${API_BASE}/api/events?limit=50`;
let url = `${API_BASE}/api/events?limit=100`;
if (eventType) {
url += `&event_type=${encodeURIComponent(eventType)}`;
}
@@ -335,7 +427,7 @@ function renderEvents(events) {
list.innerHTML = '';
if (events.length === 0) {
list.innerHTML = '<p style="color: #888;">暂无事件</p>';
list.innerHTML = '<p style="color: #888; text-align: center;">暂无事件记录</p>';
return;
}
@@ -360,5 +452,6 @@ function renderEvents(events) {
}
// 筛选变化监听
document.getElementById('date-filter').addEventListener('change', loadImages);
document.getElementById('image-filter').addEventListener('change', loadImages);
document.getElementById('event-filter').addEventListener('change', loadEvents);

View File

@@ -14,6 +14,7 @@
<div class="status-bar">
<span id="scheduler-status">状态: 加载中...</span>
<span id="capture-count">拍照次数: 0</span>
<span id="refresh-info">刷新间隔: 5秒</span>
</div>
</header>
@@ -21,15 +22,6 @@
<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>
@@ -37,6 +29,11 @@
</div>
</div>
<div class="panel-section">
<h3>⚙️ 系统设置</h3>
<button onclick="openSettingsModal()" class="btn-settings">🔧 设置参数</button>
</div>
<div class="panel-section">
<h3>📊 系统统计</h3>
<div class="stats-grid" id="stats-display">
@@ -62,15 +59,18 @@
<section class="images-section">
<h2>📷 图片时间线</h2>
<div class="filter-bar">
<select id="date-filter">
<option value="all">全部日期</option>
</select>
<select id="image-filter">
<option value="all">全部</option>
<option value="analyzed">已分析</option>
<option value="unanalyzed">未分析</option>
</select>
<button onclick="analyzeUnanalyzed()" class="btn-small">分析未分析图片</button>
<button onclick="analyzeUnanalyzed()" class="btn-small">分析未处理</button>
</div>
<div class="images-grid" id="images-grid">
<!-- 图片卡片将通过 JS 动态加载 -->
<div class="images-list" id="images-list">
<!-- 图片列表将显示序号+分析信息 -->
</div>
</section>
@@ -80,25 +80,91 @@
<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="settings-modal">
<div class="modal-content settings-modal-content">
<span class="modal-close" onclick="closeSettingsModal()">×</span>
<h2>⚙️ 系统设置</h2>
<div class="settings-section">
<h4>保存设置</h4>
<div class="setting-item">
<label>图片保存目录:</label>
<input type="text" id="setting-images-dir" placeholder="例如: D:\vision-images">
<button onclick="browseImagesDir()" class="btn-small">选择目录</button>
</div>
<p class="setting-note">每天的图片会自动保存到子文件夹(如 2026-04-16</p>
</div>
<div class="settings-section">
<h4>拍照设置</h4>
<div class="setting-item">
<label>拍照间隔 (秒):</label>
<input type="number" id="setting-interval" value="60" min="10" max="3600">
</div>
<div class="setting-item">
<label>摄像头索引:</label>
<input type="number" id="setting-camera-index" value="0" min="0" max="10">
</div>
<div class="setting-item">
<label>自动分析:</label>
<input type="checkbox" id="setting-auto-analyze" checked>
</div>
</div>
<div class="settings-section">
<h4>显示设置</h4>
<div class="setting-item">
<label>显示最近数量:</label>
<input type="number" id="setting-display-limit" value="20" min="5" max="100">
</div>
<div class="setting-item">
<label>页面刷新间隔 (秒):</label>
<input type="number" id="setting-refresh-interval" value="5" min="1" max="60">
</div>
</div>
<div class="settings-section">
<h4>API 设置</h4>
<div class="setting-item">
<label>Vision API URL:</label>
<input type="text" id="setting-api-url" placeholder="API URL">
</div>
<div class="setting-item">
<label>API Key:</label>
<input type="password" id="setting-api-key" placeholder="API Key">
</div>
<div class="setting-item">
<label>模型名称:</label>
<input type="text" id="setting-model" placeholder="模型名称">
</div>
</div>
<div class="settings-actions">
<button onclick="saveSettings()" class="btn-primary">保存设置</button>
<button onclick="closeSettingsModal()" class="btn-secondary">取消</button>
</div>
</div>
</div>
<!-- 图片详情模态框 -->
<div class="modal" id="image-modal">
<div class="modal-content">
<span class="modal-close" onclick="closeModal()">×</span>
<span class="modal-close" onclick="closeImageModal()">×</span>
<img id="modal-image" src="" alt="图片详情">
<div class="modal-info">
<h3 id="modal-title">图片详情</h3>
<p id="modal-time">时间: </p>
<p id="modal-folder">日期文件夹: </p>
<div id="modal-events" class="modal-events">
<!-- 事件详情 -->
</div>
<div class="modal-actions">
<button onclick="analyzeCurrentImage()" class="btn-secondary">🔍 分析</button>

View File

@@ -66,33 +66,9 @@ body {
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;
}
/* 按钮 */
@@ -143,6 +119,15 @@ button {
background: #757575;
}
.btn-settings {
background: #ff9800;
color: white;
}
.btn-settings:hover {
background: #f57c00;
}
/* 统计网格 */
.stats-grid {
display: grid;
@@ -204,51 +189,72 @@ button {
border-radius: 5px;
}
/* 图片网格 */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
/* 图片列表 - 序号+分析信息 */
.images-list {
max-height: 500px;
overflow-y: auto;
}
.image-card {
background: #f5f5f5;
.image-item {
background: #f9f9f9;
padding: 12px 15px;
margin-bottom: 8px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
display: flex;
align-items: center;
gap: 15px;
border-left: 4px solid #667eea;
}
.image-card:hover {
transform: scale(1.05);
.image-item:hover {
background: #f0f0f0;
}
.image-card img {
width: 100%;
height: 120px;
object-fit: cover;
.image-item.analyzed {
border-left-color: #4CAF50;
}
.image-card-info {
padding: 8px;
font-size: 12px;
.image-item.unanalyzed {
border-left-color: #f44336;
}
.image-card-info .time {
color: #888;
.image-item:hover {
background: #f0f0f0;
}
.image-card-info .analyzed {
color: #4CAF50;
.image-number {
font-size: 18px;
font-weight: bold;
color: #667eea;
min-width: 60px;
}
.image-card-info .unanalyzed {
.image-time {
color: #888;
font-size: 13px;
min-width: 140px;
}
.image-status {
font-size: 13px;
min-width: 80px;
}
.image-status.analyzed {
color: #4CAF50;
}
.image-status.unanalyzed {
color: #f44336;
}
.image-events-summary {
color: #555;
font-size: 13px;
flex: 1;
}
/* 事件列表 */
.events-list {
max-height: 500px;
@@ -261,6 +267,11 @@ button {
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid #667eea;
cursor: pointer;
}
.event-card:hover {
background: #f0f0f0;
}
.event-card.type-人物活动 {
@@ -341,6 +352,10 @@ button {
position: relative;
}
.settings-modal-content {
max-width: 600px;
}
.modal-close {
position: absolute;
top: 10px;
@@ -383,6 +398,61 @@ button {
gap: 10px;
}
/* 设置模态框 */
.settings-section {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.settings-section:last-of-type {
border-bottom: none;
}
.settings-section h4 {
margin-bottom: 15px;
color: #667eea;
}
.setting-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.setting-item label {
min-width: 140px;
color: #555;
}
.setting-item input[type="text"],
.setting-item input[type="number"],
.setting-item input[type="password"] {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 5px;
}
.setting-item input[type="checkbox"] {
width: 20px;
height: 20px;
}
.setting-note {
color: #888;
font-size: 12px;
margin-top: 5px;
}
.settings-actions {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
}
/* 响应式 */
@media (max-width: 768px) {
.control-panel {
@@ -396,4 +466,8 @@ button {
.stats-grid {
grid-template-columns: 1fr;
}
.image-item {
flex-wrap: wrap;
}
}