commit a2682dc0405a7ac8e3633603cdddb2b9a9033f28 Author: jeremygan2021 Date: Sun Nov 16 17:21:25 2025 +0800 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..fdaeb34 --- /dev/null +++ b/.env @@ -0,0 +1,28 @@ +# 环境变量配置文件 + +# 数据库配置 +DATABASE_URL=postgresql://luna:123luna@121.43.104.161:6432/luna + +# MQTT配置 +MQTT_BROKER_HOST=localhost +MQTT_BROKER_PORT=1883 +# MQTT_USERNAME= +# MQTT_PASSWORD= + +# 应用配置 +APP_NAME=墨水屏桌面屏幕系统 +DEBUG=false + +# 文件存储配置 +STATIC_DIR=static +UPLOAD_DIR=static/uploads +PROCESSED_DIR=static/processed + +# 墨水屏配置 +INK_WIDTH=400 +INK_HEIGHT=300 + +# 安全配置 +SECRET_KEY=123tangledup-ai +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b66347 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# 环境变量配置文件 + +# 数据库配置 +DATABASE_URL=postgresql://luna:123luna@121.43.104.161:6432/luna_ink + +# MQTT配置 +MQTT_BROKER_HOST=localhost +MQTT_BROKER_PORT=1883 +# MQTT_USERNAME= +# MQTT_PASSWORD= + +# 应用配置 +APP_NAME=墨水屏桌面屏幕系统 +DEBUG=false + +# 文件存储配置 +STATIC_DIR=static +UPLOAD_DIR=static/uploads +PROCESSED_DIR=static/processed + +# 墨水屏配置 +INK_WIDTH=400 +INK_HEIGHT=300 + +# 安全配置 +SECRET_KEY=your-secret-key-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 \ No newline at end of file diff --git a/.trae/rules/project_rules.md b/.trae/rules/project_rules.md new file mode 100644 index 0000000..4193e9f --- /dev/null +++ b/.trae/rules/project_rules.md @@ -0,0 +1,21 @@ +使用fastAPI框架 +python 3.12 + +- 使用pg数据库 url http://121.43.104.161:6432 +- 用户名:luna +- 密码:123luna + +用uvicorn运行 +uvicorn main:app --host 0.0.0.0 --port 9999 + +用 conda activate luna 来激活环境 或者使用uv 来启动 + +使用./start.sh 来启动服务 + +服务器项目配置: +墨水屏硬件使用 +ESP32-S3 micropython 编写固件 +显示模块 GDEY042T81 + +通信协议 MQTT 3.1.1 + HTTP/HTTPS + NTP MQTT 保障即时性,HTTP 方便下载资源,NTP 保障时间准确 +图片处理 Pillow(Python) 服务端快速预处理图片为墨水屏兼容格式 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b72620a --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# 墨水屏桌面屏幕系统服务端 + +基于 FastAPI + MQTT + HTTP/HTTPS + NTP 的轻量级墨水屏显示系统服务端。 + +## 功能特点 + +- 设备管理:维护设备信息、状态和绑定关系 +- 内容管理:为设备创建和管理内容版本 +- 图片处理:使用 Pillow 将原始图片预处理为墨水屏兼容格式 +- MQTT 推送:实时向设备推送更新指令 +- REST API:提供设备管理和内容管理的完整接口 + +## 技术栈 + +- FastAPI: Web 框架 +- PostgreSQL: 数据库 +- Pillow: 图片处理 +- Paho-MQTT: MQTT 客户端 +- Uvicorn: ASGI 服务器 + +## 快速开始 + +1. 安装依赖: +```bash +pip install -r requirements.txt +``` + +2. 运行服务: +```bash +uvicorn main:app --host 0.0.0.0 --port 9999 +``` + +## API 接口 + +### 设备管理 +- `POST /api/devices/` - 注册新设备 +- `GET /api/devices/{device_id}/bootstrap` - 设备启动获取当前版本 +- `GET /api/devices/{device_id}/status` - 获取设备状态 + +### 内容管理 +- `POST /api/devices/{device_id}/content` - 创建新内容版本 +- `GET /api/devices/{device_id}/content` - 获取内容列表 +- `GET /api/devices/{device_id}/content/{version}` - 获取特定版本内容 +- `POST /api/upload` - 上传图片 + +### 资源下载 +- `GET /static/images/{filename}` - 下载处理后的图片 \ No newline at end of file diff --git a/__pycache__/admin_routes.cpython-312.pyc b/__pycache__/admin_routes.cpython-312.pyc new file mode 100644 index 0000000..4bd126b Binary files /dev/null and b/__pycache__/admin_routes.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..372c1ed Binary files /dev/null and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..ec055de Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..61b3b4d Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/database.cpython-313.pyc b/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..d0e8873 Binary files /dev/null and b/__pycache__/database.cpython-313.pyc differ diff --git a/__pycache__/image_processor.cpython-312.pyc b/__pycache__/image_processor.cpython-312.pyc new file mode 100644 index 0000000..c8e7343 Binary files /dev/null and b/__pycache__/image_processor.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..61615af Binary files /dev/null and b/__pycache__/main.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..ba2cf3c Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..abf0787 Binary files /dev/null and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/mqtt_manager.cpython-312.pyc b/__pycache__/mqtt_manager.cpython-312.pyc new file mode 100644 index 0000000..5224f92 Binary files /dev/null and b/__pycache__/mqtt_manager.cpython-312.pyc differ diff --git a/__pycache__/schemas.cpython-312.pyc b/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..7dd38c6 Binary files /dev/null and b/__pycache__/schemas.cpython-312.pyc differ diff --git a/admin_routes.py b/admin_routes.py new file mode 100644 index 0000000..2f2a4a8 --- /dev/null +++ b/admin_routes.py @@ -0,0 +1,344 @@ +from fastapi import APIRouter, Request, Form, File, UploadFile, Depends, HTTPException, status +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session +from typing import Optional, List +import json +import os +import secrets + +from database import get_db +from models import Device as DeviceModel, Content as ContentModel +from schemas import DeviceCreate, ContentCreate +from image_processor import image_processor +from mqtt_manager import mqtt_manager + +# 创建模板对象 +templates = Jinja2Templates(directory="templates") + +# 创建管理后台路由 +admin_router = APIRouter() + +# 管理后台路由 +@admin_router.get("/", response_class=HTMLResponse) +async def admin_dashboard(request: Request, db: Session = Depends(get_db)): + """ + 管理后台首页 + """ + # 获取设备数量 + device_count = db.query(DeviceModel).count() + active_device_count = db.query(DeviceModel).filter(DeviceModel.is_active == True).count() + + # 获取内容数量 + content_count = db.query(ContentModel).count() + active_content_count = db.query(ContentModel).filter(ContentModel.is_active == True).count() + + # 获取最近上线的设备 + recent_devices = db.query(DeviceModel).order_by(DeviceModel.last_online.desc()).limit(5).all() + + # 获取最近创建的内容 + recent_contents = db.query(ContentModel).order_by(ContentModel.created_at.desc()).limit(5).all() + + return templates.TemplateResponse("admin/dashboard.html", { + "request": request, + "device_count": device_count, + "active_device_count": active_device_count, + "content_count": content_count, + "active_content_count": active_content_count, + "recent_devices": recent_devices, + "recent_contents": recent_contents + }) + +@admin_router.get("/devices", response_class=HTMLResponse) +async def devices_list(request: Request, db: Session = Depends(get_db)): + """ + 设备列表页面 + """ + devices = db.query(DeviceModel).order_by(DeviceModel.created_at.desc()).all() + return templates.TemplateResponse("admin/devices.html", { + "request": request, + "devices": devices + }) + +@admin_router.get("/devices/{device_id}", response_class=HTMLResponse) +async def device_detail(request: Request, device_id: str, db: Session = Depends(get_db)): + """ + 设备详情页面 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + + # 获取设备的内容 + contents = db.query(ContentModel).filter(ContentModel.device_id == device_id).order_by(ContentModel.version.desc()).all() + + return templates.TemplateResponse("admin/device_detail.html", { + "request": request, + "device": device, + "contents": contents + }) + +@admin_router.get("/devices/add", response_class=HTMLResponse) +@admin_router.post("/devices/add", response_class=HTMLResponse) +async def add_device(request: Request, db: Session = Depends(get_db)): + """ + 添加设备页面和处理 + """ + if request.method == "GET": + return templates.TemplateResponse("admin/add_device.html", {"request": request}) + + # 处理POST请求 + form = await request.form() + device_id = form.get("device_id") + name = form.get("name") + scene = form.get("scene") + + # 检查设备ID是否已存在 + existing_device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if existing_device: + return templates.TemplateResponse("admin/add_device.html", { + "request": request, + "error": "设备ID已存在" + }) + + # 创建新设备 + secret = secrets.token_urlsafe(32) + new_device = DeviceModel( + device_id=device_id, + secret=secret, + name=name, + scene=scene + ) + + db.add(new_device) + db.commit() + + # 订阅设备状态 + mqtt_manager.subscribe_to_device_status(device_id) + + return RedirectResponse(url="/admin/devices", status_code=303) + +@admin_router.get("/contents", response_class=HTMLResponse) +async def contents_list(request: Request, device_id: Optional[str] = None, db: Session = Depends(get_db)): + """ + 内容列表页面 + """ + if device_id: + # 获取特定设备的内容 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + + contents = db.query(ContentModel).filter(ContentModel.device_id == device_id).order_by(ContentModel.version.desc()).all() + return templates.TemplateResponse("admin/contents.html", { + "request": request, + "contents": contents, + "device": device, + "filtered": True + }) + else: + # 获取所有内容 + contents = db.query(ContentModel).order_by(ContentModel.created_at.desc()).all() + devices = db.query(DeviceModel).all() + + # 为每个内容添加设备信息 + content_list = [] + for content in contents: + device = db.query(DeviceModel).filter(DeviceModel.device_id == content.device_id).first() + content_list.append({ + "content": content, + "device": device + }) + + return templates.TemplateResponse("admin/contents.html", { + "request": request, + "content_list": content_list, + "devices": devices, + "filtered": False + }) + +@admin_router.get("/contents/{device_id}/{version}", response_class=HTMLResponse) +async def content_detail(request: Request, device_id: str, version: int, db: Session = Depends(get_db)): + """ + 内容详情页面 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="设备不存在") + + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == version + ).first() + + if not content: + raise HTTPException(status_code=404, detail="内容不存在") + + # 解析布局配置 + layout_config = None + if content.layout_config: + try: + layout_config = json.loads(content.layout_config) + except json.JSONDecodeError: + layout_config = None + + return templates.TemplateResponse("admin/content_detail.html", { + "request": request, + "device": device, + "content": content, + "layout_config": layout_config + }) + +@admin_router.get("/contents/add", response_class=HTMLResponse) +@admin_router.post("/contents/add", response_class=HTMLResponse) +async def add_content(request: Request, device_id: Optional[str] = None, db: Session = Depends(get_db)): + """ + 添加内容页面和处理 + """ + if request.method == "GET": + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/add_content.html", { + "request": request, + "devices": devices, + "selected_device": device_id + }) + + # 处理POST请求 + form = await request.form() + device_id = form.get("device_id") + title = form.get("title") + description = form.get("description") + timezone = form.get("timezone", "Asia/Shanghai") + time_format = form.get("time_format", "%Y-%m-%d %H:%M") + + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/add_content.html", { + "request": request, + "devices": devices, + "error": "设备不存在" + }) + + # 获取当前最大版本号 + from sqlalchemy import func + max_version = db.query(func.max(ContentModel.version)).filter( + ContentModel.device_id == device_id + ).scalar() or 0 + + # 创建新内容 + new_content = ContentModel( + device_id=device_id, + version=max_version + 1, + title=title, + description=description, + timezone=timezone, + time_format=time_format, + is_active=True + ) + + db.add(new_content) + db.commit() + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, new_content.version) + + return RedirectResponse(url=f"/admin/devices/{device_id}", status_code=303) + +@admin_router.get("/upload", response_class=HTMLResponse) +@admin_router.post("/upload", response_class=HTMLResponse) +async def upload_image(request: Request, db: Session = Depends(get_db)): + """ + 图片上传页面和处理 + """ + if request.method == "GET": + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/upload_image.html", { + "request": request, + "devices": devices + }) + + # 处理POST请求 + form = await request.form() + device_id = form.get("device_id") + version = form.get("version") + file: UploadFile = form.get("image") + + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/upload_image.html", { + "request": request, + "devices": devices, + "error": "设备不存在" + }) + + # 检查文件类型 + if not file.content_type.startswith("image/"): + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/upload_image.html", { + "request": request, + "devices": devices, + "error": "文件必须是图片格式" + }) + + try: + # 保存上传的文件 + file_data = await file.read() + upload_path = image_processor.save_upload(file_data, file.filename) + + # 处理图片 + processed_path = image_processor.process_image(upload_path) + + # 如果提供了版本号,更新指定版本 + if version: + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == int(version) + ).first() + + if not content: + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/upload_image.html", { + "request": request, + "devices": devices, + "error": "指定版本的内容不存在" + }) + + content.image_path = processed_path + db.commit() + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, int(version)) + else: + # 创建新内容版本 + from sqlalchemy import func + max_version = db.query(func.max(ContentModel.version)).filter( + ContentModel.device_id == device_id + ).scalar() or 0 + + content = ContentModel( + device_id=device_id, + version=max_version + 1, + image_path=processed_path, + title=f"图片内容 - {file.filename}", + is_active=True + ) + + db.add(content) + db.commit() + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, content.version) + + return RedirectResponse(url=f"/admin/devices/{device_id}", status_code=303) + + except Exception as e: + devices = db.query(DeviceModel).filter(DeviceModel.is_active == True).all() + return templates.TemplateResponse("admin/upload_image.html", { + "request": request, + "devices": devices, + "error": f"图片处理失败: {str(e)}" + }) \ No newline at end of file diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..dd27b23 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter +from api import devices, contents + +api_router = APIRouter() + +# 注册所有路由 +api_router.include_router(devices.router) +api_router.include_router(contents.router) \ No newline at end of file diff --git a/api/__pycache__/__init__.cpython-312.pyc b/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..bc30392 Binary files /dev/null and b/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/__pycache__/contents.cpython-312.pyc b/api/__pycache__/contents.cpython-312.pyc new file mode 100644 index 0000000..6d35d26 Binary files /dev/null and b/api/__pycache__/contents.cpython-312.pyc differ diff --git a/api/__pycache__/devices.cpython-312.pyc b/api/__pycache__/devices.cpython-312.pyc new file mode 100644 index 0000000..6d00788 Binary files /dev/null and b/api/__pycache__/devices.cpython-312.pyc differ diff --git a/api/contents.py b/api/contents.py new file mode 100644 index 0000000..1c6d97a --- /dev/null +++ b/api/contents.py @@ -0,0 +1,339 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query, File, UploadFile +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +import json +import os + +from database import get_db +from schemas import Content as ContentSchema, ContentCreate, ContentUpdate, ContentResponse +from models import Content as ContentModel, Device as DeviceModel +from mqtt_manager import mqtt_manager +from image_processor import image_processor +from config import settings + +router = APIRouter( + prefix="/api", + tags=["contents"] +) + +@router.post("/devices/{device_id}/content", response_model=ContentSchema, status_code=status.HTTP_201_CREATED) +async def create_content( + device_id: str, + content: ContentCreate, + db: Session = Depends(get_db) +): + """ + 为设备创建新内容版本 + """ + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 获取当前最大版本号 + max_version = db.query(func.max(ContentModel.version)).filter( + ContentModel.device_id == device_id + ).scalar() or 0 + + # 创建新内容 + db_content = ContentModel( + device_id=device_id, + version=max_version + 1, + title=content.title, + description=content.description, + image_path=content.image_path, + layout_config=content.layout_config, + timezone=content.timezone, + time_format=content.time_format + ) + + db.add(db_content) + db.commit() + db.refresh(db_content) + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, db_content.version) + + return db_content + +@router.get("/devices/{device_id}/content", response_model=List[ContentSchema]) +async def list_content( + device_id: str, + skip: int = 0, + limit: int = 100, + is_active: Optional[bool] = None, + db: Session = Depends(get_db) +): + """ + 获取设备内容列表 + """ + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + query = db.query(ContentModel).filter(ContentModel.device_id == device_id) + + if is_active is not None: + query = query.filter(ContentModel.is_active == is_active) + + contents = query.order_by(ContentModel.version.desc()).offset(skip).limit(limit).all() + return contents + +@router.get("/devices/{device_id}/content/{version}", response_model=ContentResponse) +async def get_content( + device_id: str, + version: int, + db: Session = Depends(get_db) +): + """ + 获取特定版本的内容详情 + """ + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == version + ).first() + + if not content: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="内容不存在" + ) + + # 构建图片URL + image_url = None + if content.image_path: + # 确保路径是相对路径 + rel_path = os.path.relpath(content.image_path) + image_url = f"/static/{rel_path}" + + # 解析布局配置 + layout_config = None + if content.layout_config: + try: + layout_config = json.loads(content.layout_config) + except json.JSONDecodeError: + layout_config = None + + return ContentResponse( + version=content.version, + title=content.title, + description=content.description, + image_url=image_url, + layout_config=layout_config, + timezone=content.timezone, + time_format=content.time_format, + created_at=content.created_at + ) + +@router.put("/devices/{device_id}/content/{version}", response_model=ContentSchema) +async def update_content( + device_id: str, + version: int, + content_update: ContentUpdate, + db: Session = Depends(get_db) +): + """ + 更新内容 + """ + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == version + ).first() + + if not content: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="内容不存在" + ) + + # 更新内容信息 + update_data = content_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(content, field, value) + + db.commit() + db.refresh(content) + + # 如果内容被激活,发送MQTT更新通知 + if content.is_active: + mqtt_manager.send_update_command(device_id, content.version) + + return content + +@router.delete("/devices/{device_id}/content/{version}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_content( + device_id: str, + version: int, + db: Session = Depends(get_db) +): + """ + 删除内容 + """ + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == version + ).first() + + if not content: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="内容不存在" + ) + + db.delete(content) + db.commit() + +@router.get("/devices/{device_id}/content/latest", response_model=ContentResponse) +async def get_latest_content(device_id: str, db: Session = Depends(get_db)): + """ + 获取设备的最新活跃内容 + """ + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 获取最新的活跃内容 + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.is_active == True + ).order_by(ContentModel.version.desc()).first() + + if not content: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备没有活跃内容" + ) + + # 构建图片URL + image_url = None + if content.image_path: + # 确保路径是相对路径 + rel_path = os.path.relpath(content.image_path) + image_url = f"/static/{rel_path}" + + # 解析布局配置 + layout_config = None + if content.layout_config: + try: + layout_config = json.loads(content.layout_config) + except json.JSONDecodeError: + layout_config = None + + return ContentResponse( + version=content.version, + title=content.title, + description=content.description, + image_url=image_url, + layout_config=layout_config, + timezone=content.timezone, + time_format=content.time_format, + created_at=content.created_at + ) + +@router.post("/upload") +async def upload_image( + device_id: str = Query(..., description="设备ID"), + version: Optional[int] = Query(None, description="内容版本,如果提供则更新指定版本"), + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """ + 上传图片并处理为墨水屏兼容格式 + """ + # 检查设备是否存在 + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 检查文件类型 + if not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文件必须是图片格式" + ) + + try: + # 保存上传的文件 + file_data = await file.read() + upload_path = image_processor.save_upload(file_data, file.filename) + + # 处理图片 + processed_path = image_processor.process_image(upload_path) + + # 如果提供了版本号,更新指定版本 + if version: + content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.version == version + ).first() + + if not content: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="指定版本的内容不存在" + ) + + content.image_path = processed_path + db.commit() + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, version) + else: + # 创建新内容版本 + max_version = db.query(func.max(ContentModel.version)).filter( + ContentModel.device_id == device_id + ).scalar() or 0 + + content = ContentModel( + device_id=device_id, + version=max_version + 1, + image_path=processed_path, + title=f"图片内容 - {file.filename}", + is_active=True + ) + + db.add(content) + db.commit() + db.refresh(content) + + # 发送MQTT更新通知 + mqtt_manager.send_update_command(device_id, content.version) + + # 构建图片URL + rel_path = os.path.relpath(processed_path) + image_url = f"/static/{rel_path}" + + return { + "message": "图片上传并处理成功", + "image_url": image_url, + "version": content.version if version is None else version + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"图片处理失败: {str(e)}" + ) \ No newline at end of file diff --git a/api/devices.py b/api/devices.py new file mode 100644 index 0000000..c05985a --- /dev/null +++ b/api/devices.py @@ -0,0 +1,183 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime +import secrets + +from database import get_db +from schemas import Device as DeviceSchema, DeviceCreate, DeviceUpdate, BootstrapResponse +from models import Device as DeviceModel +from database import Content as ContentModel +from mqtt_manager import mqtt_manager + +router = APIRouter( + prefix="/api/devices", + tags=["devices"] +) + +@router.post("/", response_model=DeviceSchema, status_code=status.HTTP_201_CREATED) +async def create_device(device: DeviceCreate, db: Session = Depends(get_db)): + """ + 注册新设备 + """ + # 检查设备ID是否已存在 + db_device = db.query(DeviceModel).filter(DeviceModel.device_id == device.device_id).first() + if db_device: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="设备ID已存在" + ) + + # 创建新设备 + secret = device.secret if device.secret else secrets.token_urlsafe(32) + db_device = DeviceModel( + device_id=device.device_id, + secret=secret, + name=device.name, + scene=device.scene + ) + + db.add(db_device) + db.commit() + db.refresh(db_device) + + # 订阅设备状态 + mqtt_manager.subscribe_to_device_status(device.device_id) + + return db_device + +@router.get("/", response_model=List[DeviceSchema]) +async def list_devices( + skip: int = 0, + limit: int = 100, + scene: Optional[str] = None, + is_active: Optional[bool] = None, + db: Session = Depends(get_db) +): + """ + 获取设备列表 + """ + query = db.query(DeviceModel) + + if scene: + query = query.filter(DeviceModel.scene == scene) + + if is_active is not None: + query = query.filter(DeviceModel.is_active == is_active) + + devices = query.offset(skip).limit(limit).all() + return devices + +@router.get("/{device_id}", response_model=DeviceSchema) +async def get_device(device_id: str, db: Session = Depends(get_db)): + """ + 获取设备详情 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + return device + +@router.put("/{device_id}", response_model=DeviceSchema) +async def update_device( + device_id: str, + device_update: DeviceUpdate, + db: Session = Depends(get_db) +): + """ + 更新设备信息 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 更新设备信息 + update_data = device_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(device, field, value) + + device.updated_at = datetime.utcnow() + db.commit() + db.refresh(device) + + return device + +@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_device(device_id: str, db: Session = Depends(get_db)): + """ + 删除设备 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 取消订阅设备状态 + mqtt_manager.unsubscribe_from_device_status(device_id) + + db.delete(device) + db.commit() + +@router.get("/{device_id}/bootstrap", response_model=BootstrapResponse) +async def device_bootstrap(device_id: str, db: Session = Depends(get_db)): + """ + 设备启动获取当前版本信息 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + # 更新设备最后在线时间 + device.last_online = datetime.utcnow() + db.commit() + + # 获取最新的活跃内容 + latest_content = db.query(ContentModel).filter( + ContentModel.device_id == device_id, + ContentModel.is_active == True + ).order_by(ContentModel.version.desc()).first() + + response = BootstrapResponse( + device_id=device_id, + timezone=latest_content.timezone if latest_content else "Asia/Shanghai", + time_format=latest_content.time_format if latest_content else "%Y-%m-%d %H:%M" + ) + + if latest_content: + response.content_version = latest_content.version + response.last_updated = latest_content.created_at + + return response + +@router.get("/{device_id}/status") +async def get_device_status(device_id: str, db: Session = Depends(get_db)): + """ + 获取设备状态 + """ + device = db.query(DeviceModel).filter(DeviceModel.device_id == device_id).first() + if not device: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="设备不存在" + ) + + return { + "device_id": device_id, + "name": device.name, + "scene": device.scene, + "is_active": device.is_active, + "last_online": device.last_online, + "created_at": device.created_at, + "updated_at": device.updated_at + } \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..b69c719 --- /dev/null +++ b/config.py @@ -0,0 +1,35 @@ +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + # 数据库配置 + database_url: str = "postgresql://luna:123luna@121.43.104.161:6432/luna_ink" + + # MQTT配置 + mqtt_broker_host: str = "localhost" + mqtt_broker_port: int = 1883 + mqtt_username: Optional[str] = None + mqtt_password: Optional[str] = None + + # 应用配置 + app_name: str = "墨水屏桌面屏幕系统" + debug: bool = False + + # 文件存储配置 + static_dir: str = "static" + upload_dir: str = "static/uploads" + processed_dir: str = "static/processed" + + # 墨水屏配置 + ink_width: int = 400 + ink_height: int = 300 + + # 安全配置 + secret_key: str = "your-secret-key-change-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..cd5fd0d --- /dev/null +++ b/database.py @@ -0,0 +1,57 @@ +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, Boolean, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, relationship +from datetime import datetime +from config import settings + +Base = declarative_base() + +class Device(Base): + __tablename__ = "devices" + + id = Column(Integer, primary_key=True, index=True) + device_id = Column(String(50), unique=True, index=True, nullable=False) + secret = Column(String(100), nullable=False) + name = Column(String(100), nullable=True) + scene = Column(String(100), nullable=True) + last_online = Column(DateTime, default=datetime.utcnow) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关联内容版本 + contents = relationship("Content", back_populates="device") + +class Content(Base): + __tablename__ = "contents" + + id = Column(Integer, primary_key=True, index=True) + device_id = Column(String(50), ForeignKey("devices.device_id"), nullable=False) + version = Column(Integer, nullable=False) + title = Column(String(200), nullable=True) + description = Column(Text, nullable=True) + image_path = Column(String(500), nullable=True) + layout_config = Column(Text, nullable=True) # JSON格式的布局配置 + timezone = Column(String(50), default="Asia/Shanghai") + time_format = Column(String(20), default="%Y-%m-%d %H:%M") + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # 关联设备 + device = relationship("Device", back_populates="contents") + +# 创建数据库连接 +engine = create_engine(settings.database_url) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建所有表 +def init_db(): + Base.metadata.create_all(bind=engine) + +# 获取数据库会话 +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/image_processor.py b/image_processor.py new file mode 100644 index 0000000..6c7d327 --- /dev/null +++ b/image_processor.py @@ -0,0 +1,134 @@ +import os +import uuid +from PIL import Image, ImageOps +from typing import Tuple, Optional +from config import settings + +class ImageProcessor: + def __init__(self): + self.width = settings.ink_width + self.height = settings.ink_height + self.upload_dir = settings.upload_dir + self.processed_dir = settings.processed_dir + + # 确保目录存在 + os.makedirs(self.upload_dir, exist_ok=True) + os.makedirs(self.processed_dir, exist_ok=True) + + def process_image(self, image_path: str, grayscale: bool = True, dither: bool = True) -> str: + """ + 处理上传的图片,使其适配墨水屏显示 + + Args: + image_path: 原始图片路径 + grayscale: 是否转换为灰度图 + dither: 是否使用抖动算法 + + Returns: + 处理后图片的相对路径 + """ + try: + # 打开原始图片 + img = Image.open(image_path) + + # 转换为RGB模式(处理RGBA等格式) + if img.mode != 'RGB': + img = img.convert('RGB') + + # 自动旋转(基于EXIF信息) + img = ImageOps.exif_transpose(img) + + # 计算缩放比例,保持宽高比 + img_ratio = img.width / img.height + target_ratio = self.width / self.height + + if img_ratio > target_ratio: + # 图片较宽,以高度为准 + new_height = self.height + new_width = int(self.height * img_ratio) + else: + # 图片较高,以宽度为准 + new_width = self.width + new_height = int(self.width / img_ratio) + + # 缩放图片 + img = img.resize((new_width, new_height), Image.LANCZOS) + + # 居中裁剪到目标尺寸 + left = (new_width - self.width) // 2 + top = (new_height - self.height) // 2 + right = left + self.width + bottom = top + self.height + img = img.crop((left, top, right, bottom)) + + # 转换为灰度图 + if grayscale: + img = img.convert('L') + + # 生成处理后的文件名 + filename = f"{uuid.uuid4()}.bmp" + processed_path = os.path.join(self.processed_dir, filename) + + # 保存为BMP格式(墨水屏易解析) + if grayscale: + # 黑白图片,使用抖动算法 + if dither: + img.convert('1').save(processed_path) + else: + img.convert('1', dither=Image.NONE).save(processed_path) + else: + # 彩色图片,转换为RGB模式 + img.save(processed_path) + + # 返回相对路径 + return os.path.relpath(processed_path) + + except Exception as e: + raise Exception(f"图片处理失败: {str(e)}") + + def save_upload(self, file_data: bytes, filename: str) -> str: + """ + 保存上传的原始文件 + + Args: + file_data: 文件二进制数据 + filename: 原始文件名 + + Returns: + 保存后的文件路径 + """ + # 生成唯一文件名 + file_ext = os.path.splitext(filename)[1] + unique_filename = f"{uuid.uuid4()}{file_ext}" + upload_path = os.path.join(self.upload_dir, unique_filename) + + # 保存文件 + with open(upload_path, "wb") as f: + f.write(file_data) + + return upload_path + + def get_image_info(self, image_path: str) -> dict: + """ + 获取图片信息 + + Args: + image_path: 图片路径 + + Returns: + 图片信息字典 + """ + try: + with Image.open(image_path) as img: + return { + "width": img.width, + "height": img.height, + "mode": img.mode, + "format": img.format, + "size_bytes": os.path.getsize(image_path) + } + except Exception as e: + raise Exception(f"获取图片信息失败: {str(e)}") + +# 全局图片处理器实例 +image_processor = ImageProcessor() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ebfbc88 --- /dev/null +++ b/main.py @@ -0,0 +1,111 @@ +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import logging +import os + +from config import settings +from database import init_db +from mqtt_manager import mqtt_manager +from api import api_router +from admin_routes import admin_router + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动时执行 + logger.info("正在启动墨水屏桌面屏幕系统服务端...") + + # 初始化数据库 + try: + init_db() + logger.info("数据库初始化完成") + except Exception as e: + logger.error(f"数据库初始化失败: {str(e)}") + + # 连接MQTT代理 + try: + mqtt_manager.connect() + logger.info("MQTT连接已启动") + except Exception as e: + logger.error(f"MQTT连接失败: {str(e)}") + + # 确保静态文件目录存在 + os.makedirs(settings.static_dir, exist_ok=True) + + yield + + # 关闭时执行 + logger.info("正在关闭墨水屏桌面屏幕系统服务端...") + + # 断开MQTT连接 + mqtt_manager.disconnect() + logger.info("MQTT连接已断开") + +# 创建FastAPI应用 +app = FastAPI( + title=settings.app_name, + description="基于 FastAPI + MQTT + HTTP/HTTPS + NTP 的轻量级墨水屏显示系统服务端", + version="1.0.0", + lifespan=lifespan +) + +# 添加CORS中间件 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 在生产环境中应该设置具体的允许来源 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 挂载静态文件 +app.mount("/static", StaticFiles(directory=settings.static_dir), name="static") + +# 注册API路由 +app.include_router(api_router) + +# 包含管理后台路由 +app.include_router(admin_router, prefix="/admin", tags=["管理后台"]) + +# 根路径 +@app.get("/") +async def root(): + return { + "message": "墨水屏桌面屏幕系统服务端", + "version": "1.0.0", + "docs": "/docs", + "redoc": "/redoc" + } + +# 健康检查 +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "mqtt_connected": mqtt_manager.connected + } + +# 中间件:记录请求日志 +@app.middleware("http") +async def log_requests(request: Request, call_next): + logger.info(f"请求: {request.method} {request.url}") + response = await call_next(request) + logger.info(f"响应状态码: {response.status_code}") + return response + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host="0.0.0.0", + port=9999, + reload=settings.debug + ) \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..c180430 --- /dev/null +++ b/models.py @@ -0,0 +1,7 @@ +from sqlalchemy.orm import Session +from database import Base + +# 导入所有模型以确保它们被注册到Base.metadata +from database import Device, Content + +__all__ = ["Device", "Content"] \ No newline at end of file diff --git a/mqtt_manager.py b/mqtt_manager.py new file mode 100644 index 0000000..d1306fb --- /dev/null +++ b/mqtt_manager.py @@ -0,0 +1,170 @@ +import json +import logging +import time +from typing import Optional, Dict, Any +import paho.mqtt.client as mqtt +from schemas import MQTTCommand, MQTTStatus +from config import settings + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class MQTTManager: + def __init__(self): + self.client = mqtt.Client() + self.connected = False + self.setup_client() + + def setup_client(self): + """设置MQTT客户端""" + # 设置回调函数 + self.client.on_connect = self.on_connect + self.client.on_disconnect = self.on_disconnect + self.client.on_message = self.on_message + + # 设置认证信息(如果有) + if settings.mqtt_username and settings.mqtt_password: + self.client.username_pw_set(settings.mqtt_username, settings.mqtt_password) + + def connect(self): + """连接到MQTT代理""" + try: + self.client.connect(settings.mqtt_broker_host, settings.mqtt_broker_port, 60) + self.client.loop_start() + logger.info(f"正在连接到MQTT代理 {settings.mqtt_broker_host}:{settings.mqtt_broker_port}") + except Exception as e: + logger.error(f"连接MQTT代理失败: {str(e)}") + + def disconnect(self): + """断开MQTT连接""" + if self.connected: + self.client.loop_stop() + self.client.disconnect() + logger.info("已断开MQTT连接") + + def on_connect(self, client, userdata, flags, rc): + """连接回调函数""" + if rc == 0: + self.connected = True + logger.info("成功连接到MQTT代理") + else: + logger.error(f"连接MQTT代理失败,返回码: {rc}") + + def on_disconnect(self, client, userdata, rc): + """断开连接回调函数""" + self.connected = False + logger.warning(f"与MQTT代理断开连接,返回码: {rc}") + + def on_message(self, client, userdata, msg): + """消息接收回调函数""" + try: + topic = msg.topic + payload = msg.payload.decode("utf-8") + logger.info(f"收到MQTT消息 - 主题: {topic}, 内容: {payload}") + + # 解析设备状态上报 + if "/status" in topic: + device_id = topic.split("/")[1] + status_data = json.loads(payload) + self.handle_device_status(device_id, status_data) + + except Exception as e: + logger.error(f"处理MQTT消息失败: {str(e)}") + + def handle_device_status(self, device_id: str, status_data: Dict[str, Any]): + """处理设备状态上报""" + try: + # 这里可以更新设备状态到数据库 + # 例如:更新最后在线时间、处理错误状态等 + logger.info(f"设备 {device_id} 状态上报: {status_data}") + except Exception as e: + logger.error(f"处理设备状态失败: {str(e)}") + + def publish_command(self, device_id: str, command: MQTTCommand) -> bool: + """ + 向设备发布命令 + + Args: + device_id: 设备ID + command: 命令对象 + + Returns: + 是否发布成功 + """ + if not self.connected: + logger.error("MQTT未连接,无法发布命令") + return False + + try: + topic = f"esp32/{device_id}/cmd" + payload = command.model_dump_json() + + result = self.client.publish(topic, payload) + if result.rc == mqtt.MQTT_ERR_SUCCESS: + logger.info(f"成功向设备 {device_id} 发布命令: {payload}") + return True + else: + logger.error(f"向设备 {device_id} 发布命令失败,错误码: {result.rc}") + return False + + except Exception as e: + logger.error(f"发布命令失败: {str(e)}") + return False + + def send_update_command(self, device_id: str, content_version: int) -> bool: + """ + 发送更新命令 + + Args: + device_id: 设备ID + content_version: 内容版本 + + Returns: + 是否发送成功 + """ + command = MQTTCommand( + type="update", + content_version=content_version, + timestamp=int(time.time()) + ) + return self.publish_command(device_id, command) + + def subscribe_to_device_status(self, device_id: str): + """ + 订阅设备状态 + + Args: + device_id: 设备ID + """ + if not self.connected: + logger.error("MQTT未连接,无法订阅") + return + + try: + topic = f"esp32/{device_id}/status" + self.client.subscribe(topic) + logger.info(f"已订阅设备 {device_id} 状态") + except Exception as e: + logger.error(f"订阅设备状态失败: {str(e)}") + + def unsubscribe_from_device_status(self, device_id: str): + """ + 取消订阅设备状态 + + Args: + device_id: 设备ID + """ + if not self.connected: + logger.error("MQTT未连接,无法取消订阅") + return + + try: + topic = f"esp32/{device_id}/status" + self.client.unsubscribe(topic) + logger.info(f"已取消订阅设备 {device_id} 状态") + except Exception as e: + logger.error(f"取消订阅设备状态失败: {str(e)}") + +# 全局MQTT管理器实例 +mqtt_manager = MQTTManager() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..898bea2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +pillow==10.1.0 +paho-mqtt==1.6.1 +python-multipart==0.0.6 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +aiofiles==23.2.1 +httpx==0.25.2 \ No newline at end of file diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..9bd92a5 --- /dev/null +++ b/schemas.py @@ -0,0 +1,89 @@ +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +from datetime import datetime + +# 设备相关模型 +class DeviceBase(BaseModel): + device_id: str = Field(..., description="设备唯一标识") + name: Optional[str] = Field(None, description="设备名称") + scene: Optional[str] = Field(None, description="设备使用场景") + +class DeviceCreate(DeviceBase): + secret: str = Field(..., description="设备密钥") + +class DeviceUpdate(BaseModel): + name: Optional[str] = None + scene: Optional[str] = None + is_active: Optional[bool] = None + +class Device(DeviceBase): + id: int + last_online: datetime + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +# 内容相关模型 +class ContentBase(BaseModel): + title: Optional[str] = Field(None, description="内容标题") + description: Optional[str] = Field(None, description="内容描述") + image_path: Optional[str] = Field(None, description="图片路径") + layout_config: Optional[str] = Field(None, description="布局配置JSON") + timezone: str = Field("Asia/Shanghai", description="时区") + time_format: str = Field("%Y-%m-%d %H:%M", description="时间显示格式") + +class ContentCreate(ContentBase): + device_id: str = Field(..., description="设备ID") + +class ContentUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + image_path: Optional[str] = None + layout_config: Optional[str] = None + timezone: Optional[str] = None + time_format: Optional[str] = None + is_active: Optional[bool] = None + +class Content(ContentBase): + id: int + device_id: str + version: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +# 响应模型 +class BootstrapResponse(BaseModel): + device_id: str + content_version: Optional[int] = None + timezone: str + time_format: str + last_updated: Optional[datetime] = None + +class ContentResponse(BaseModel): + version: int + title: Optional[str] = None + description: Optional[str] = None + image_url: Optional[str] = None + layout_config: Optional[Dict[str, Any]] = None + timezone: str + time_format: str + created_at: datetime + +# MQTT消息模型 +class MQTTCommand(BaseModel): + type: str = Field(..., description="命令类型") + content_version: Optional[int] = None + timestamp: Optional[int] = None + +class MQTTStatus(BaseModel): + event: str = Field(..., description="事件类型") + content_version: Optional[int] = None + timestamp: int = Field(..., description="时间戳") + device_id: str = Field(..., description="设备ID") + message: Optional[str] = None \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..5863881 --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# 启动脚本 + +# 检查Python版本 +python_version=$(python3 --version 2>&1 | awk '{print $2}' | cut -d. -f1,2) +required_version="3.8" + +if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then + echo "错误: 需要Python 3.8或更高版本,当前版本: $python_version" + exit 1 +fi + +# 检查是否存在虚拟环境 +if [ ! -d "venv" ]; then + echo "创建虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +echo "激活虚拟环境..." +source venv/bin/activate + +# 安装依赖 +echo "安装依赖..." +pip install -r requirements.txt + +# 复制环境变量文件(如果不存在) +if [ ! -f ".env" ]; then + echo "创建环境变量文件..." + cp .env.example .env + echo "请根据需要修改 .env 文件中的配置" +fi + +# 启动应用 +echo "启动墨水屏桌面屏幕系统服务端..." +uvicorn main:app --host 0.0.0.0 --port 9999 diff --git a/static/admin/css/admin-enhanced.css b/static/admin/css/admin-enhanced.css new file mode 100644 index 0000000..9a566f0 --- /dev/null +++ b/static/admin/css/admin-enhanced.css @@ -0,0 +1,868 @@ +/* 管理后台美化样式 - 默认主题 */ + +/* 全局样式 */ +:root { + --primary-color: #4e73df; + --secondary-color: #858796; + --success-color: #1cc88a; + --info-color: #36b9cc; + --warning-color: #f6c23e; + --danger-color: #e74a3b; + --light-color: #f8f9fc; + --dark-color: #5a5c69; + --sidebar-bg: #4e73df; + --sidebar-hover: #2e59d9; + --card-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15); + --border-radius: 0.5rem; + --transition: all 0.3s ease; +} + +/* 圣诞节主题变量 */ +.christmas-theme { + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --success-color: #198754; + --info-color: #0dcaf0; + --warning-color: #ffc107; + --danger-color: #dc3545; + --light-color: #f8f9fa; + --dark-color: #212529; + --sidebar-bg: #0f5132; + --sidebar-hover: #0a3622; + --card-shadow: 0 0.15rem 1.75rem 0 rgba(0, 0, 0, 0.15); + --border-radius: 0.5rem; + --transition: all 0.3s ease; +} + +/* 基础样式重置 */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--light-color); + color: var(--dark-color); + transition: var(--transition); +} + +/* 导航栏样式 */ +.navbar { + box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 1rem 0; + transition: var(--transition); +} + +.navbar-brand { + font-weight: 700; + font-size: 1.25rem; + color: white !important; + display: flex; + align-items: center; +} + +.navbar-brand i { + margin-right: 0.5rem; +} + +.nav-link { + font-weight: 500; + color: rgba(255, 255, 255, 0.8) !important; + transition: var(--transition); + border-radius: var(--border-radius); + margin: 0 0.25rem; + padding: 0.5rem 1rem !important; +} + +.nav-link:hover, .nav-link.active { + color: white !important; + background-color: rgba(255, 255, 255, 0.1); +} + +/* 主题切换按钮 */ +.theme-toggle { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + background: white; + border-radius: 50%; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--card-shadow); + cursor: pointer; + transition: var(--transition); + border: none; +} + +.theme-toggle:hover { + transform: scale(1.1); +} + +.theme-toggle i { + font-size: 1.25rem; + color: var(--primary-color); +} + +/* 主内容区域 */ +.main-content { + padding: 1.5rem; + transition: var(--transition); +} + +/* 页面标题 */ +.page-title { + font-weight: 700; + color: var(--dark-color); + margin-bottom: 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.page-title h1 { + margin: 0; + font-size: 1.75rem; +} + +.page-title .breadcrumb { + background: transparent; + padding: 0; + margin: 0; +} + +/* 卡片样式 */ +.card { + border: none; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + transition: var(--transition); + overflow: hidden; + margin-bottom: 1.5rem; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.card-header { + background-color: white; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 1rem 1.25rem; + font-weight: 700; + color: var(--dark-color); +} + +.card-body { + padding: 1.25rem; +} + +/* 统计卡片 */ +.stat-card { + position: relative; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background-color: var(--primary-color); +} + +.stat-card.primary::before { + background-color: var(--primary-color); +} + +.stat-card.success::before { + background-color: var(--success-color); +} + +.stat-card.info::before { + background-color: var(--info-color); +} + +.stat-card.warning::before { + background-color: var(--warning-color); +} + +.stat-card .card-body { + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-card .stat-info { + flex: 1; +} + +.stat-card .stat-value { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-card .stat-label { + font-size: 0.875rem; + font-weight: 500; + color: var(--secondary-color); + text-transform: uppercase; +} + +.stat-card .stat-icon { + width: 60px; + height: 60px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 1.5rem; +} + +.stat-card.primary .stat-icon { + background-color: var(--primary-color); +} + +.stat-card.success .stat-icon { + background-color: var(--success-color); +} + +.stat-card.info .stat-icon { + background-color: var(--info-color); +} + +.stat-card.warning .stat-icon { + background-color: var(--warning-color); +} + +/* 表格样式 */ +.table-container { + background: white; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + overflow: hidden; +} + +.table { + margin-bottom: 0; +} + +.table th { + border-top: none; + font-weight: 600; + font-size: 0.875rem; + color: var(--dark-color); + background-color: var(--light-color); + padding: 1rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.table td { + font-size: 0.875rem; + padding: 1rem; + vertical-align: middle; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.table tbody tr:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +/* 按钮样式 */ +.btn { + border-radius: var(--border-radius); + font-weight: 500; + padding: 0.5rem 1rem; + transition: var(--transition); + border: none; +} + +.btn-primary { + background-color: var(--primary-color); +} + +.btn-success { + background-color: var(--success-color); +} + +.btn-info { + background-color: var(--info-color); +} + +.btn-warning { + background-color: var(--warning-color); +} + +.btn-danger { + background-color: var(--danger-color); +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +/* 表单样式 */ +.form-control, .form-select { + border-radius: var(--border-radius); + border: 1px solid rgba(0, 0, 0, 0.1); + padding: 0.75rem; + transition: var(--transition); +} + +.form-control:focus, .form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25); +} + +.form-label { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--dark-color); +} + +/* 图片上传区域样式 */ +.upload-area { + position: relative; + overflow: hidden; + border: 2px dashed var(--border-color); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + background-color: var(--light-bg); + cursor: pointer; + transition: all 0.3s ease; +} + +.upload-area:hover { + border-color: var(--primary-color); + background-color: rgba(13, 110, 253, 0.05); +} + +.upload-area.drag-over { + border-color: var(--primary-color); + background-color: rgba(13, 110, 253, 0.1); + transform: scale(1.02); +} + +.upload-area input[type="file"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + z-index: 1; +} + +.upload-overlay { + pointer-events: none; +} + +.upload-content { + color: var(--text-muted); +} + +.upload-content p { + margin-bottom: 0; +} + +/* 图片预览样式 */ +.preview-container { + position: relative; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background-color: var(--light-bg); +} + +.preview-container img { + display: block; + max-height: 400px; + margin: 0 auto; +} + +.preview-actions { + position: absolute; + top: 10px; + right: 10px; + opacity: 0; + transition: opacity 0.3s ease; +} + +.preview-container:hover .preview-actions { + opacity: 1; +} + +/* 处理步骤样式 */ +.process-steps { + position: relative; + padding-left: 30px; +} + +.step-item { + position: relative; + padding-bottom: 20px; + padding-left: 20px; +} + +.step-item:not(:last-child):before { + content: ''; + position: absolute; + top: 24px; + left: 9px; + height: calc(100% - 4px); + width: 2px; + background-color: var(--border-color); +} + +.step-number { + position: absolute; + left: -30px; + top: 0; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--primary-color); + color: white; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +.step-text { + color: var(--text-color); + font-size: 14px; +} + +/* 圣诞节主题特殊样式 */ +.christmas-theme .upload-area { + border-color: #c41e3a; + background-color: rgba(196, 30, 58, 0.05); +} + +.christmas-theme .upload-area:hover { + border-color: #165b33; + background-color: rgba(22, 91, 51, 0.05); +} + +.christmas-theme .upload-area.drag-over { + border-color: #ffd700; + background-color: rgba(255, 215, 0, 0.1); +} + +.christmas-theme .step-number { + background-color: #c41e3a; +} + +.christmas-theme .step-item:not(:last-child):before { + background-color: #165b33; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .upload-area { + padding: 30px 15px; + } + + .preview-container img { + max-height: 250px; + } +} + +/* 预警框样式 */ +.alert { + border: none; + border-radius: var(--border-radius); + padding: 1rem; + margin-bottom: 1rem; +} + +/* 徽章样式 */ +.badge { + font-size: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius); +} + +/* 分页样式 */ +.pagination { + margin-bottom: 0; +} + +.page-link { + border: none; + color: var(--primary-color); + padding: 0.5rem 0.75rem; + margin: 0 0.125rem; + border-radius: var(--border-radius); +} + +.page-item.active .page-link { + background-color: var(--primary-color); +} + +/* 圣诞节主题特殊样式 */ +.christmas-theme { + background-color: #f8f9fa; + background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%23dc3545' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E"); +} + +.christmas-theme .navbar { + background: linear-gradient(to right, #0f5132, #198754) !important; + position: relative; +} + +.christmas-theme .navbar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 5px; + background: repeating-linear-gradient( + 90deg, + #dc3545, + #dc3545 10px, + #ffc107 10px, + #ffc107 20px, + #198754 20px, + #198754 30px + ); +} + +.christmas-theme .theme-toggle { + background: #f8f9fa; + border: 2px solid #dc3545; +} + +.christmas-theme .theme-toggle i { + color: #dc3545; +} + +.christmas-theme .card { + border-top: 3px solid #198754; + position: relative; + overflow: hidden; +} + +.christmas-theme .card::after { + content: ''; + position: absolute; + top: 10px; + right: 10px; + width: 20px; + height: 20px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23dc3545'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E"); + background-size: contain; + opacity: 0.7; +} + +.christmas-theme .stat-card::after { + content: ''; + position: absolute; + bottom: -10px; + right: -10px; + width: 40px; + height: 40px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23198754'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'/%3E%3Ccircle cx='12' cy='12' r='5'/%3E%3C/svg%3E"); + background-size: contain; + opacity: 0.7; +} + +.christmas-theme .navbar-brand::before { + content: '🎄 '; + margin-right: 0.5rem; +} + +.christmas-theme .btn-primary { + background-color: #0f5132; + border-color: #0f5132; +} + +.christmas-theme .btn-primary:hover { + background-color: #0a3622; + border-color: #0a3622; +} + +.christmas-theme .btn-success { + background-color: #198754; + border-color: #198754; +} + +.christmas-theme .btn-success:hover { + background-color: #146c43; + border-color: #146c43; +} + +.christmas-theme .btn-info { + background-color: #0dcaf0; + border-color: #0dcaf0; +} + +.christmas-theme .btn-warning { + background-color: #ffc107; + border-color: #ffc107; + color: #000; +} + +.christmas-theme .btn-danger { + background-color: #dc3545; + border-color: #dc3545; +} + +.christmas-theme .page-link { + color: #0f5132; +} + +.christmas-theme .page-item.active .page-link { + background-color: #0f5132; + border-color: #0f5132; +} + +.christmas-theme .table thead th { + background-color: #f8f9fa; + color: #0f5132; + border-bottom: 2px solid #198754; +} + +.christmas-theme .badge.bg-primary { + background-color: #0f5132 !important; +} + +.christmas-theme .badge.bg-success { + background-color: #198754 !important; +} + +.christmas-theme .badge.bg-info { + background-color: #0dcaf0 !important; + color: #000 !important; +} + +.christmas-theme .badge.bg-warning { + background-color: #ffc107 !important; + color: #000 !important; +} + +.christmas-theme .badge.bg-danger { + background-color: #dc3545 !important; +} + +/* 雪花效果 */ +.snowflake { + position: fixed; + top: -10px; + z-index: 9999; + user-select: none; + cursor: default; + animation: snowfall linear infinite; + color: #fff; + font-size: 1em; + opacity: 0.8; +} + +@keyframes snowfall { + 0% { + transform: translateY(-100px); + } + 100% { + transform: translateY(calc(100vh + 100px)); + } +} + +/* 圣诞装饰横幅 */ +.christmas-decorations { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 10px; + background: repeating-linear-gradient( + 90deg, + #dc3545, + #dc3545 10px, + #ffc107 10px, + #ffc107 20px, + #198754 20px, + #198754 30px + ); + z-index: 10; +} + +/* 圣诞帽 */ +.christmas-hat { + display: inline-block; + font-size: 1.2em; + margin-left: 5px; + animation: wiggle 2s ease-in-out infinite; +} + +@keyframes wiggle { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .main-content { + padding: 1rem; + } + + .stat-card .card-body { + flex-direction: column; + text-align: center; + } + + .stat-card .stat-icon { + margin-top: 1rem; + } + + .page-title { + flex-direction: column; + align-items: flex-start; + } + + .page-title .breadcrumb { + margin-top: 0.5rem; + } + + .table-container { + overflow-x: auto; + } + + .theme-toggle { + top: auto; + bottom: 20px; + right: 20px; + } +} + +/* 加载动画 */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 状态指示器 */ +.status-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 5px; +} + +.status-online { + background-color: var(--success-color); +} + +.status-offline { + background-color: var(--danger-color); +} + +.status-disabled { + background-color: var(--secondary-color); +} + +/* 设备卡片样式 */ +.device-card { + transition: var(--transition); + border-left: 4px solid var(--primary-color); +} + +.device-card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +/* 内容卡片样式 */ +.content-card { + transition: var(--transition); + border-left: 4px solid var(--info-color); +} + +.content-card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +/* 时间线样式 */ +.timeline { + position: relative; + padding: 0; + list-style: none; +} + +.timeline:before { + content: ''; + position: absolute; + top: 0; + left: 30px; + height: 100%; + width: 2px; + background: rgba(0, 0, 0, 0.1); +} + +.timeline-item { + margin-bottom: 1.5rem; + position: relative; +} + +.timeline-item:before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 24px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--primary-color); + border: 2px solid white; + z-index: 1; +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--light-color); +} + +::-webkit-scrollbar-thumb { + background: var(--secondary-color); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--dark-color); +} \ No newline at end of file diff --git a/static/admin/css/admin.css b/static/admin/css/admin.css new file mode 100644 index 0000000..c5e067f --- /dev/null +++ b/static/admin/css/admin.css @@ -0,0 +1,346 @@ +/* 管理后台样式 */ + +/* 边框颜色样式 */ +.border-left-primary { + border-left: 0.25rem solid #4e73df !important; +} + +.border-left-success { + border-left: 0.25rem solid #1cc88a !important; +} + +.border-left-info { + border-left: 0.25rem solid #36b9cc !important; +} + +.border-left-warning { + border-left: 0.25rem solid #f6c23e !important; +} + +/* 卡片阴影 */ +.shadow { + box-shadow: 0 0.15rem 1.75rem 0 rgba(58, 59, 69, 0.15) !important; +} + +/* 文本颜色 */ +.text-xs { + font-size: 0.7rem; +} + +.font-weight-bold { + font-weight: 700 !important; +} + +.text-gray-300 { + color: #dddfeb !important; +} + +.text-gray-800 { + color: #5a5c69 !important; +} + +/* 卡片悬停效果 */ +.card:hover { + transform: translateY(-2px); + transition: all 0.3s ease; +} + +/* 按钮悬停效果 */ +.btn:hover { + transform: translateY(-1px); + transition: all 0.2s ease; +} + +/* 表格样式 */ +.table th { + border-top: none; + font-weight: 600; + font-size: 0.85rem; + color: #5a5c69; + background-color: #f8f9fc; +} + +.table td { + font-size: 0.85rem; + padding: 0.75rem; + vertical-align: middle; +} + +/* 徽章样式 */ +.badge { + font-size: 0.7rem; + padding: 0.25rem 0.5rem; +} + +/* 表单样式 */ +.form-control:focus { + border-color: #bac8f3; + box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25); +} + +.form-select:focus { + border-color: #bac8f3; + box-shadow: 0 0 0 0.2rem rgba(78, 115, 223, 0.25); +} + +/* 导航栏样式 */ +.navbar-brand { + font-weight: 700; +} + +.nav-link { + font-weight: 500; +} + +.nav-link.active { + font-weight: 700; +} + +/* 页面标题样式 */ +.h2 { + font-weight: 700; + color: #5a5c69; +} + +/* 卡片标题样式 */ +.card-header { + background-color: #f8f9fc; + border-bottom: 1px solid #e3e6f0; +} + +.card-header h6 { + font-weight: 700; + font-size: 1rem; + color: #5a5c69; +} + +/* 预警框样式 */ +.alert { + border: none; + border-radius: 0.35rem; + font-size: 0.85rem; +} + +/* 图片预览样式 */ +.img-preview { + max-width: 100%; + height: auto; + border: 1px solid #ddd; + border-radius: 0.25rem; + padding: 0.25rem; +} + +/* 加载动画 */ +.spinner-border-sm { + width: 1rem; + height: 1rem; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .table-responsive { + font-size: 0.8rem; + } + + .btn-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .btn-toolbar .btn { + margin-bottom: 0.5rem; + } +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* 状态指示器 */ +.status-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 5px; +} + +.status-online { + background-color: #1cc88a; +} + +.status-offline { + background-color: #e74a3b; +} + +.status-disabled { + background-color: #858796; +} + +/* 设备卡片样式 */ +.device-card { + transition: all 0.3s ease; + border-left: 4px solid #4e73df; +} + +.device-card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +/* 内容卡片样式 */ +.content-card { + transition: all 0.3s ease; + border-left: 4px solid #36b9cc; +} + +.content-card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +/* 图片上传区域样式 */ +.upload-area { + border: 2px dashed #ddd; + border-radius: 0.35rem; + padding: 2rem; + text-align: center; + transition: all 0.3s ease; +} + +.upload-area:hover { + border-color: #4e73df; + background-color: #f8f9fc; +} + +.upload-area.dragover { + border-color: #4e73df; + background-color: #f8f9fc; +} + +/* 进度条样式 */ +.progress { + height: 0.5rem; +} + +/* 时间线样式 */ +.timeline { + position: relative; + padding: 0; + list-style: none; +} + +.timeline:before { + content: ''; + position: absolute; + top: 0; + left: 30px; + height: 100%; + width: 2px; + background: #e3e6f0; +} + +.timeline-item { + margin-bottom: 1.5rem; + position: relative; +} + +.timeline-item:before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 24px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #4e73df; + border: 2px solid #fff; + z-index: 1; +} + +/* 统计卡片动画 */ +.stat-card { + transition: all 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.stat-card .card-body { + padding: 1.25rem; +} + +/* 图标样式 */ +.icon-circle { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; +} + +.icon-circle.primary { + background-color: #4e73df; +} + +.icon-circle.success { + background-color: #1cc88a; +} + +.icon-circle.info { + background-color: #36b9cc; +} + +.icon-circle.warning { + background-color: #f6c23e; +} + +/* 深色模式支持 */ +@media (prefers-color-scheme: dark) { + .card { + background-color: #2d3748; + color: #e2e8f0; + } + + .card-header { + background-color: #4a5568; + border-bottom-color: #718096; + } + + .table { + color: #e2e8f0; + } + + .table th { + background-color: #4a5568; + color: #e2e8f0; + } + + .form-control, + .form-select { + background-color: #4a5568; + border-color: #718096; + color: #e2e8f0; + } + + .navbar-dark { + background-color: #2d3748 !important; + } +} \ No newline at end of file diff --git a/static/admin/js/admin-enhanced.js b/static/admin/js/admin-enhanced.js new file mode 100644 index 0000000..2a2a8cc --- /dev/null +++ b/static/admin/js/admin-enhanced.js @@ -0,0 +1,511 @@ +// 管理后台JavaScript功能 - 增强版 + +// 页面加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + // 初始化所有工具提示 + initTooltips(); + + // 初始化确认对话框 + initConfirmDialogs(); + + // 初始化表格排序 + initTableSorting(); + + // 初始化状态更新 + initStatusUpdates(); + + // 初始化图片上传 + initImageUpload(); + + // 初始化主题切换 + initThemeToggle(); + + // 初始化动画效果 + initAnimations(); +}); + +// 初始化主题切换 +function initThemeToggle() { + const themeToggle = document.getElementById('themeToggle'); + + // 检查本地存储的主题设置 + const savedTheme = localStorage.getItem('adminTheme') || 'default'; + applyTheme(savedTheme); + + // 设置切换按钮图标 + updateThemeToggleIcon(savedTheme); + + if (themeToggle) { + themeToggle.addEventListener('click', function() { + const currentTheme = document.body.classList.contains('christmas-theme') ? 'christmas' : 'default'; + const newTheme = currentTheme === 'christmas' ? 'default' : 'christmas'; + + applyTheme(newTheme); + localStorage.setItem('adminTheme', newTheme); + updateThemeToggleIcon(newTheme); + + // 添加切换动画 + document.body.style.transition = 'all 0.5s ease'; + + // 显示主题切换提示 + const themeName = newTheme === 'christmas' ? '圣诞节主题' : '默认主题'; + showAlert(`已切换至${themeName}`, 'success'); + }); + } +} + +// 应用主题 +function applyTheme(theme) { + if (theme === 'christmas') { + document.body.classList.add('christmas-theme'); + } else { + document.body.classList.remove('christmas-theme'); + } +} + +// 更新主题切换按钮图标 +function updateThemeToggleIcon(theme) { + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + const icon = themeToggle.querySelector('i'); + if (icon) { + if (theme === 'christmas') { + icon.className = 'fas fa-snowflake'; + } else { + icon.className = 'fas fa-palette'; + } + } + } +} + +// 初始化动画效果 +function initAnimations() { + // 添加滚动动画 + const observerOptions = { + threshold: 0.1, + rootMargin: '0px 0px -50px 0px' + }; + + const observer = new IntersectionObserver(function(entries) { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('animate-in'); + observer.unobserve(entry.target); + } + }); + }, observerOptions); + + // 观察所有卡片 + const cards = document.querySelectorAll('.card'); + cards.forEach(card => { + card.classList.add('animate-prep'); + observer.observe(card); + }); +} + +// 初始化工具提示 +function initTooltips() { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); +} + +// 初始化确认对话框 +function initConfirmDialogs() { + // 为所有带有data-confirm属性的按钮添加确认对话框 + const confirmButtons = document.querySelectorAll('[data-confirm]'); + confirmButtons.forEach(button => { + button.addEventListener('click', function(e) { + const message = this.getAttribute('data-confirm'); + if (!confirm(message)) { + e.preventDefault(); + return false; + } + }); + }); +} + +// 初始化表格排序 +function initTableSorting() { + const sortableTables = document.querySelectorAll('.table-sortable'); + sortableTables.forEach(table => { + const headers = table.querySelectorAll('th[data-sort]'); + headers.forEach(header => { + header.style.cursor = 'pointer'; + header.addEventListener('click', function() { + sortTable(table, this.getAttribute('data-sort')); + }); + }); + }); +} + +// 表格排序函数 +function sortTable(table, column) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const isAsc = table.getAttribute('data-sort-order') !== 'asc'; + + rows.sort((a, b) => { + const aValue = a.querySelector(`td[data-column="${column}"]`).textContent.trim(); + const bValue = b.querySelector(`td[data-column="${column}"]`).textContent.trim(); + + if (isAsc) { + return aValue.localeCompare(bValue); + } else { + return bValue.localeCompare(aValue); + } + }); + + // 清空表格并重新添加排序后的行 + tbody.innerHTML = ''; + rows.forEach(row => tbody.appendChild(row)); + + // 更新排序状态 + table.setAttribute('data-sort-order', isAsc ? 'asc' : 'desc'); + + // 更新排序图标 + const headers = table.querySelectorAll('th[data-sort]'); + headers.forEach(header => { + const icon = header.querySelector('.sort-icon'); + if (icon) icon.remove(); + }); + + const currentHeader = table.querySelector(`th[data-sort="${column}"]`); + const icon = document.createElement('i'); + icon.className = `sort-icon fas fa-sort-${isAsc ? 'up' : 'down'} ms-1`; + currentHeader.appendChild(icon); +} + +// 初始化状态更新 +function initStatusUpdates() { + // 定期更新设备状态 + const deviceStatusElements = document.querySelectorAll('.device-status'); + if (deviceStatusElements.length > 0) { + setInterval(updateDeviceStatus, 30000); // 每30秒更新一次 + } +} + +// 更新设备状态 +function updateDeviceStatus() { + const deviceStatusElements = document.querySelectorAll('.device-status[data-device-id]'); + deviceStatusElements.forEach(element => { + const deviceId = element.getAttribute('data-device-id'); + + fetch(`/api/devices/${deviceId}/status`) + .then(response => response.json()) + .then(data => { + if (data.online) { + element.innerHTML = '在线'; + } else { + element.innerHTML = '离线'; + } + }) + .catch(error => { + console.error('Error updating device status:', error); + }); + }); +} + +// 初始化图片上传 +function initImageUpload() { + const uploadArea = document.querySelector('.upload-area'); + const fileInput = document.querySelector('#image'); + + if (uploadArea && fileInput) { + // 点击上传区域触发文件选择 + uploadArea.addEventListener('click', function() { + fileInput.click(); + }); + + // 拖拽上传 + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + uploadArea.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, unhighlight, false); + }); + + function highlight() { + uploadArea.classList.add('dragover'); + } + + function unhighlight() { + uploadArea.classList.remove('dragover'); + } + + uploadArea.addEventListener('drop', handleDrop, false); + + function handleDrop(e) { + const dt = e.dataTransfer; + const files = dt.files; + + if (files.length) { + fileInput.files = files; + handleFiles(files); + } + } + + fileInput.addEventListener('change', function() { + handleFiles(this.files); + }); + + function handleFiles(files) { + if (files.length) { + const file = files[0]; + + // 验证文件类型 + if (!file.type.startsWith('image/')) { + showAlert('请选择图片文件', 'danger'); + return; + } + + // 验证文件大小(限制为10MB) + if (file.size > 10 * 1024 * 1024) { + showAlert('图片文件大小不能超过10MB', 'danger'); + return; + } + + // 显示图片预览 + const reader = new FileReader(); + reader.onload = function(e) { + const preview = document.querySelector('#imagePreview'); + const previewImg = document.querySelector('#previewImg'); + + if (preview && previewImg) { + previewImg.src = e.target.result; + preview.style.display = 'block'; + } + }; + reader.readAsDataURL(file); + } + } + } +} + +// 显示提示消息 +function showAlert(message, type = 'info') { + const alertContainer = document.querySelector('.alert-container') || createAlertContainer(); + + const alertElement = document.createElement('div'); + alertElement.className = `alert alert-${type} alert-dismissible fade show`; + alertElement.setAttribute('role', 'alert'); + alertElement.innerHTML = ` + ${message} + + `; + + alertContainer.appendChild(alertElement); + + // 自动关闭提示 + setTimeout(() => { + alertElement.classList.remove('show'); + setTimeout(() => { + alertElement.remove(); + }, 300); + }, 5000); +} + +// 创建提示消息容器 +function createAlertContainer() { + const container = document.createElement('div'); + container.className = 'alert-container position-fixed top-0 end-0 p-3'; + container.style.zIndex = '1050'; + document.body.appendChild(container); + return container; +} + +// 推送内容到设备 +function pushContent(deviceId, version) { + if (confirm('确定要推送此内容到设备吗?')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 推送中...'; + + fetch(`/api/devices/${deviceId}/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + version: version + }) + }) + .then(response => response.json()) + .then(data => { + showAlert('内容推送成功', 'success'); + }) + .catch(error => { + console.error('Error:', error); + showAlert('内容推送失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 刷新设备状态 +function refreshDevice(deviceId) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 刷新中...'; + + fetch(`/api/devices/${deviceId}/status`) + .then(response => response.json()) + .then(data => { + if (data.online) { + showAlert('设备在线', 'success'); + } else { + showAlert('设备离线', 'warning'); + } + location.reload(); + }) + .catch(error => { + console.error('Error:', error); + showAlert('获取设备状态失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); +} + +// 删除设备 +function deleteDevice(deviceId) { + if (confirm('确定要删除此设备吗?此操作不可恢复!')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 删除中...'; + + fetch(`/api/devices/${deviceId}`, { + method: 'DELETE' + }) + .then(response => { + if (response.ok) { + showAlert('设备删除成功', 'success'); + setTimeout(() => { + window.location.href = '/admin/devices'; + }, 1000); + } else { + throw new Error('删除失败'); + } + }) + .catch(error => { + console.error('Error:', error); + showAlert('设备删除失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 删除内容 +function deleteContent(deviceId, version) { + if (confirm('确定要删除此内容吗?此操作不可恢复!')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 删除中...'; + + fetch(`/api/contents/${deviceId}/${version}`, { + method: 'DELETE' + }) + .then(response => { + if (response.ok) { + showAlert('内容删除成功', 'success'); + setTimeout(() => { + window.location.href = `/admin/devices/${deviceId}`; + }, 1000); + } else { + throw new Error('删除失败'); + } + }) + .catch(error => { + console.error('Error:', error); + showAlert('内容删除失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 格式化日期时间 +function formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +// 格式化文件大小 +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// 复制文本到剪贴板 +function copyToClipboard(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showAlert('已复制到剪贴板', 'success'); +} + +// 添加动画样式 +const style = document.createElement('style'); +style.textContent = ` + .animate-prep { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s ease; + } + + .animate-in { + opacity: 1; + transform: translateY(0); + } +`; +document.head.appendChild(style); \ No newline at end of file diff --git a/static/admin/js/admin.js b/static/admin/js/admin.js new file mode 100644 index 0000000..b490703 --- /dev/null +++ b/static/admin/js/admin.js @@ -0,0 +1,471 @@ +// 管理后台JavaScript功能 + +// 页面加载完成后执行 +document.addEventListener('DOMContentLoaded', function() { + // 初始化所有工具提示 + initTooltips(); + + // 初始化确认对话框 + initConfirmDialogs(); + + // 初始化表格排序 + initTableSorting(); + + // 初始化状态更新 + initStatusUpdates(); + + // 初始化图片上传 + initImageUpload(); +}); + +// 初始化工具提示 +function initTooltips() { + const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl); + }); +} + +// 初始化确认对话框 +function initConfirmDialogs() { + // 为所有带有data-confirm属性的按钮添加确认对话框 + const confirmButtons = document.querySelectorAll('[data-confirm]'); + confirmButtons.forEach(button => { + button.addEventListener('click', function(e) { + const message = this.getAttribute('data-confirm'); + if (!confirm(message)) { + e.preventDefault(); + return false; + } + }); + }); +} + +// 初始化表格排序 +function initTableSorting() { + const sortableTables = document.querySelectorAll('.table-sortable'); + sortableTables.forEach(table => { + const headers = table.querySelectorAll('th[data-sort]'); + headers.forEach(header => { + header.style.cursor = 'pointer'; + header.addEventListener('click', function() { + sortTable(table, this.getAttribute('data-sort')); + }); + }); + }); +} + +// 表格排序函数 +function sortTable(table, column) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const isAsc = table.getAttribute('data-sort-order') !== 'asc'; + + rows.sort((a, b) => { + const aValue = a.querySelector(`td[data-column="${column}"]`).textContent.trim(); + const bValue = b.querySelector(`td[data-column="${column}"]`).textContent.trim(); + + if (isAsc) { + return aValue.localeCompare(bValue); + } else { + return bValue.localeCompare(aValue); + } + }); + + // 清空表格并重新添加排序后的行 + tbody.innerHTML = ''; + rows.forEach(row => tbody.appendChild(row)); + + // 更新排序状态 + table.setAttribute('data-sort-order', isAsc ? 'asc' : 'desc'); + + // 更新排序图标 + const headers = table.querySelectorAll('th[data-sort]'); + headers.forEach(header => { + const icon = header.querySelector('.sort-icon'); + if (icon) icon.remove(); + }); + + const currentHeader = table.querySelector(`th[data-sort="${column}"]`); + const icon = document.createElement('i'); + icon.className = `sort-icon fas fa-sort-${isAsc ? 'up' : 'down'} ms-1`; + currentHeader.appendChild(icon); +} + +// 初始化状态更新 +function initStatusUpdates() { + // 定期更新设备状态 + const deviceStatusElements = document.querySelectorAll('.device-status'); + if (deviceStatusElements.length > 0) { + setInterval(updateDeviceStatus, 30000); // 每30秒更新一次 + } +} + +// 更新设备状态 +function updateDeviceStatus() { + const deviceStatusElements = document.querySelectorAll('.device-status[data-device-id]'); + deviceStatusElements.forEach(element => { + const deviceId = element.getAttribute('data-device-id'); + + fetch(`/api/devices/${deviceId}/status`) + .then(response => response.json()) + .then(data => { + if (data.online) { + element.innerHTML = '在线'; + } else { + element.innerHTML = '离线'; + } + }) + .catch(error => { + console.error('Error updating device status:', error); + }); + }); +} + +// 初始化图片上传 +function initImageUpload() { + const uploadArea = document.querySelector('.upload-area'); + const fileInput = document.querySelector('#image'); + + if (uploadArea && fileInput) { + // 点击上传区域触发文件选择 + uploadArea.addEventListener('click', function() { + fileInput.click(); + }); + + // 拖拽上传 + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, preventDefaults, false); + }); + + function preventDefaults(e) { + e.preventDefault(); + e.stopPropagation(); + } + + ['dragenter', 'dragover'].forEach(eventName => { + uploadArea.addEventListener(eventName, highlight, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadArea.addEventListener(eventName, unhighlight, false); + }); + + function highlight() { + uploadArea.classList.add('dragover'); + } + + function unhighlight() { + uploadArea.classList.remove('dragover'); + } + + uploadArea.addEventListener('drop', handleDrop, false); + + function handleDrop(e) { + const dt = e.dataTransfer; + const files = dt.files; + + if (files.length) { + fileInput.files = files; + handleFiles(files); + } + } + + fileInput.addEventListener('change', function() { + handleFiles(this.files); + }); + + function handleFiles(files) { + if (files.length) { + const file = files[0]; + + // 验证文件类型 + if (!file.type.startsWith('image/')) { + showAlert('请选择图片文件', 'danger'); + return; + } + + // 验证文件大小(限制为10MB) + if (file.size > 10 * 1024 * 1024) { + showAlert('图片文件大小不能超过10MB', 'danger'); + return; + } + + // 显示图片预览 + const reader = new FileReader(); + reader.onload = function(e) { + const preview = document.querySelector('#imagePreview'); + const previewImg = document.querySelector('#previewImg'); + + if (preview && previewImg) { + previewImg.src = e.target.result; + preview.style.display = 'block'; + } + }; + reader.readAsDataURL(file); + } + } + } +} + +// 显示提示消息 +function showAlert(message, type = 'info') { + const alertContainer = document.querySelector('.alert-container') || createAlertContainer(); + + const alertElement = document.createElement('div'); + alertElement.className = `alert alert-${type} alert-dismissible fade show`; + alertElement.setAttribute('role', 'alert'); + alertElement.innerHTML = ` + ${message} + + `; + + alertContainer.appendChild(alertElement); + + // 自动关闭提示 + setTimeout(() => { + alertElement.classList.remove('show'); + setTimeout(() => { + alertElement.remove(); + }, 300); + }, 5000); +} + +// 创建提示消息容器 +function createAlertContainer() { + const container = document.createElement('div'); + container.className = 'alert-container position-fixed top-0 end-0 p-3'; + container.style.zIndex = '1050'; + document.body.appendChild(container); + return container; +} + +// 推送内容到设备 +function pushContent(deviceId, version) { + if (confirm('确定要推送此内容到设备吗?')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 推送中...'; + + fetch(`/api/devices/${deviceId}/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + version: version + }) + }) + .then(response => response.json()) + .then(data => { + showAlert('内容推送成功', 'success'); + }) + .catch(error => { + console.error('Error:', error); + showAlert('内容推送失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 刷新设备状态 +function refreshDevice(deviceId) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 刷新中...'; + + fetch(`/api/devices/${deviceId}/status`) + .then(response => response.json()) + .then(data => { + if (data.online) { + showAlert('设备在线', 'success'); + } else { + showAlert('设备离线', 'warning'); + } + location.reload(); + }) + .catch(error => { + console.error('Error:', error); + showAlert('获取设备状态失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); +} + +// 重启设备 +function rebootDevice(deviceId) { + if (confirm('确定要重启设备吗?设备将在几秒钟内重启。')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 重启中...'; + + fetch(`/api/devices/${deviceId}/reboot`, { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + showAlert('重启命令已发送', 'success'); + }) + .catch(error => { + console.error('Error:', error); + showAlert('发送重启命令失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 切换内容状态 +function toggleContentStatus(deviceId, version) { + const btn = event.target; + const isCurrentlyActive = btn.textContent.includes('禁用'); + const action = isCurrentlyActive ? '禁用' : '启用'; + + if (confirm(`确定要${action}此内容吗?`)) { + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 处理中...'; + + fetch(`/api/contents/${deviceId}/${version}/toggle`, { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + showAlert(`内容${action}成功`, 'success'); + location.reload(); + }) + .catch(error => { + console.error('Error:', error); + showAlert(`内容${action}失败`, 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 删除内容 +function deleteContent(deviceId, version) { + if (confirm('确定要删除此内容吗?此操作不可恢复!')) { + const btn = event.target; + const originalText = btn.innerHTML; + + // 显示加载状态 + btn.disabled = true; + btn.innerHTML = ' 删除中...'; + + fetch(`/api/contents/${deviceId}/${version}`, { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + showAlert('内容删除成功', 'success'); + window.location.href = `/admin/devices/${deviceId}`; + }) + .catch(error => { + console.error('Error:', error); + showAlert('内容删除失败', 'danger'); + }) + .finally(() => { + // 恢复按钮状态 + btn.disabled = false; + btn.innerHTML = originalText; + }); + } +} + +// 格式化日期时间 +function formatDateTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +// 格式化文件大小 +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// 复制文本到剪贴板 +function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + showAlert('已复制到剪贴板', 'success'); + }).catch(err => { + console.error('复制失败:', err); + showAlert('复制失败', 'danger'); + }); +} + +// 导出数据 +function exportData(type, format = 'json') { + const url = `/api/export/${type}?format=${format}`; + window.open(url, '_blank'); +} + +// 初始化图表 +function initChart(canvasId, chartType, data, options = {}) { + const canvas = document.getElementById(canvasId); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + + // 默认选项 + const defaultOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + title: { + display: true, + text: options.title || '' + } + } + }; + + // 合并选项 + const mergedOptions = Object.assign({}, defaultOptions, options); + + // 创建图表 + new Chart(ctx, { + type: chartType, + data: data, + options: mergedOptions + }); +} \ No newline at end of file diff --git a/static/processed/b8b1b0f5-a81c-41e1-87e3-f3cbaadd8099.bmp b/static/processed/b8b1b0f5-a81c-41e1-87e3-f3cbaadd8099.bmp new file mode 100644 index 0000000..240926c Binary files /dev/null and b/static/processed/b8b1b0f5-a81c-41e1-87e3-f3cbaadd8099.bmp differ diff --git a/static/uploads/2f2fcebe-b1af-4219-9da8-d507da9dad97.jpg b/static/uploads/2f2fcebe-b1af-4219-9da8-d507da9dad97.jpg new file mode 100644 index 0000000..2bff860 Binary files /dev/null and b/static/uploads/2f2fcebe-b1af-4219-9da8-d507da9dad97.jpg differ diff --git a/templates/admin/add_content.html b/templates/admin/add_content.html new file mode 100644 index 0000000..40f7697 --- /dev/null +++ b/templates/admin/add_content.html @@ -0,0 +1,178 @@ +{% extends "admin/base.html" %} + +{% block title %}添加内容 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

