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