feat: v1.1.0 - 图片按日期分文件夹保存、设置面板、序号显示模式、自动刷新
This commit is contained in:
21
analyzer.py
21
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("没有测试图片,请先拍照")
|
||||
print("没有测试图片")
|
||||
27
camera.py
27
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()
|
||||
98
config.py
98
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);
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
57
database.py
57
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
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
49
scheduler.py
49
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()
|
||||
|
||||
108
web/app.py
108
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("<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)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user