添加内容

+ +
+ +
+
+
+
+
内容信息
+
+
+ {% if error %} + + {% endif %} + +
+
+ + +
选择要添加内容的设备
+
+ +
+ + +
为内容起一个易于识别的标题
+
+ +
+ + +
内容的详细描述(可选)
+
+ +
+
+ + +
选择设备所在时区
+
+ +
+ + +
时间显示格式,如:%Y-%m-%d %H:%M:%S
+
+
+ +
+
+ + +
创建后立即激活此内容
+
+
+ +
+
+ + +
创建完成后自动将内容推送到设备
+
+
+ +
+ + 取消 + + +
+
+
+
+
+ +
+
+
+
内容类型
+
+
+
+
+
+
文本内容
+ 当前 +
+

在此页面创建的是基本的文本内容,包括标题、描述和时间显示。

+ 适用于简单文字信息展示 +
+
+
+
图片内容
+ 其他 +
+

如需创建图片内容,请使用"图片上传"功能,上传图片后系统会自动创建内容。

+ 适用于复杂图形和图像展示 +
+
+ + +
+
+ +
+
+
时间格式说明
+
+
+
常用时间格式代码
+
+
%Y 四位年份 (2023)
+
%m 两位月份 (01-12)
+
%d 两位日期 (01-31)
+
%H 24小时制小时 (00-23)
+
%M 分钟 (00-59)
+
%S 秒 (00-59)
+
+ +
格式示例
+
+
+
+ %Y-%m-%d %H:%M + 2023-12-25 14:30 +
+
+
+
+ %m/%d/%Y + 12/25/2023 +
+
+
+
+ %H:%M:%S + 14:30:45 +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/add_device.html b/templates/admin/add_device.html new file mode 100644 index 0000000..787ced5 --- /dev/null +++ b/templates/admin/add_device.html @@ -0,0 +1,158 @@ +{% extends "admin/base.html" %} + +{% block title %}添加设备 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

