From f703c0491c7840247d0b886694f022dcdcd2957f Mon Sep 17 00:00:00 2001
From: hubian <908234780@qq.com>
Date: Thu, 16 Apr 2026 10:42:36 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20v1.1.0=20-=20=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E6=8C=89=E6=97=A5=E6=9C=9F=E5=88=86=E6=96=87=E4=BB=B6=E5=A4=B9?=
=?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=80=81=E8=AE=BE=E7=BD=AE=E9=9D=A2=E6=9D=BF?=
=?UTF-8?q?=E3=80=81=E5=BA=8F=E5=8F=B7=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=BC=8F?=
=?UTF-8?q?=E3=80=81=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
analyzer.py | 21 ++--
camera.py | 27 +++--
config.py | 98 +++++++++++++++---
database.py | 57 ++++++++---
scheduler.py | 49 +++++----
web/app.py | 108 ++++++++++++++++++--
web/static/app.js | 233 +++++++++++++++++++++++++++++-------------
web/static/index.html | 98 +++++++++++++++---
web/static/style.css | 168 +++++++++++++++++++++---------
9 files changed, 639 insertions(+), 220 deletions(-)
diff --git a/analyzer.py b/analyzer.py
index f75eeed..8e204a8 100644
--- a/analyzer.py
+++ b/analyzer.py
@@ -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("没有测试图片,请先拍照")
\ No newline at end of file
+ print("没有测试图片")
\ No newline at end of file
diff --git a/camera.py b/camera.py
index a5568b6..885fce4 100644
--- a/camera.py
+++ b/camera.py
@@ -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()
\ No newline at end of file
diff --git a/config.py b/config.py
index 099448d..e77b1e4 100644
--- a/config.py
+++ b/config.py
@@ -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);
-"""
\ No newline at end of file
+"""
+
+
+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()
\ No newline at end of file
diff --git a/database.py b/database.py
index 69b95d9..08b61d1 100644
--- a/database.py
+++ b/database.py
@@ -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
# 全局实例
diff --git a/scheduler.py b/scheduler.py
index eb16a69..07dab85 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -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()
diff --git a/web/app.py b/web/app.py
index 2d68e07..abf6077 100644
--- a/web/app.py
+++ b/web/app.py
@@ -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("
请创建 index.html
")
-# ============== 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)}
diff --git a/web/static/app.js b/web/static/app.js
index d302bff..2e22f10 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -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 = '';
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 = '';
+ 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 = '暂无图片
';
+ list.innerHTML = '暂无图片记录
';
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 = `
- ${time}
-
+
+ item.innerHTML = `
+ #${img.id}
+ ${time}
+
${img.analyzed ? '✓ 已分析' : '○ 未分析'}
-
+
+ ${img.events_summary || '无事件'}
`;
- 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 = '暂无事件
';
+ list.innerHTML = '暂无事件记录
';
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);
\ No newline at end of file
diff --git a/web/static/index.html b/web/static/index.html
index bf2122a..58abf50 100644
--- a/web/static/index.html
+++ b/web/static/index.html
@@ -14,6 +14,7 @@
状态: 加载中...
拍照次数: 0
+ 刷新间隔: 5秒
@@ -21,15 +22,6 @@
+
+
⚙️ 系统设置
+
+
+
📊 系统统计
@@ -62,15 +59,18 @@
📷 图片时间线
+
-
+
-
-
+
+
@@ -80,25 +80,91 @@
-
+
+
+
+
+
×
+
⚙️ 系统设置
+
+
+
保存设置
+
+
+
+
+
+
每天的图片会自动保存到子文件夹(如 2026-04-16)
+
+
+
+
+
+
显示设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
×
+
×
图片详情
时间:
+
日期文件夹:
-
diff --git a/web/static/style.css b/web/static/style.css
index 4229440..12f8444 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -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;
+ }
}
\ No newline at end of file