first commit
This commit is contained in:
28
.env
Normal file
28
.env
Normal 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
28
.env.example
Normal 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
|
||||||
21
.trae/rules/project_rules.md
Normal file
21
.trae/rules/project_rules.md
Normal 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 保障时间准确
|
||||||
|
图片处理 Pillow(Python) 服务端快速预处理图片为墨水屏兼容格式
|
||||||
47
README.md
Normal file
47
README.md
Normal 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}` - 下载处理后的图片
|
||||||
BIN
__pycache__/admin_routes.cpython-312.pyc
Normal file
BIN
__pycache__/admin_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-312.pyc
Normal file
BIN
__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/config.cpython-313.pyc
Normal file
BIN
__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-312.pyc
Normal file
BIN
__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/database.cpython-313.pyc
Normal file
BIN
__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/image_processor.cpython-312.pyc
Normal file
BIN
__pycache__/image_processor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-312.pyc
Normal file
BIN
__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/main.cpython-313.pyc
Normal file
BIN
__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/models.cpython-312.pyc
Normal file
BIN
__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/mqtt_manager.cpython-312.pyc
Normal file
BIN
__pycache__/mqtt_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
BIN
__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
344
admin_routes.py
Normal file
344
admin_routes.py
Normal 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
8
api/__init__.py
Normal 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)
|
||||||
BIN
api/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
api/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/contents.cpython-312.pyc
Normal file
BIN
api/__pycache__/contents.cpython-312.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/devices.cpython-312.pyc
Normal file
BIN
api/__pycache__/devices.cpython-312.pyc
Normal file
Binary file not shown.
339
api/contents.py
Normal file
339
api/contents.py
Normal 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
183
api/devices.py
Normal 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
35
config.py
Normal 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
57
database.py
Normal 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
134
image_processor.py
Normal 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
111
main.py
Normal 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
7
models.py
Normal 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
170
mqtt_manager.py
Normal 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
13
requirements.txt
Normal 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
89
schemas.py
Normal 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
37
start.sh
Executable 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
|
||||||
868
static/admin/css/admin-enhanced.css
Normal file
868
static/admin/css/admin-enhanced.css
Normal 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
346
static/admin/css/admin.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
511
static/admin/js/admin-enhanced.js
Normal file
511
static/admin/js/admin-enhanced.js
Normal 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
471
static/admin/js/admin.js
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
BIN
static/processed/b8b1b0f5-a81c-41e1-87e3-f3cbaadd8099.bmp
Normal file
BIN
static/processed/b8b1b0f5-a81c-41e1-87e3-f3cbaadd8099.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
static/uploads/2f2fcebe-b1af-4219-9da8-d507da9dad97.jpg
Normal file
BIN
static/uploads/2f2fcebe-b1af-4219-9da8-d507da9dad97.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
178
templates/admin/add_content.html
Normal file
178
templates/admin/add_content.html
Normal 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 %}
|
||||||
158
templates/admin/add_device.html
Normal file
158
templates/admin/add_device.html
Normal 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
79
templates/admin/base.html
Normal 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>
|
||||||
295
templates/admin/content_detail.html
Normal file
295
templates/admin/content_detail.html
Normal 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 %}
|
||||||
236
templates/admin/contents.html
Normal file
236
templates/admin/contents.html
Normal 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 %}
|
||||||
470
templates/admin/dashboard.html
Normal file
470
templates/admin/dashboard.html
Normal 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 %}
|
||||||
277
templates/admin/device_detail.html
Normal file
277
templates/admin/device_detail.html
Normal 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 %}
|
||||||
148
templates/admin/devices.html
Normal file
148
templates/admin/devices.html
Normal 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 %}
|
||||||
288
templates/admin/upload_image.html
Normal file
288
templates/admin/upload_image.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user