添加设备

+ +
+ +
+
+
+
+
设备信息
+
+
+ {% if error %} + + {% endif %} + +
+
+ + +
设备的唯一标识符,通常为ESP32的MAC地址或其他唯一ID
+
+ +
+ + +
为设备起一个易于识别的名称
+
+ +
+ + +
选择设备所在的应用场景
+
+ +
+ + +
可选,提供更详细的位置信息
+
+ +
+ + +
可选,提供设备的详细描述信息
+
+ +
+ + 取消 + + +
+
+
+
+
+ +
+
+
+
设备配置说明
+
+
+
设备ID获取方法
+

设备ID通常可以通过以下方式获取:

+
+
+
+
ESP32的MAC地址
+ 推荐 +
+

格式如:A1:B2:C3:D4:E5:F6

+ 可通过串口输出或设备标签获取 +
+
+
+
设备序列号
+ 备选 +
+

制造商提供的唯一序列号

+ 确保在设备固件中一致 +
+
+
+
自定义ID
+ 高级 +
+

自定义的唯一标识符

+ 需确保在设备固件中一致 +
+
+
+
+ +
+
+
设备配置步骤
+
+
+
+
+
1
+
在ESP32固件中设置设备ID
+
+
+
2
+
配置MQTT连接参数
+
+
+
3
+
确保设备能连接到MQTT服务器
+
+
+
4
+
在此页面添加设备信息
+
+
+
5
+
设备将自动连接并显示在设备列表中
+
+
+ + + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/admin/base.html b/templates/admin/base.html new file mode 100644 index 0000000..1b6a294 --- /dev/null +++ b/templates/admin/base.html @@ -0,0 +1,79 @@ + + + + + + {% block title %}墨水屏管理系统{% endblock %} + + + + + + + +
+
+
+ {% block content %}{% endblock %} +
+
+
+ + + + {% block scripts %}{% endblock %} + + + + \ No newline at end of file diff --git a/templates/admin/content_detail.html b/templates/admin/content_detail.html new file mode 100644 index 0000000..d9fe4e0 --- /dev/null +++ b/templates/admin/content_detail.html @@ -0,0 +1,295 @@ +{% extends "admin/base.html" %} + +{% block title %}内容详情 - {{ content.title }} - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

