first commit

This commit is contained in:
jeremygan2021
2025-11-16 17:21:25 +08:00
commit a2682dc040
46 changed files with 5976 additions and 0 deletions

28
.env Normal file
View File

@@ -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

28
.env.example Normal file
View File

@@ -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

View File

@@ -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 保障时间准确
图片处理 PillowPython 服务端快速预处理图片为墨水屏兼容格式

47
README.md Normal file
View File

@@ -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}` - 下载处理后的图片

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

344
admin_routes.py Normal file
View File

@@ -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)}"
})

8
api/__init__.py Normal file
View File

@@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

339
api/contents.py Normal file
View File

@@ -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)}"
)

183
api/devices.py Normal file
View File

@@ -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
}

35
config.py Normal file
View File

@@ -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()

57
database.py Normal file
View File

@@ -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()

134
image_processor.py Normal file
View File

@@ -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()

111
main.py Normal file
View File

@@ -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
)

7
models.py Normal file
View File

@@ -0,0 +1,7 @@
from sqlalchemy.orm import Session
from database import Base
# 导入所有模型以确保它们被注册到Base.metadata
from database import Device, Content
__all__ = ["Device", "Content"]

170
mqtt_manager.py Normal file
View File

@@ -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()

13
requirements.txt Normal file
View File

@@ -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

89
schemas.py Normal file
View File

@@ -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

37
start.sh Executable file
View File

@@ -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

View File

@@ -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);
}

346
static/admin/css/admin.css Normal file
View File

@@ -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;
}
}

View File

@@ -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 = '<span class="badge bg-success">在线</span>';
} else {
element.innerHTML = '<span class="badge bg-secondary">离线</span>';
}
})
.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}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 推送中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 刷新中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 删除中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 删除中...';
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);

471
static/admin/js/admin.js Normal file
View File

@@ -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 = '<span class="badge bg-success">在线</span>';
} else {
element.innerHTML = '<span class="badge bg-secondary">离线</span>';
}
})
.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}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 推送中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 刷新中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 重启中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 处理中...';
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 删除中...';
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
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

View File

@@ -0,0 +1,178 @@
{% extends "admin/base.html" %}
{% block title %}添加内容 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-plus-circle me-2"></i>添加内容</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="/admin/contents" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> 返回内容列表
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">内容信息</h6>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="device_id" class="form-label">设备</label>
<select class="form-select" id="device_id" name="device_id" required>
<option value="">请选择设备</option>
{% for device in devices %}
<option value="{{ device.device_id }}" {% if selected_device == device.device_id %}selected{% endif %}>
{{ device.name }} ({{ device.device_id }})
</option>
{% endfor %}
</select>
<div class="form-text">选择要添加内容的设备</div>
</div>
<div class="mb-3">
<label for="title" class="form-label">标题</label>
<input type="text" class="form-control" id="title" name="title" required>
<div class="form-text">为内容起一个易于识别的标题</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">描述</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea>
<div class="form-text">内容的详细描述(可选)</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="timezone" class="form-label">时区</label>
<select class="form-select" id="timezone" name="timezone">
<option value="Asia/Shanghai" selected>Asia/Shanghai (北京时间)</option>
<option value="UTC">UTC (协调世界时)</option>
<option value="America/New_York">America/New_York (纽约时间)</option>
<option value="Europe/London">Europe/London (伦敦时间)</option>
<option value="Asia/Tokyo">Asia/Tokyo (东京时间)</option>
</select>
<div class="form-text">选择设备所在时区</div>
</div>
<div class="col-md-6 mb-3">
<label for="time_format" class="form-label">时间格式</label>
<input type="text" class="form-control" id="time_format" name="time_format" value="%Y-%m-%d %H:%M">
<div class="form-text">时间显示格式,如:%Y-%m-%d %H:%M:%S</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" checked>
<label class="form-check-label" for="is_active">
启用此内容
</label>
<div class="form-text">创建后立即激活此内容</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="auto_push" name="auto_push" checked>
<label class="form-check-label" for="auto_push">
创建后自动推送到设备
</label>
<div class="form-text">创建完成后自动将内容推送到设备</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/admin/contents" class="btn btn-secondary me-md-2">
<i class="fas fa-times me-1"></i> 取消
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> 创建内容
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info">内容类型</h6>
</div>
<div class="card-body">
<div class="list-group mb-4">
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1"><i class="fas fa-font me-2"></i>文本内容</h6>
<span class="badge bg-primary rounded-pill">当前</span>
</div>
<p class="mb-1">在此页面创建的是基本的文本内容,包括标题、描述和时间显示。</p>
<small>适用于简单文字信息展示</small>
</div>
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1"><i class="fas fa-image me-2"></i>图片内容</h6>
<span class="badge bg-secondary rounded-pill">其他</span>
</div>
<p class="mb-1">如需创建图片内容,请使用"图片上传"功能,上传图片后系统会自动创建内容。</p>
<small>适用于复杂图形和图像展示</small>
</div>
</div>
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i> 创建内容后,系统将自动推送到对应设备。
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-warning">时间格式说明</h6>
</div>
<div class="card-body">
<h6 class="mb-3"><i class="fas fa-clock me-2"></i>常用时间格式代码</h6>
<div class="row g-2 mb-4">
<div class="col-6"><span class="badge bg-light text-dark">%Y</span> 四位年份 (2023)</div>
<div class="col-6"><span class="badge bg-light text-dark">%m</span> 两位月份 (01-12)</div>
<div class="col-6"><span class="badge bg-light text-dark">%d</span> 两位日期 (01-31)</div>
<div class="col-6"><span class="badge bg-light text-dark">%H</span> 24小时制小时 (00-23)</div>
<div class="col-6"><span class="badge bg-light text-dark">%M</span> 分钟 (00-59)</div>
<div class="col-6"><span class="badge bg-light text-dark">%S</span> 秒 (00-59)</div>
</div>
<h6 class="mb-3"><i class="fas fa-code me-2"></i>格式示例</h6>
<div class="list-group list-group-flush">
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-center">
<code>%Y-%m-%d %H:%M</code>
<span class="text-muted">2023-12-25 14:30</span>
</div>
</div>
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-center">
<code>%m/%d/%Y</code>
<span class="text-muted">12/25/2023</span>
</div>
</div>
<div class="list-group-item px-0">
<div class="d-flex justify-content-between align-items-center">
<code>%H:%M:%S</code>
<span class="text-muted">14:30:45</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{% extends "admin/base.html" %}
{% block title %}添加设备 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-plus-circle me-2"></i>添加设备</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="/admin/devices" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> 返回设备列表
</a>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">设备信息</h6>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="device_id" class="form-label">设备ID</label>
<input type="text" class="form-control" id="device_id" name="device_id" required>
<div class="form-text">设备的唯一标识符通常为ESP32的MAC地址或其他唯一ID</div>
</div>
<div class="mb-3">
<label for="name" class="form-label">设备名称</label>
<input type="text" class="form-control" id="name" name="name" required>
<div class="form-text">为设备起一个易于识别的名称</div>
</div>
<div class="mb-3">
<label for="scene" class="form-label">应用场景</label>
<select class="form-select" id="scene" name="scene" required>
<option value="">请选择应用场景</option>
<option value="office"><i class="fas fa-building me-1"></i>办公室</option>
<option value="meeting"><i class="fas fa-users me-1"></i>会议室</option>
<option value="reception"><i class="fas fa-concierge-bell me-1"></i>前台</option>
<option value="lobby"><i class="fas fa-door-open me-1"></i>大厅</option>
<option value="corridor"><i class="fas fa-route me-1"></i>走廊</option>
<option value="classroom"><i class="fas fa-chalkboard-teacher me-1"></i>教室</option>
<option value="other"><i class="fas fa-tag me-1"></i>其他</option>
</select>
<div class="form-text">选择设备所在的应用场景</div>
</div>
<div class="mb-3">
<label for="location" class="form-label">设备位置</label>
<input type="text" class="form-control" id="location" name="location" placeholder="例如:一楼大厅东侧">
<div class="form-text">可选,提供更详细的位置信息</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">设备描述</label>
<textarea class="form-control" id="description" name="description" rows="3" placeholder="设备用途、特殊配置等"></textarea>
<div class="form-text">可选,提供设备的详细描述信息</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/admin/devices" class="btn btn-secondary me-md-2">
<i class="fas fa-times me-1"></i> 取消
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> 添加设备
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info">设备配置说明</h6>
</div>
<div class="card-body">
<h5 class="mb-3"><i class="fas fa-microchip me-2"></i>设备ID获取方法</h5>
<p>设备ID通常可以通过以下方式获取</p>
<div class="list-group mb-4">
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">ESP32的MAC地址</h6>
<small>推荐</small>
</div>
<p class="mb-1">格式如A1:B2:C3:D4:E5:F6</p>
<small>可通过串口输出或设备标签获取</small>
</div>
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">设备序列号</h6>
<small>备选</small>
</div>
<p class="mb-1">制造商提供的唯一序列号</p>
<small>确保在设备固件中一致</small>
</div>
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">自定义ID</h6>
<small>高级</small>
</div>
<p class="mb-1">自定义的唯一标识符</p>
<small>需确保在设备固件中一致</small>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-warning">设备配置步骤</h6>
</div>
<div class="card-body">
<div class="process-steps">
<div class="step-item">
<div class="step-number">1</div>
<div class="step-text">在ESP32固件中设置设备ID</div>
</div>
<div class="step-item">
<div class="step-number">2</div>
<div class="step-text">配置MQTT连接参数</div>
</div>
<div class="step-item">
<div class="step-number">3</div>
<div class="step-text">确保设备能连接到MQTT服务器</div>
</div>
<div class="step-item">
<div class="step-number">4</div>
<div class="step-text">在此页面添加设备信息</div>
</div>
<div class="step-item">
<div class="step-number">5</div>
<div class="step-text">设备将自动连接并显示在设备列表中</div>
</div>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="fas fa-info-circle me-2"></i> 添加设备后,系统将自动生成设备密钥,用于设备认证。
</div>
<div class="alert alert-light mt-3" role="alert">
<h6 class="alert-heading"><i class="fas fa-lightbulb me-2"></i>提示</h6>
<p class="mb-0">确保设备ID唯一且与固件中设置的一致否则设备将无法正常连接到系统。</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

79
templates/admin/base.html Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}墨水屏管理系统{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/admin/css/admin-enhanced.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="/admin">
<i class="fas fa-desktop me-2"></i>
墨水屏管理系统
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/admin' %}active{% endif %}" href="/admin">
<i class="fas fa-tachometer-alt me-1"></i>仪表盘
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/devices' in request.url.path %}active{% endif %}" href="/admin/devices">
<i class="fas fa-mobile-alt me-1"></i>设备管理
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/contents' in request.url.path %}active{% endif %}" href="/admin/contents">
<i class="fas fa-file-image me-1"></i>内容管理
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/upload' in request.url.path %}active{% endif %}" href="/admin/upload">
<i class="fas fa-upload me-1"></i>图片上传
</a>
</li>
</ul>
<div class="d-flex">
<button class="btn btn-outline-light me-2" id="themeToggle" title="切换主题">
<i class="fas fa-palette"></i>
</button>
<span class="navbar-text text-light">
<i class="far fa-clock me-1"></i>
<span id="currentTime"></span>
</span>
</div>
</div>
</div>
</nav>
<div class="container-fluid mt-4">
<div class="row">
<div class="col-12">
{% block content %}{% endblock %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/admin/js/admin-enhanced.js"></script>
{% block scripts %}{% endblock %}
<script>
// 更新当前时间
function updateTime() {
const now = new Date();
document.getElementById('currentTime').textContent = now.toLocaleTimeString('zh-CN');
}
updateTime();
setInterval(updateTime, 1000);
</script>
</body>
</html>

View File

@@ -0,0 +1,295 @@
{% extends "admin/base.html" %}
{% block title %}内容详情 - {{ content.title }} - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-file-alt me-2"></i>内容详情 - {{ content.title }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="/admin/contents" class="btn btn-secondary me-2">
<i class="fas fa-arrow-left me-1"></i> 返回内容列表
</a>
<a href="/admin/devices/{{ device.device_id }}" class="btn btn-primary">
<i class="fas fa-tv me-1"></i> 查看设备
</a>
</div>
</div>
<div class="row">
<!-- 内容信息 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-info-circle me-2"></i>内容信息</h6>
</div>
<div class="card-body">
<table class="table table-borderless table-hover">
<tr>
<th width="30%"><i class="fas fa-tv me-2"></i>设备</th>
<td><a href="/admin/devices/{{ device.device_id }}">{{ device.name }} ({{ device.device_id }})</a></td>
</tr>
<tr>
<th><i class="fas fa-heading me-2"></i>标题</th>
<td>{{ content.title }}</td>
</tr>
<tr>
<th><i class="fas fa-code-branch me-2"></i>版本</th>
<td><span class="badge bg-info">v{{ content.version }}</span></td>
</tr>
<tr>
<th><i class="fas fa-align-left me-2"></i>描述</th>
<td>{{ content.description or '无' }}</td>
</tr>
<tr>
<th><i class="fas fa-toggle-on me-2"></i>状态</th>
<td>
{% if content.is_active %}
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i>活跃</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-pause-circle me-1"></i>禁用</span>
{% endif %}
</td>
</tr>
<tr>
<th><i class="fas fa-globe me-2"></i>时区</th>
<td>{{ content.timezone }}</td>
</tr>
<tr>
<th><i class="fas fa-clock me-2"></i>时间格式</th>
<td><code>{{ content.time_format }}</code></td>
</tr>
<tr>
<th><i class="fas fa-calendar-plus me-2"></i>创建时间</th>
<td>{{ content.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- 内容操作 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info"><i class="fas fa-cogs me-2"></i>内容操作</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<button class="btn btn-success" onclick="pushContent('{{ device.device_id }}', {{ content.version }})">
<i class="fas fa-paper-plane me-2"></i> 推送到设备
</button>
<button class="btn btn-warning" onclick="toggleContentStatus('{{ device.device_id }}', {{ content.version }})">
{% if content.is_active %}
<i class="fas fa-pause me-2"></i> 禁用内容
{% else %}
<i class="fas fa-play me-2"></i> 启用内容
{% endif %}
</button>
<a href="/admin/upload?device_id={{ device.device_id }}&version={{ content.version }}" class="btn btn-info">
<i class="fas fa-image me-2"></i> 上传图片
</a>
<button class="btn btn-danger" onclick="deleteContent('{{ device.device_id }}', {{ content.version }})">
<i class="fas fa-trash me-2"></i> 删除内容
</button>
</div>
<div class="mt-4">
<h6 class="mb-3"><i class="fas fa-chart-line me-2"></i>推送历史</h6>
<div class="list-group list-group-flush">
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span><i class="fas fa-history me-2"></i>最后推送时间</span>
<span class="badge bg-primary rounded-pill">{{ content.last_pushed_at.strftime('%Y-%m-%d %H:%M') if content.last_pushed_at else '从未' }}</span>
</div>
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span><i class="fas fa-sync-alt me-2"></i>推送次数</span>
<span class="badge bg-info rounded-pill">{{ content.push_count or 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 图片预览 -->
{% if content.image_path %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-image me-2"></i>图片预览</h6>
</div>
<div class="card-body text-center">
<div class="position-relative d-inline-block">
<img src="{{ content.image_path }}" alt="内容图片" class="img-fluid rounded shadow-sm" style="max-height: 400px;">
<div class="position-absolute top-0 end-0 p-2">
<a href="{{ content.image_path }}" target="_blank" class="btn btn-sm btn-light bg-white rounded-circle">
<i class="fas fa-expand"></i>
</a>
</div>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" onclick="downloadImage('{{ content.image_path }}')">
<i class="fas fa-download me-1"></i> 下载图片
</button>
</div>
</div>
</div>
{% endif %}
<!-- 布局配置 -->
{% if layout_config %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-th-large me-2"></i>布局配置</h6>
</div>
<div class="card-body">
<div class="bg-light p-3 rounded">
<pre class="mb-0">{{ layout_config | tojson(indent=2) }}</pre>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-outline-primary" onclick="copyLayoutConfig()">
<i class="fas fa-copy me-1"></i> 复制配置
</button>
</div>
</div>
</div>
{% endif %}
<!-- 设备信息 -->
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-tv me-2"></i>设备信息</h6>
</div>
<div class="card-body">
<table class="table table-borderless table-hover">
<tr>
<th width="30%"><i class="fas fa-barcode me-2"></i>设备ID</th>
<td><code>{{ device.device_id }}</code></td>
</tr>
<tr>
<th><i class="fas fa-tag me-2"></i>设备名称</th>
<td>{{ device.name }}</td>
</tr>
<tr>
<th><i class="fas fa-map-marker-alt me-2"></i>应用场景</th>
<td>{{ device.scene }}</td>
</tr>
<tr>
<th><i class="fas fa-toggle-on me-2"></i>状态</th>
<td>
{% if device.is_active %}
{% if device.is_online %}
<span class="badge bg-success"><i class="fas fa-wifi me-1"></i>在线</span>
{% else %}
<span class="badge bg-warning"><i class="fas fa-wifi-slash me-1"></i>离线</span>
{% endif %}
{% else %}
<span class="badge bg-secondary"><i class="fas fa-ban me-1"></i>禁用</span>
{% endif %}
</td>
</tr>
<tr>
<th><i class="fas fa-clock me-2"></i>最后上线</th>
<td>{{ device.last_online.strftime('%Y-%m-%d %H:%M:%S') if device.last_online else '从未' }}</td>
</tr>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function pushContent(deviceId, version) {
if (confirm('确定要推送此内容到设备吗?')) {
fetch(`/api/devices/${deviceId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
version: version
})
})
.then(response => response.json())
.then(data => {
showToast('内容推送成功', 'success');
})
.catch(error => {
console.error('Error:', error);
showToast('内容推送失败', 'error');
});
}
}
function toggleContentStatus(deviceId, version) {
const action = event.target.textContent.includes('启用') ? '启用' : '禁用';
if (confirm(`确定要${action}此内容吗?`)) {
fetch(`/api/contents/${deviceId}/${version}/toggle`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
showToast(`内容${action}成功`, 'success');
location.reload();
})
.catch(error => {
console.error('Error:', error);
showToast(`内容${action}失败`, 'error');
});
}
}
function deleteContent(deviceId, version) {
if (confirm('确定要删除此内容吗?此操作不可恢复!')) {
fetch(`/api/contents/${deviceId}/${version}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
showToast('内容删除成功', 'success');
window.location.href = `/admin/devices/${deviceId}`;
})
.catch(error => {
console.error('Error:', error);
showToast('内容删除失败', 'error');
});
}
}
function downloadImage(imagePath) {
const link = document.createElement('a');
link.href = imagePath;
link.download = imagePath.split('/').pop();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function copyLayoutConfig() {
const configText = document.querySelector('pre').textContent;
navigator.clipboard.writeText(configText).then(() => {
showToast('布局配置已复制到剪贴板', 'success');
}).catch(err => {
console.error('复制失败:', err);
showToast('复制失败', 'error');
});
}
function showToast(message, type) {
// 创建一个简单的toast通知而不是使用alert
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'success' ? 'success' : 'danger'} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = '1050';
toast.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : 'exclamation-circle'} me-2"></i>${message}`;
document.body.appendChild(toast);
// 3秒后自动移除
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(() => {
document.body.removeChild(toast);
}, 500);
}, 3000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,236 @@
{% extends "admin/base.html" %}
{% block title %}内容管理 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-file-alt me-2"></i>内容管理</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="/admin/contents/add" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> 添加内容
</a>
<a href="/admin/upload" class="btn btn-success">
<i class="fas fa-upload me-1"></i> 上传图片
</a>
</div>
</div>
</div>
<!-- 设备筛选 -->
<div class="row mb-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">筛选条件</h6>
</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-6">
<label for="device_id" class="form-label">按设备筛选</label>
<select class="form-select" id="device_id" name="device_id" onchange="this.form.submit()">
<option value="">所有设备</option>
{% for device in devices %}
<option value="{{ device.device_id }}" {% if filtered and device.device_id == device_id %}selected{% endif %}>
{{ device.name }} ({{ device.device_id }})
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="content_type" class="form-label">内容类型</label>
<select class="form-select" id="content_type" name="content_type" onchange="this.form.submit()">
<option value="">全部类型</option>
<option value="image" {% if content_type == 'image' %}selected{% endif %}>图片内容</option>
<option value="text" {% if content_type == 'text' %}selected{% endif %}>文本内容</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">状态</label>
<select class="form-select" id="status" name="status" onchange="this.form.submit()">
<option value="">全部状态</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>活跃</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>禁用</option>
</select>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 内容列表 -->
{% if content_list %}
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">内容列表</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle table-sortable" data-sort-order="asc">
<thead>
<tr>
<th data-sort="device">设备 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="title">标题 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="version">版本 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="type">类型 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="status">状态 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="created_at">创建时间 <i class="fas fa-sort text-muted"></i></th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr>
<td data-column="device">
<a href="/admin/devices/{{ item.content.device_id }}" class="text-decoration-none">
<i class="fas fa-mobile-alt me-1"></i>
{{ item.device.name if item.device else item.content.device_id }}
</a>
</td>
<td data-column="title">
<a href="/admin/devices/{{ item.content.device_id }}/contents/{{ item.content.version }}" class="text-decoration-none fw-bold">
{{ item.content.title }}
</a>
</td>
<td data-column="version">
<span class="badge bg-light text-dark">v{{ item.content.version }}</span>
</td>
<td data-column="type">
{% if item.content.image_path %}
<span class="badge bg-info">
<i class="fas fa-image me-1"></i>图片
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-font me-1"></i>文本
</span>
{% endif %}
</td>
<td data-column="status">
{% if item.content.is_active %}
<span class="badge bg-success">
<i class="fas fa-check-circle me-1"></i>活跃
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-times-circle me-1"></i>禁用
</span>
{% endif %}
</td>
<td data-column="created_at">
<span title="{{ item.content.created_at.strftime('%Y-%m-%d %H:%M:%S') }}">
{{ item.content.created_at.strftime('%Y-%m-%d %H:%M') }}
</span>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/admin/devices/{{ item.content.device_id }}/contents/{{ item.content.version }}" class="btn btn-outline-info" title="查看详情">
<i class="fas fa-eye"></i>
</a>
<button class="btn btn-outline-success" onclick="pushContent('{{ item.content.device_id }}', {{ item.content.version }})" title="推送到设备">
<i class="fas fa-paper-plane"></i>
</button>
<button class="btn btn-outline-warning" onclick="duplicateContent('{{ item.content.device_id }}', {{ item.content.version }})" title="复制内容">
<i class="fas fa-copy"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteContent('{{ item.content.device_id }}', {{ item.content.version }})" title="删除内容">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<i class="fas fa-file-alt fa-4x text-muted mb-4"></i>
<h5 class="card-title">暂无内容</h5>
<p class="card-text">
{% if filtered %}
该设备还没有任何内容。
<a href="/admin/contents/add?device_id={{ device_id }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus me-2"></i> 添加内容
</a>
{% else %}
系统中还没有任何内容。
<a href="/admin/contents/add" class="btn btn-primary btn-lg">
<i class="fas fa-plus me-2"></i> 添加第一个内容
</a>
{% endif %}
</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function pushContent(deviceId, version) {
if (confirm('确定要推送此内容到设备吗?')) {
fetch(`/api/devices/${deviceId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
version: version
})
})
.then(response => response.json())
.then(data => {
alert('内容推送成功');
})
.catch(error => {
console.error('Error:', error);
alert('内容推送失败');
});
}
}
function duplicateContent(deviceId, version) {
if (confirm('确定要复制此内容吗?')) {
fetch(`/api/devices/${deviceId}/contents/${version}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
alert('内容复制成功');
window.location.reload();
})
.catch(error => {
console.error('Error:', error);
alert('内容复制失败');
});
}
}
function deleteContent(deviceId, version) {
if (confirm('确定要删除此内容吗?此操作不可恢复!')) {
fetch(`/api/devices/${deviceId}/contents/${version}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
alert('内容删除成功');
window.location.reload();
})
.catch(error => {
console.error('Error:', error);
alert('内容删除失败');
});
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,470 @@
{% extends "admin/base.html" %}
{% block title %}仪表盘 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-chart-line me-2"></i>仪表盘</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshDashboard">
<i class="fas fa-sync-alt"></i> 刷新
</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="themeToggle" title="切换主题">
<i class="fas fa-snowflake"></i> 圣诞主题
</button>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card card-stats bg-primary text-white shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-5">
<div class="icon-big text-center">
<i class="fas fa-desktop"></i>
</div>
</div>
<div class="col-7">
<div class="numbers">
<p class="card-category">设备总数</p>
<h3 class="card-title">{{ device_count }}</h3>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="stats">
<i class="fas fa-clock"></i> 最近更新: {{ last_update or '刚刚' }}
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card card-stats bg-success text-white shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-5">
<div class="icon-big text-center">
<i class="fas fa-wifi"></i>
</div>
</div>
<div class="col-7">
<div class="numbers">
<p class="card-category">活跃设备</p>
<h3 class="card-title">{{ active_device_count }}</h3>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="stats">
<i class="fas fa-signal"></i> 在线率: {{ ((active_device_count / device_count * 100) | round(1) if device_count > 0 else 0) }}%
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card card-stats bg-info text-white shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-5">
<div class="icon-big text-center">
<i class="fas fa-images"></i>
</div>
</div>
<div class="col-7">
<div class="numbers">
<p class="card-category">内容总数</p>
<h3 class="card-title">{{ content_count }}</h3>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="stats">
<i class="fas fa-database"></i> 存储空间: {{ storage_usage or '未知' }}
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card card-stats bg-warning text-white shadow-sm">
<div class="card-body">
<div class="row">
<div class="col-5">
<div class="icon-big text-center">
<i class="fas fa-play-circle"></i>
</div>
</div>
<div class="col-7">
<div class="numbers">
<p class="card-category">活跃内容</p>
<h3 class="card-title">{{ active_content_count }}</h3>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="stats">
<i class="fas fa-chart-line"></i> 活跃率: {{ ((active_content_count / content_count * 100) | round(1) if content_count > 0 else 0) }}%
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 最近上线的设备 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-history me-2"></i>最近上线的设备
</h6>
<a href="/admin/devices" class="btn btn-sm btn-primary">
<i class="fas fa-list me-1"></i>查看全部
</a>
</div>
<div class="card-body">
{% if recent_devices %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th><i class="fas fa-barcode me-1"></i>设备ID</th>
<th><i class="fas fa-tag me-1"></i>名称</th>
<th><i class="fas fa-toggle-on me-1"></i>状态</th>
<th><i class="fas fa-clock me-1"></i>最后上线</th>
</tr>
</thead>
<tbody>
{% for device in recent_devices %}
<tr>
<td><a href="/admin/devices/{{ device.device_id }}" class="text-decoration-none"><code>{{ device.device_id }}</code></a></td>
<td>{{ device.name }}</td>
<td>
{% if device.is_online %}
<span class="badge bg-success"><i class="fas fa-wifi me-1"></i>在线</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-wifi-slash me-1"></i>离线</span>
{% endif %}
</td>
<td>{{ device.last_online.strftime('%Y-%m-%d %H:%M') if device.last_online else '从未' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无设备</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 最近创建的内容 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-clock me-2"></i>最近创建的内容
</h6>
<a href="/admin/contents" class="btn btn-sm btn-primary">
<i class="fas fa-list me-1"></i>查看全部
</a>
</div>
<div class="card-body">
{% if recent_contents %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th><i class="fas fa-barcode me-1"></i>设备ID</th>
<th><i class="fas fa-heading me-1"></i>标题</th>
<th><i class="fas fa-code-branch me-1"></i>版本</th>
<th><i class="fas fa-calendar me-1"></i>创建时间</th>
</tr>
</thead>
<tbody>
{% for content in recent_contents %}
<tr>
<td><a href="/admin/devices/{{ content.device_id }}" class="text-decoration-none"><code>{{ content.device_id }}</code></a></td>
<td><a href="/admin/devices/{{ content.device_id }}/contents/{{ content.version }}" class="text-decoration-none">{{ content.title }}</a></td>
<td><span class="badge bg-info">v{{ content.version }}</span></td>
<td>{{ content.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">暂无内容</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-rocket me-2"></i>快捷操作
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3">
<a href="/admin/devices/add" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-plus me-2"></i> 添加设备
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/contents/add" class="btn btn-info btn-lg btn-block">
<i class="fas fa-plus me-2"></i> 添加内容
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/upload" class="btn btn-success btn-lg btn-block">
<i class="fas fa-upload me-2"></i> 上传图片
</a>
</div>
<div class="col-md-3 mb-3">
<a href="/admin/devices" class="btn btn-warning btn-lg btn-block">
<i class="fas fa-tv me-2"></i> 设备管理
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 系统状态 -->
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-heartbeat me-2"></i>系统状态
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-database fa-2x text-primary"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">数据库</h6>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-success" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small class="text-muted">连接正常</small>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-network-wired fa-2x text-info"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">MQTT服务</h6>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-success" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small class="text-muted">运行中</small>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="fas fa-microchip fa-2x text-warning"></i>
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mb-1">存储空间</h6>
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: 45%" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small class="text-muted">已使用 45%</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// 刷新仪表盘
document.getElementById('refreshDashboard').addEventListener('click', function() {
const icon = this.querySelector('i');
icon.classList.add('fa-spin');
setTimeout(() => {
location.reload();
}, 500);
});
// 主题切换
document.getElementById('themeToggle').addEventListener('click', function() {
const body = document.body;
const isChristmas = body.classList.contains('christmas-theme');
if (isChristmas) {
body.classList.remove('christmas-theme');
localStorage.setItem('theme', 'default');
this.innerHTML = '<i class="fas fa-snowflake"></i> 圣诞主题';
showToast('已切换到默认主题', 'info');
} else {
body.classList.add('christmas-theme');
localStorage.setItem('theme', 'christmas');
this.innerHTML = '<i class="fas fa-sun"></i> 默认主题';
showToast('已切换到圣诞主题', 'success');
// 添加圣诞特效
addChristmasEffects();
}
});
// 页面加载时检查主题设置
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'christmas') {
document.body.classList.add('christmas-theme');
document.getElementById('themeToggle').innerHTML = '<i class="fas fa-sun"></i> 默认主题';
addChristmasEffects();
}
});
// 添加圣诞特效
function addChristmasEffects() {
// 创建雪花效果
createSnowflakes();
// 添加圣诞装饰
addChristmasDecorations();
}
// 创建雪花效果
function createSnowflakes() {
// 如果已经存在雪花容器,先移除
const existingSnowflakes = document.getElementById('snowflakes-container');
if (existingSnowflakes) {
existingSnowflakes.remove();
}
// 创建雪花容器
const snowflakesContainer = document.createElement('div');
snowflakesContainer.id = 'snowflakes-container';
snowflakesContainer.style.position = 'fixed';
snowflakesContainer.style.top = '0';
snowflakesContainer.style.left = '0';
snowflakesContainer.style.width = '100%';
snowflakesContainer.style.height = '100%';
snowflakesContainer.style.pointerEvents = 'none';
snowflakesContainer.style.zIndex = '999';
snowflakesContainer.style.overflow = 'hidden';
// 创建雪花
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'snowflake';
snowflake.innerHTML = '❄';
snowflake.style.position = 'absolute';
snowflake.style.top = Math.random() * 100 + '%';
snowflake.style.left = Math.random() * 100 + '%';
snowflake.style.fontSize = Math.random() * 10 + 10 + 'px';
snowflake.style.opacity = Math.random() * 0.7 + 0.3;
snowflake.style.animation = `fall ${Math.random() * 5 + 5}s linear infinite`;
snowflake.style.animationDelay = Math.random() * 5 + 's';
snowflakesContainer.appendChild(snowflake);
}
document.body.appendChild(snowflakesContainer);
// 添加雪花下落动画
const style = document.createElement('style');
style.textContent = `
@keyframes fall {
from { transform: translateY(-100px); }
to { transform: translateY(calc(100vh + 100px)); }
}
`;
document.head.appendChild(style);
}
// 添加圣诞装饰
function addChristmasDecorations() {
// 在页面顶部添加圣诞装饰横幅
const header = document.querySelector('nav.navbar');
if (header && !header.classList.contains('christmas-decorated')) {
header.classList.add('christmas-decorated');
// 创建圣诞装饰
const decorations = document.createElement('div');
decorations.className = 'christmas-decorations';
decorations.style.position = 'absolute';
decorations.style.top = '0';
decorations.style.left = '0';
decorations.style.width = '100%';
decorations.style.height = '10px';
decorations.style.background = 'linear-gradient(90deg, #ff0000, #00ff00, #ff0000, #00ff00, #ff0000)';
decorations.style.zIndex = '10';
header.style.position = 'relative';
header.appendChild(decorations);
// 添加圣诞帽到logo
const logo = document.querySelector('.navbar-brand');
if (logo && !logo.querySelector('.christmas-hat')) {
const hat = document.createElement('span');
hat.className = 'christmas-hat';
hat.innerHTML = '🎅';
hat.style.marginLeft = '5px';
logo.appendChild(hat);
}
}
}
// Toast通知函数
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'success' ? 'success' : type === 'warning' ? 'warning' : type === 'info' ? 'info' : 'danger'} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = '1050';
toast.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'warning' ? 'exclamation-triangle' : type === 'info' ? 'info-circle' : 'exclamation-circle'} me-2"></i>${message}`;
document.body.appendChild(toast);
// 3秒后自动移除
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(() => {
document.body.removeChild(toast);
}, 500);
}, 3000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,277 @@
{% extends "admin/base.html" %}
{% block title %}设备详情 - {{ device.name }} - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-tv me-2"></i>设备详情 - {{ device.name }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="/admin/devices" class="btn btn-secondary me-2">
<i class="fas fa-arrow-left me-1"></i> 返回设备列表
</a>
<a href="/admin/devices/{{ device.device_id }}/contents/add" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> 添加内容
</a>
</div>
</div>
<div class="row">
<!-- 设备信息 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-info-circle me-2"></i>设备信息</h6>
</div>
<div class="card-body">
<table class="table table-borderless table-hover">
<tr>
<th width="30%"><i class="fas fa-barcode me-2"></i>设备ID</th>
<td><code>{{ device.device_id }}</code></td>
</tr>
<tr>
<th><i class="fas fa-tag me-2"></i>设备名称</th>
<td>{{ device.name }}</td>
</tr>
<tr>
<th><i class="fas fa-map-marker-alt me-2"></i>应用场景</th>
<td>{{ device.scene }}</td>
</tr>
<tr>
<th><i class="fas fa-toggle-on me-2"></i>状态</th>
<td>
{% if device.is_active %}
{% if device.is_online %}
<span class="badge bg-success"><i class="fas fa-wifi me-1"></i>在线</span>
{% else %}
<span class="badge bg-warning"><i class="fas fa-wifi-slash me-1"></i>离线</span>
{% endif %}
{% else %}
<span class="badge bg-secondary"><i class="fas fa-ban me-1"></i>禁用</span>
{% endif %}
</td>
</tr>
<tr>
<th><i class="fas fa-clock me-2"></i>最后上线</th>
<td>{{ device.last_online.strftime('%Y-%m-%d %H:%M:%S') if device.last_online else '从未' }}</td>
</tr>
<tr>
<th><i class="fas fa-calendar-plus me-2"></i>创建时间</th>
<td>{{ device.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- 设备操作 -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info"><i class="fas fa-cogs me-2"></i>设备操作</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/admin/devices/{{ device.device_id }}/contents/add" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> 添加内容
</a>
<a href="/admin/upload?device_id={{ device.device_id }}" class="btn btn-success">
<i class="fas fa-upload me-2"></i> 上传图片
</a>
<button class="btn btn-warning" onclick="refreshDevice('{{ device.device_id }}')">
<i class="fas fa-sync me-2"></i> 刷新设备状态
</button>
<button class="btn btn-info" onclick="rebootDevice('{{ device.device_id }}')">
<i class="fas fa-power-off me-2"></i> 重启设备
</button>
</div>
<div class="mt-4">
<h6 class="mb-3"><i class="fas fa-chart-line me-2"></i>设备统计</h6>
<div class="list-group list-group-flush">
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span><i class="fas fa-file-alt me-2"></i>内容总数</span>
<span class="badge bg-primary rounded-pill">{{ contents|length }}</span>
</div>
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span><i class="fas fa-check-circle me-2"></i>活跃内容</span>
<span class="badge bg-success rounded-pill">{{ contents|selectattr('is_active')|list|length }}</span>
</div>
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span><i class="fas fa-image me-2"></i>图片内容</span>
<span class="badge bg-info rounded-pill">{{ contents|selectattr('image_path')|list|length }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 内容列表 -->
<div class="card shadow-sm">
<div class="card-header bg-white py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-file-alt me-2"></i>内容列表</h6>
<div>
<button class="btn btn-sm btn-outline-secondary me-2" onclick="toggleContentList()">
<i class="fas fa-compress-alt me-1"></i> 折叠/展开
</button>
<a href="/admin/devices/{{ device.device_id }}/contents/add" class="btn btn-sm btn-primary">
<i class="fas fa-plus me-1"></i> 添加内容
</a>
</div>
</div>
<div class="card-body" id="contentList">
{% if contents %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th><i class="fas fa-code-branch me-1"></i>版本</th>
<th><i class="fas fa-heading me-1"></i>标题</th>
<th><i class="fas fa-file-image me-1"></i>类型</th>
<th><i class="fas fa-toggle-on me-1"></i>状态</th>
<th><i class="fas fa-calendar me-1"></i>创建时间</th>
<th><i class="fas fa-cogs me-1"></i>操作</th>
</tr>
</thead>
<tbody>
{% for content in contents %}
<tr>
<td><span class="badge bg-info">v{{ content.version }}</span></td>
<td><a href="/admin/devices/{{ device.device_id }}/contents/{{ content.version }}" class="text-decoration-none">{{ content.title }}</a></td>
<td>
{% if content.image_path %}
<span class="badge bg-info"><i class="fas fa-image me-1"></i>图片</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-font me-1"></i>文本</span>
{% endif %}
</td>
<td>
{% if content.is_active %}
<span class="badge bg-success"><i class="fas fa-check-circle me-1"></i>活跃</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-pause-circle me-1"></i>禁用</span>
{% endif %}
</td>
<td>{{ content.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/admin/devices/{{ device.device_id }}/contents/{{ content.version }}" class="btn btn-outline-info" title="查看">
<i class="fas fa-eye"></i>
</a>
<button class="btn btn-outline-success" onclick="pushContent('{{ device.device_id }}', {{ content.version }})" title="推送">
<i class="fas fa-paper-plane"></i>
</button>
<a href="/admin/upload?device_id={{ device.device_id }}&version={{ content.version }}" class="btn btn-outline-primary" title="上传图片">
<i class="fas fa-image"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h5>暂无内容</h5>
<p class="text-muted">此设备还没有任何内容。</p>
<a href="/admin/devices/{{ device.device_id }}/contents/add" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> 添加第一个内容
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function refreshDevice(deviceId) {
showToast('正在刷新设备状态...', 'info');
fetch(`/api/devices/${deviceId}/status`, {
method: 'GET'
})
.then(response => response.json())
.then(data => {
if (data.online) {
showToast('设备在线', 'success');
} else {
showToast('设备离线', 'warning');
}
location.reload();
})
.catch(error => {
console.error('Error:', error);
showToast('获取设备状态失败', 'error');
});
}
function rebootDevice(deviceId) {
if (confirm('确定要重启设备吗?设备可能需要几分钟才能重新上线。')) {
showToast('正在发送重启命令...', 'info');
fetch(`/api/devices/${deviceId}/reboot`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
showToast('重启命令已发送', 'success');
})
.catch(error => {
console.error('Error:', error);
showToast('发送重启命令失败', 'error');
});
}
}
function pushContent(deviceId, version) {
if (confirm('确定要推送此内容到设备吗?')) {
showToast('正在推送内容...', 'info');
fetch(`/api/devices/${deviceId}/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
version: version
})
})
.then(response => response.json())
.then(data => {
showToast('内容推送成功', 'success');
})
.catch(error => {
console.error('Error:', error);
showToast('内容推送失败', 'error');
});
}
}
function toggleContentList() {
const contentList = document.getElementById('contentList');
contentList.classList.toggle('d-none');
}
function showToast(message, type) {
// 创建一个简单的toast通知而不是使用alert
const toast = document.createElement('div');
toast.className = `alert alert-${type === 'success' ? 'success' : type === 'warning' ? 'warning' : type === 'info' ? 'info' : 'danger'} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = '1050';
toast.innerHTML = `<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'warning' ? 'exclamation-triangle' : type === 'info' ? 'info-circle' : 'exclamation-circle'} me-2"></i>${message}`;
document.body.appendChild(toast);
// 3秒后自动移除
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.5s';
setTimeout(() => {
document.body.removeChild(toast);
}, 500);
}, 3000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,148 @@
{% extends "admin/base.html" %}
{% block title %}设备管理 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-mobile-alt me-2"></i>设备管理</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
<i class="fas fa-filter me-1"></i> 筛选
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" data-filter="all">全部设备</a></li>
<li><a class="dropdown-item" href="#" data-filter="online">在线设备</a></li>
<li><a class="dropdown-item" href="#" data-filter="offline">离线设备</a></li>
<li><a class="dropdown-item" href="#" data-filter="disabled">禁用设备</a></li>
</ul>
</div>
<a href="/admin/devices/add" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> 添加设备
</a>
</div>
</div>
{% if devices %}
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">设备列表</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle table-sortable" data-sort-order="asc">
<thead>
<tr>
<th data-sort="device_id">设备ID <i class="fas fa-sort text-muted"></i></th>
<th data-sort="name">名称 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="scene">场景 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="status">状态 <i class="fas fa-sort text-muted"></i></th>
<th data-sort="last_online">最后上线 <i class="fas fa-sort text-muted"></i></th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr data-status="{% if device.is_active %}{% if device.is_online %}online{% else %}offline{% endif %}{% else %}disabled{% endif %}">
<td data-column="device_id">
<a href="/admin/devices/{{ device.device_id }}" class="text-decoration-none fw-bold">
{{ device.device_id }}
</a>
</td>
<td data-column="name">{{ device.name }}</td>
<td data-column="scene">
<span class="badge bg-light text-dark">{{ device.scene }}</span>
</td>
<td data-column="status">
{% if device.is_active %}
{% if device.is_online %}
<span class="badge bg-success">
<i class="fas fa-circle me-1" style="font-size: 0.5rem;"></i>在线
</span>
{% else %}
<span class="badge bg-warning">
<i class="fas fa-circle me-1" style="font-size: 0.5rem;"></i>离线
</span>
{% endif %}
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-circle me-1" style="font-size: 0.5rem;"></i>禁用
</span>
{% endif %}
</td>
<td data-column="last_online">
{% if device.last_online %}
<span title="{{ device.last_online.strftime('%Y-%m-%d %H:%M:%S') }}">
{{ device.last_online.strftime('%Y-%m-%d %H:%M') }}
</span>
{% else %}
<span class="text-muted">从未</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/admin/devices/{{ device.device_id }}" class="btn btn-outline-info" title="查看详情">
<i class="fas fa-eye"></i>
</a>
<a href="/admin/devices/{{ device.device_id }}/contents/add" class="btn btn-outline-success" title="添加内容">
<i class="fas fa-plus"></i>
</a>
<button class="btn btn-outline-primary" onclick="refreshDevice('{{ device.device_id }}')" title="刷新状态">
<i class="fas fa-sync-alt"></i>
</button>
<button class="btn btn-outline-danger" onclick="deleteDevice('{{ device.device_id }}')" title="删除设备">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5">
<i class="fas fa-mobile-alt fa-4x text-muted mb-4"></i>
<h5 class="card-title">暂无设备</h5>
<p class="card-text">您还没有添加任何设备。</p>
<a href="/admin/devices/add" class="btn btn-primary btn-lg">
<i class="fas fa-plus me-2"></i> 添加第一个设备
</a>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// 设备筛选功能
document.addEventListener('DOMContentLoaded', function() {
const filterButtons = document.querySelectorAll('[data-filter]');
const deviceRows = document.querySelectorAll('tbody tr');
filterButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const filter = this.getAttribute('data-filter');
// 更新按钮状态
filterButtons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
// 筛选设备
deviceRows.forEach(row => {
if (filter === 'all') {
row.style.display = '';
} else {
const status = row.getAttribute('data-status');
row.style.display = status === filter ? '' : 'none';
}
});
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,288 @@
{% extends "admin/base.html" %}
{% block title %}图片上传 - 墨水屏管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-image me-2"></i>图片上传</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="/admin/contents" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i> 返回内容列表
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">图片上传</h6>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" id="uploadForm">
<div class="mb-3">
<label for="device_id" class="form-label">设备</label>
<select class="form-select" id="device_id" name="device_id" required>
<option value="">请选择设备</option>
{% for device in devices %}
<option value="{{ device.device_id }}">{{ device.name }} ({{ device.device_id }})</option>
{% endfor %}
</select>
<div class="form-text">选择要上传图片的设备</div>
</div>
<div class="mb-3">
<label for="version" class="form-label">内容版本</label>
<select class="form-select" id="version" name="version">
<option value="">创建新版本</option>
</select>
<div class="form-text">选择现有版本或创建新版本</div>
</div>
<div class="mb-3">
<label for="title" class="form-label">内容标题</label>
<input type="text" class="form-control" id="title" name="title" placeholder="输入内容标题">
<div class="form-text">为图片内容添加描述性标题</div>
</div>
<div class="mb-3">
<label for="image" class="form-label">图片文件</label>
<div class="upload-area" id="uploadArea">
<input type="file" class="form-control" id="image" name="image" accept="image/*" required>
<div class="upload-overlay">
<div class="upload-content">
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
<p class="mb-2">拖拽图片到此处或点击选择</p>
<p class="text-muted small">支持JPG、PNG、BMP等常见图片格式</p>
</div>
</div>
</div>
</div>
<!-- 图片预览 -->
<div class="mb-3" id="imagePreview" style="display: none;">
<label class="form-label">图片预览</label>
<div class="preview-container">
<img id="previewImg" src="#" alt="图片预览" class="img-fluid">
<div class="preview-actions">
<button type="button" class="btn btn-sm btn-outline-danger" id="removeImage">
<i class="fas fa-trash"></i> 移除
</button>
</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="autoPush" name="autoPush" checked>
<label class="form-check-label" for="autoPush">
上传后自动推送到设备
</label>
<div class="form-text">上传完成后自动将内容推送到设备</div>
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enhanceContrast" name="enhanceContrast" checked>
<label class="form-check-label" for="enhanceContrast">
增强图片对比度
</label>
<div class="form-text">自动优化图片对比度以适应墨水屏显示</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="/admin/contents" class="btn btn-secondary me-md-2">
<i class="fas fa-times me-1"></i> 取消
</a>
<button type="submit" class="btn btn-primary" id="uploadBtn">
<i class="fas fa-upload me-1"></i> 上传图片
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm mb-4">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-info">图片处理说明</h6>
</div>
<div class="card-body">
<h5 class="mb-3"><i class="fas fa-info-circle me-2"></i>支持的图片格式</h5>
<div class="row g-2 mb-4">
<div class="col-6"><span class="badge bg-light text-dark">JPEG/JPG</span></div>
<div class="col-6"><span class="badge bg-light text-dark">PNG</span></div>
<div class="col-6"><span class="badge bg-light text-dark">BMP</span></div>
<div class="col-6"><span class="badge bg-light text-dark">WEBP</span></div>
</div>
<h5 class="mb-3"><i class="fas fa-cogs me-2"></i>图片处理流程</h5>
<div class="process-steps">
<div class="step-item">
<div class="step-number">1</div>
<div class="step-text">上传原始图片</div>
</div>
<div class="step-item">
<div class="step-number">2</div>
<div class="step-text">自动转换为黑白图像</div>
</div>
<div class="step-item">
<div class="step-number">3</div>
<div class="step-text">调整大小以适应墨水屏</div>
</div>
<div class="step-item">
<div class="step-number">4</div>
<div class="step-text">优化对比度</div>
</div>
<div class="step-item">
<div class="step-number">5</div>
<div class="step-text">保存处理后的图片</div>
</div>
</div>
<h5 class="mb-3 mt-4"><i class="fas fa-desktop me-2"></i>墨水屏规格</h5>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
分辨率
<span class="badge bg-primary rounded-pill">400×300 像素</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
显示模式
<span class="badge bg-secondary rounded-pill">黑白</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
刷新时间
<span class="badge bg-info rounded-pill">约2-3秒</span>
</li>
</ul>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-warning">使用提示</h6>
</div>
<div class="card-body">
<div class="alert alert-light border-0">
<ul class="mb-0">
<li class="mb-2"><i class="fas fa-check-circle text-success me-2"></i>建议上传高对比度的图片,以获得更好的显示效果</li>
<li class="mb-2"><i class="fas fa-check-circle text-success me-2"></i>图片内容应简洁明了,避免过多细节</li>
<li class="mb-2"><i class="fas fa-check-circle text-success me-2"></i>文字内容应使用较大字号,确保可读性</li>
<li><i class="fas fa-check-circle text-success me-2"></i>上传后会自动处理为适合墨水屏显示的格式</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const deviceSelect = document.getElementById('device_id');
const versionSelect = document.getElementById('version');
const imageInput = document.getElementById('image');
const imagePreview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
const uploadForm = document.getElementById('uploadForm');
const uploadBtn = document.getElementById('uploadBtn');
const uploadArea = document.getElementById('uploadArea');
const removeImageBtn = document.getElementById('removeImage');
// 拖拽上传功能
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
this.classList.add('drag-over');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
this.classList.remove('drag-over');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
this.classList.remove('drag-over');
if (e.dataTransfer.files.length) {
imageInput.files = e.dataTransfer.files;
showImagePreview(e.dataTransfer.files[0]);
}
});
// 点击上传区域触发文件选择
uploadArea.addEventListener('click', function() {
imageInput.click();
});
// 移除图片
removeImageBtn.addEventListener('click', function() {
imageInput.value = '';
imagePreview.style.display = 'none';
});
// 设备选择变化时,加载该设备的内容版本
deviceSelect.addEventListener('change', function() {
const deviceId = this.value;
if (!deviceId) {
versionSelect.innerHTML = '<option value="">创建新版本</option>';
return;
}
// 获取设备的内容版本
fetch(`/api/devices/${deviceId}/contents`)
.then(response => response.json())
.then(data => {
versionSelect.innerHTML = '<option value="">创建新版本</option>';
data.contents.forEach(content => {
const option = document.createElement('option');
option.value = content.version;
option.textContent = `v${content.version} - ${content.title}`;
versionSelect.appendChild(option);
});
})
.catch(error => {
console.error('Error:', error);
versionSelect.innerHTML = '<option value="">创建新版本</option>';
});
});
// 图片选择变化时,显示预览
imageInput.addEventListener('change', function() {
if (this.files && this.files[0]) {
showImagePreview(this.files[0]);
} else {
imagePreview.style.display = 'none';
}
});
// 显示图片预览
function showImagePreview(file) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
imagePreview.style.display = 'block';
};
reader.readAsDataURL(file);
}
}
// 表单提交时,显示加载状态
uploadForm.addEventListener('submit', function(e) {
uploadBtn.disabled = true;
uploadBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>上传中...';
});
});
</script>
{% endblock %}