内容详情 - {{ content.title }}

+ +
+ +
+ +
+
+
+
内容信息
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
设备{{ device.name }} ({{ device.device_id }})
标题{{ content.title }}
版本v{{ content.version }}
描述{{ content.description or '无' }}
状态 + {% if content.is_active %} + 活跃 + {% else %} + 禁用 + {% endif %} +
时区{{ content.timezone }}
时间格式{{ content.time_format }}
创建时间{{ content.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+ + +
+
+
+
内容操作
+
+
+
+ + + + 上传图片 + + +
+ +
+
推送历史
+
+
+ 最后推送时间 + {{ content.last_pushed_at.strftime('%Y-%m-%d %H:%M') if content.last_pushed_at else '从未' }} +
+
+ 推送次数 + {{ content.push_count or 0 }} +
+
+
+
+
+
+
+ + +{% if content.image_path %} +
+
+
图片预览
+
+
+
+ 内容图片 +
+ + + +
+
+
+ +
+
+
+{% endif %} + + +{% if layout_config %} +
+
+
布局配置
+
+
+
+
{{ layout_config | tojson(indent=2) }}
+
+
+ +
+
+
+{% endif %} + + +
+
+
设备信息
+
+
+ + + + + + + + + + + + + + + + + + + + + +
设备ID{{ device.device_id }}
设备名称{{ device.name }}
应用场景{{ device.scene }}
状态 + {% if device.is_active %} + {% if device.is_online %} + 在线 + {% else %} + 离线 + {% endif %} + {% else %} + 禁用 + {% endif %} +
最后上线{{ device.last_online.strftime('%Y-%m-%d %H:%M:%S') if device.last_online else '从未' }}
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/contents.html b/templates/admin/contents.html new file mode 100644 index 0000000..2868484 --- /dev/null +++ b/templates/admin/contents.html @@ -0,0 +1,236 @@ +{% extends "admin/base.html" %} + +{% block title %}内容管理 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

内容管理

+ +
+ + +
+
+
+
+
筛选条件
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +{% if content_list %} +
+
+
内容列表
+
+
+
+ + + + + + + + + + + + + + {% for item in content_list %} + + + + + + + + + + {% endfor %} + +
设备 标题 版本 类型 状态 创建时间 操作
+ + + {{ item.device.name if item.device else item.content.device_id }} + + + + {{ item.content.title }} + + + v{{ item.content.version }} + + {% if item.content.image_path %} + + 图片 + + {% else %} + + 文本 + + {% endif %} + + {% if item.content.is_active %} + + 活跃 + + {% else %} + + 禁用 + + {% endif %} + + + {{ item.content.created_at.strftime('%Y-%m-%d %H:%M') }} + + +
+ + + + + + +
+
+
+
+
+{% else %} +
+
+ +
暂无内容
+

+ {% if filtered %} + 该设备还没有任何内容。 + + 添加内容 + + {% else %} + 系统中还没有任何内容。 + + 添加第一个内容 + + {% endif %} +

+
+
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..5342425 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,470 @@ +{% extends "admin/base.html" %} + +{% block title %}仪表盘 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

仪表盘

+
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+

设备总数

+

{{ device_count }}

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+

活跃设备

+

{{ active_device_count }}

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+

内容总数

+

{{ content_count }}

+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+

活跃内容

+

{{ active_content_count }}

+
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+ 最近上线的设备 +
+ + 查看全部 + +
+
+ {% if recent_devices %} +
+ + + + + + + + + + + {% for device in recent_devices %} + + + + + + + {% endfor %} + +
设备ID名称状态最后上线
{{ device.device_id }}{{ device.name }} + {% if device.is_online %} + 在线 + {% else %} + 离线 + {% endif %} + {{ device.last_online.strftime('%Y-%m-%d %H:%M') if device.last_online else '从未' }}
+
+ {% else %} +
+ +

暂无设备

+
+ {% endif %} +
+
+
+ + +
+
+
+
+ 最近创建的内容 +
+ + 查看全部 + +
+
+ {% if recent_contents %} +
+ + + + + + + + + + + {% for content in recent_contents %} + + + + + + + {% endfor %} + +
设备ID标题版本创建时间
{{ content.device_id }}{{ content.title }}v{{ content.version }}{{ content.created_at.strftime('%Y-%m-%d %H:%M') }}
+
+ {% else %} +
+ +

暂无内容

+
+ {% endif %} +
+
+
+
+ + +
+
+
+
+
+ 快捷操作 +
+
+ +
+
+
+ + +
+
+
+
+
+ 系统状态 +
+
+
+
+
+
+
+ +
+
+
数据库
+
+
+
+ 连接正常 +
+
+
+
+
+
+ +
+
+
MQTT服务
+
+
+
+ 运行中 +
+
+
+
+
+
+ +
+
+
存储空间
+
+
+
+ 已使用 45% +
+
+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/device_detail.html b/templates/admin/device_detail.html new file mode 100644 index 0000000..0980293 --- /dev/null +++ b/templates/admin/device_detail.html @@ -0,0 +1,277 @@ +{% extends "admin/base.html" %} + +{% block title %}设备详情 - {{ device.name }} - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

设备详情 - {{ device.name }}

+ +
+ +
+ +
+
+
+
设备信息
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
设备ID{{ device.device_id }}
设备名称{{ device.name }}
应用场景{{ device.scene }}
状态 + {% if device.is_active %} + {% if device.is_online %} + 在线 + {% else %} + 离线 + {% endif %} + {% else %} + 禁用 + {% endif %} +
最后上线{{ device.last_online.strftime('%Y-%m-%d %H:%M:%S') if device.last_online else '从未' }}
创建时间{{ device.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+
+
+
+ + +
+
+
+
设备操作
+
+
+
+ + 添加内容 + + + 上传图片 + + + +
+ +
+
设备统计
+
+
+ 内容总数 + {{ contents|length }} +
+
+ 活跃内容 + {{ contents|selectattr('is_active')|list|length }} +
+
+ 图片内容 + {{ contents|selectattr('image_path')|list|length }} +
+
+
+
+
+
+
+ + +
+
+
内容列表
+
+ + + 添加内容 + +
+
+
+ {% if contents %} +
+ + + + + + + + + + + + + {% for content in contents %} + + + + + + + + + {% endfor %} + +
版本标题类型状态创建时间操作
v{{ content.version }}{{ content.title }} + {% if content.image_path %} + 图片 + {% else %} + 文本 + {% endif %} + + {% if content.is_active %} + 活跃 + {% else %} + 禁用 + {% endif %} + {{ content.created_at.strftime('%Y-%m-%d %H:%M') }} +
+ + + + + + + +
+
+
+ {% else %} +
+ +
暂无内容
+

此设备还没有任何内容。

+ + 添加第一个内容 + +
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/devices.html b/templates/admin/devices.html new file mode 100644 index 0000000..784eb45 --- /dev/null +++ b/templates/admin/devices.html @@ -0,0 +1,148 @@ +{% extends "admin/base.html" %} + +{% block title %}设备管理 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

设备管理

+ +
+ +{% if devices %} +
+
+
设备列表
+
+
+
+ + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + {% endfor %} + +
设备ID 名称 场景 状态 最后上线 操作
+ + {{ device.device_id }} + + {{ device.name }} + {{ device.scene }} + + {% if device.is_active %} + {% if device.is_online %} + + 在线 + + {% else %} + + 离线 + + {% endif %} + {% else %} + + 禁用 + + {% endif %} + + {% if device.last_online %} + + {{ device.last_online.strftime('%Y-%m-%d %H:%M') }} + + {% else %} + 从未 + {% endif %} + +
+ + + + + + + + +
+
+
+
+
+{% else %} +
+
+ +
暂无设备
+

您还没有添加任何设备。

+ + 添加第一个设备 + +
+
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/admin/upload_image.html b/templates/admin/upload_image.html new file mode 100644 index 0000000..a29b6ff --- /dev/null +++ b/templates/admin/upload_image.html @@ -0,0 +1,288 @@ +{% extends "admin/base.html" %} + +{% block title %}图片上传 - 墨水屏管理系统{% endblock %} + +{% block content %} +
+

图片上传

+ +
+ +
+
+
+
+
图片上传
+
+
+ {% if error %} + + {% endif %} + +
+
+ + +
选择要上传图片的设备
+
+ +
+ + +
选择现有版本或创建新版本
+
+ +
+ + +
为图片内容添加描述性标题
+
+ +
+ +
+ +
+
+ +

拖拽图片到此处或点击选择

+

支持JPG、PNG、BMP等常见图片格式

+
+
+
+
+ + + + +
+
+ + +
上传完成后自动将内容推送到设备
+
+
+ +
+
+ + +
自动优化图片对比度以适应墨水屏显示
+
+
+ +
+ + 取消 + + +
+
+
+
+
+ +
+
+
+
图片处理说明
+
+
+
支持的图片格式
+
+
JPEG/JPG
+
PNG
+
BMP
+
WEBP
+
+ +
图片处理流程
+
+
+
1
+
上传原始图片
+
+
+
2
+
自动转换为黑白图像
+
+
+
3
+
调整大小以适应墨水屏
+
+
+
4
+
优化对比度
+
+
+
5
+
保存处理后的图片
+
+
+ +
墨水屏规格
+
    +
  • + 分辨率 + 400×300 像素 +
  • +
  • + 显示模式 + 黑白 +
  • +
  • + 刷新时间 + 约2-3秒 +
  • +
+
+
+ +
+
+
使用提示
+
+
+
+
    +
  • 建议上传高对比度的图片,以获得更好的显示效果
  • +
  • 图片内容应简洁明了,避免过多细节
  • +
  • 文字内容应使用较大字号,确保可读性
  • +
  • 上传后会自动处理为适合墨水屏显示的格式
  • +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file