增加API鉴权
This commit is contained in:
6
.env
6
.env
@@ -25,4 +25,8 @@ INK_HEIGHT=300
|
||||
# 安全配置
|
||||
SECRET_KEY=123tangledup-ai
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 管理员配置
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=123456
|
||||
Binary file not shown.
BIN
__pycache__/auth.cpython-312.pyc
Normal file
BIN
__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from config import settings
|
||||
|
||||
from database import get_db
|
||||
from models import Device as DeviceModel, Content as ContentModel, Todo as TodoModel
|
||||
@@ -20,6 +21,68 @@ templates = Jinja2Templates(directory="templates")
|
||||
# 创建管理后台路由
|
||||
admin_router = APIRouter()
|
||||
|
||||
# 登录页面
|
||||
@admin_router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: Optional[str] = None):
|
||||
"""
|
||||
管理员登录页面
|
||||
"""
|
||||
# 如果已经登录,重定向到首页
|
||||
if request.session.get("authenticated"):
|
||||
return RedirectResponse(url=next or "/admin/", status_code=303)
|
||||
|
||||
return templates.TemplateResponse("admin/login.html", {
|
||||
"request": request,
|
||||
"next": next
|
||||
})
|
||||
|
||||
# 登录处理
|
||||
@admin_router.post("/login", response_class=HTMLResponse)
|
||||
async def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
remember: Optional[bool] = Form(False),
|
||||
next: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
处理管理员登录
|
||||
"""
|
||||
# 验证用户名和密码
|
||||
# 这里使用配置文件中的设置,实际项目中应该使用数据库存储用户信息
|
||||
if username == settings.admin_username and password == settings.admin_password:
|
||||
# 设置会话
|
||||
request.session["authenticated"] = True
|
||||
request.session["username"] = username
|
||||
|
||||
# 设置会话过期时间
|
||||
if remember:
|
||||
request.session["expire_at_browser_close"] = False
|
||||
else:
|
||||
request.session["expire_at_browser_close"] = True
|
||||
|
||||
# 重定向到原始请求的页面或首页
|
||||
return RedirectResponse(url=next or "/admin/", status_code=303)
|
||||
else:
|
||||
# 登录失败,返回错误信息
|
||||
return templates.TemplateResponse("admin/login.html", {
|
||||
"request": request,
|
||||
"next": next,
|
||||
"error": "用户名或密码错误"
|
||||
})
|
||||
|
||||
# 登出
|
||||
@admin_router.get("/logout", response_class=HTMLResponse)
|
||||
async def logout(request: Request):
|
||||
"""
|
||||
管理员登出
|
||||
"""
|
||||
# 清除会话
|
||||
request.session.clear()
|
||||
|
||||
# 重定向到登录页面
|
||||
return RedirectResponse(url="/admin/login", status_code=303)
|
||||
|
||||
# 管理后台路由
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def admin_dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
@@ -3,7 +3,7 @@ from api import devices, contents, todos
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 注册所有路由
|
||||
api_router.include_router(devices.router)
|
||||
api_router.include_router(contents.router)
|
||||
api_router.include_router(todos.router)
|
||||
# 注册所有路由,并添加全局安全要求
|
||||
api_router.include_router(devices.router, prefix="/devices")
|
||||
api_router.include_router(contents.router, prefix="/contents")
|
||||
api_router.include_router(todos.router, prefix="/todos")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, File, UploadFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, File, UploadFile, Security
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List, Optional
|
||||
@@ -11,13 +11,13 @@ 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
|
||||
from auth import get_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["contents"]
|
||||
)
|
||||
|
||||
@router.post("/devices/{device_id}/content", response_model=ContentSchema, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/devices/{device_id}/content", response_model=ContentSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)])
|
||||
async def create_content(
|
||||
device_id: str,
|
||||
content: ContentCreate,
|
||||
@@ -60,7 +60,7 @@ async def create_content(
|
||||
|
||||
return db_content
|
||||
|
||||
@router.get("/devices/{device_id}/content", response_model=List[ContentSchema])
|
||||
@router.get("/devices/{device_id}/content", response_model=List[ContentSchema], dependencies=[Depends(get_api_key)])
|
||||
async def list_content(
|
||||
device_id: str,
|
||||
skip: int = 0,
|
||||
@@ -87,7 +87,7 @@ async def list_content(
|
||||
contents = query.order_by(ContentModel.version.desc()).offset(skip).limit(limit).all()
|
||||
return contents
|
||||
|
||||
@router.get("/devices/{device_id}/content/{version}", response_model=ContentResponse)
|
||||
@router.get("/devices/{device_id}/content/{version}", response_model=ContentResponse, dependencies=[Depends(get_api_key)])
|
||||
async def get_content(
|
||||
device_id: str,
|
||||
version: int,
|
||||
@@ -141,7 +141,7 @@ async def get_content(
|
||||
created_at=content.created_at
|
||||
)
|
||||
|
||||
@router.put("/devices/{device_id}/content/{version}", response_model=ContentSchema)
|
||||
@router.put("/devices/{device_id}/content/{version}", response_model=ContentSchema, dependencies=[Depends(get_api_key)])
|
||||
async def update_content(
|
||||
device_id: str,
|
||||
version: int,
|
||||
@@ -176,7 +176,7 @@ async def update_content(
|
||||
|
||||
return content
|
||||
|
||||
@router.delete("/devices/{device_id}/content/{version}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/devices/{device_id}/content/{version}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)])
|
||||
async def delete_content(
|
||||
device_id: str,
|
||||
version: int,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Security
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
@@ -9,13 +9,13 @@ from schemas import Device as DeviceSchema, DeviceCreate, DeviceUpdate, Bootstra
|
||||
from models import Device as DeviceModel
|
||||
from database import Content as ContentModel
|
||||
from mqtt_manager import mqtt_manager
|
||||
from auth import get_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/devices",
|
||||
tags=["devices"]
|
||||
)
|
||||
|
||||
@router.post("/", response_model=DeviceSchema, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/", response_model=DeviceSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)])
|
||||
async def create_device(device: DeviceCreate, db: Session = Depends(get_db)):
|
||||
"""
|
||||
注册新设备
|
||||
@@ -46,7 +46,7 @@ async def create_device(device: DeviceCreate, db: Session = Depends(get_db)):
|
||||
|
||||
return db_device
|
||||
|
||||
@router.get("/", response_model=List[DeviceSchema])
|
||||
@router.get("/", response_model=List[DeviceSchema], dependencies=[Depends(get_api_key)])
|
||||
async def list_devices(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
@@ -68,7 +68,7 @@ async def list_devices(
|
||||
devices = query.offset(skip).limit(limit).all()
|
||||
return devices
|
||||
|
||||
@router.get("/{device_id}", response_model=DeviceSchema)
|
||||
@router.get("/{device_id}", response_model=DeviceSchema, dependencies=[Depends(get_api_key)])
|
||||
async def get_device(device_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取设备详情
|
||||
@@ -81,7 +81,7 @@ async def get_device(device_id: str, db: Session = Depends(get_db)):
|
||||
)
|
||||
return device
|
||||
|
||||
@router.put("/{device_id}", response_model=DeviceSchema)
|
||||
@router.put("/{device_id}", response_model=DeviceSchema, dependencies=[Depends(get_api_key)])
|
||||
async def update_device(
|
||||
device_id: str,
|
||||
device_update: DeviceUpdate,
|
||||
@@ -108,7 +108,7 @@ async def update_device(
|
||||
|
||||
return device
|
||||
|
||||
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)])
|
||||
async def delete_device(device_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
删除设备
|
||||
@@ -126,11 +126,12 @@ async def delete_device(device_id: str, db: Session = Depends(get_db)):
|
||||
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)):
|
||||
@router.post("/{device_id}/bootstrap", response_model=BootstrapResponse, dependencies=[Depends(get_api_key)])
|
||||
async def bootstrap_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(
|
||||
@@ -138,29 +139,19 @@ async def device_bootstrap(device_id: str, db: Session = Depends(get_db)):
|
||||
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()
|
||||
# 获取设备场景的内容
|
||||
contents = db.query(ContentModel).filter(ContentModel.scene == device.scene).all()
|
||||
|
||||
# 构建响应
|
||||
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"
|
||||
scene=device.scene,
|
||||
contents=contents
|
||||
)
|
||||
|
||||
if latest_content:
|
||||
response.content_version = latest_content.version
|
||||
response.last_updated = latest_content.created_at
|
||||
|
||||
return response
|
||||
|
||||
@router.get("/{device_id}/status")
|
||||
@router.get("/{device_id}/status", dependencies=[Depends(get_api_key)])
|
||||
async def get_device_status(device_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取设备状态
|
||||
|
||||
18
api/todos.py
18
api/todos.py
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Security
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
@@ -7,13 +7,13 @@ from database import get_db
|
||||
from schemas import Todo as TodoSchema, TodoCreate, TodoUpdate
|
||||
from models import Todo as TodoModel
|
||||
from database import Device as DeviceModel
|
||||
from auth import get_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/todos",
|
||||
tags=["todos"]
|
||||
)
|
||||
|
||||
@router.post("/", response_model=TodoSchema, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/", response_model=TodoSchema, status_code=status.HTTP_201_CREATED, dependencies=[Depends(get_api_key)])
|
||||
async def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
|
||||
"""
|
||||
创建新的待办事项
|
||||
@@ -40,7 +40,7 @@ async def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
|
||||
|
||||
return db_todo
|
||||
|
||||
@router.get("/", response_model=List[TodoSchema])
|
||||
@router.get("/", response_model=List[TodoSchema], dependencies=[Depends(get_api_key)])
|
||||
async def list_todos(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
@@ -62,7 +62,7 @@ async def list_todos(
|
||||
todos = query.order_by(TodoModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return todos
|
||||
|
||||
@router.get("/{todo_id}", response_model=TodoSchema)
|
||||
@router.get("/{todo_id}", response_model=TodoSchema, dependencies=[Depends(get_api_key)])
|
||||
async def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
获取待办事项详情
|
||||
@@ -75,7 +75,7 @@ async def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
)
|
||||
return todo
|
||||
|
||||
@router.put("/{todo_id}", response_model=TodoSchema)
|
||||
@router.put("/{todo_id}", response_model=TodoSchema, dependencies=[Depends(get_api_key)])
|
||||
async def update_todo(
|
||||
todo_id: int,
|
||||
todo_update: TodoUpdate,
|
||||
@@ -110,7 +110,7 @@ async def update_todo(
|
||||
|
||||
return todo
|
||||
|
||||
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_api_key)])
|
||||
async def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
删除待办事项
|
||||
@@ -125,7 +125,7 @@ async def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
db.delete(todo)
|
||||
db.commit()
|
||||
|
||||
@router.post("/{todo_id}/complete", response_model=TodoSchema)
|
||||
@router.post("/{todo_id}/complete", response_model=TodoSchema, dependencies=[Depends(get_api_key)])
|
||||
async def complete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
标记待办事项为完成
|
||||
@@ -146,7 +146,7 @@ async def complete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
return todo
|
||||
|
||||
@router.post("/{todo_id}/incomplete", response_model=TodoSchema)
|
||||
@router.post("/{todo_id}/incomplete", response_model=TodoSchema, dependencies=[Depends(get_api_key)])
|
||||
async def incomplete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
"""
|
||||
标记待办事项为未完成
|
||||
|
||||
118
auth.py
Normal file
118
auth.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from fastapi import HTTPException, status, Request, Security, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
from config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建API Key安全方案
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
async def get_api_key(api_key: str = Security(api_key_header)):
|
||||
"""
|
||||
API Key依赖项,用于路由级别的鉴权
|
||||
"""
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="缺少API Key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
if api_key != settings.secret_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的API Key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
class APIKeyMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
API Key鉴权中间件
|
||||
验证请求中的Secret Key
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 跳过不需要鉴权的路径
|
||||
if self._should_skip_auth(request.url.path):
|
||||
return await call_next(request)
|
||||
|
||||
# 检查API Key
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
if not api_key:
|
||||
logger.warning(f"缺少API Key: {request.method} {request.url.path}")
|
||||
return Response(
|
||||
content='{"detail": "缺少API Key"}',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
# 验证API Key
|
||||
if api_key != settings.secret_key:
|
||||
logger.warning(f"无效的API Key: {request.method} {request.url.path}")
|
||||
return Response(
|
||||
content='{"detail": "无效的API Key"}',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _should_skip_auth(self, path: str) -> bool:
|
||||
"""
|
||||
判断是否跳过鉴权的路径
|
||||
"""
|
||||
# 所有API路径都需要鉴权,不跳过
|
||||
# 如果路径以/api开头,则不跳过(需要鉴权)
|
||||
if path.startswith("/api"):
|
||||
return False
|
||||
|
||||
skip_paths = [
|
||||
"/",
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/admin",
|
||||
"/admin/login",
|
||||
"/static",
|
||||
]
|
||||
|
||||
# 检查是否以跳过路径开头
|
||||
for skip_path in skip_paths:
|
||||
if path.startswith(skip_path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class AdminAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Admin页面认证中间件
|
||||
验证用户是否已登录
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 只对admin路径进行认证
|
||||
if not request.url.path.startswith("/admin") or request.url.path == "/admin/login":
|
||||
return await call_next(request)
|
||||
|
||||
# 检查会话
|
||||
if not self._is_authenticated(request):
|
||||
# 重定向到登录页面
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/admin/login?next=" + request.url.path, status_code=303)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _is_authenticated(self, request: Request) -> bool:
|
||||
"""
|
||||
检查用户是否已认证
|
||||
"""
|
||||
# 从session中获取认证信息
|
||||
session = request.session
|
||||
return session.get("authenticated", False)
|
||||
@@ -29,6 +29,10 @@ class Settings(BaseSettings):
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# 管理员配置
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "123456"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
30
main.py
30
main.py
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import os
|
||||
@@ -10,6 +11,7 @@ from database import init_db
|
||||
from mqtt_manager import mqtt_manager
|
||||
from api import api_router
|
||||
from admin_routes import admin_router
|
||||
from auth import APIKeyMiddleware, AdminAuthMiddleware
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
@@ -51,10 +53,21 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# 创建FastAPI应用
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="基于 FastAPI + MQTT + HTTP/HTTPS + NTP 的轻量级墨水屏显示系统服务端",
|
||||
title="墨水屏桌面屏幕系统 API",
|
||||
description="用于管理墨水屏设备、内容和待办事项的API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
lifespan=lifespan,
|
||||
openapi_components={
|
||||
"securitySchemes": {
|
||||
"APIKeyHeader": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
"description": "API Key鉴权,请在下方输入正确的API Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
security=[{"APIKeyHeader": []}]
|
||||
)
|
||||
|
||||
# 添加CORS中间件
|
||||
@@ -66,11 +79,20 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 添加API Key鉴权中间件
|
||||
app.add_middleware(APIKeyMiddleware)
|
||||
|
||||
# 添加Admin认证中间件
|
||||
app.add_middleware(AdminAuthMiddleware)
|
||||
|
||||
# 添加Session中间件
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
||||
|
||||
# 挂载静态文件
|
||||
app.mount("/static", StaticFiles(directory=settings.static_dir), name="static")
|
||||
|
||||
# 注册API路由
|
||||
app.include_router(api_router)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
# 包含管理后台路由
|
||||
app.include_router(admin_router, prefix="/admin", tags=["管理后台"])
|
||||
|
||||
@@ -50,7 +50,22 @@
|
||||
<button class="btn btn-outline-light me-2" id="themeToggle" title="切换主题">
|
||||
<i class="fas fa-palette"></i>
|
||||
</button>
|
||||
<span class="navbar-text text-light">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-light dropdown-toggle" type="button" id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-user-circle me-1"></i>
|
||||
{% if request.session.get('username') %}
|
||||
{{ request.session.get('username') }}
|
||||
{% else %}
|
||||
管理员
|
||||
{% endif %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||
<li><a class="dropdown-item" href="/admin/logout">
|
||||
<i class="fas fa-sign-out-alt me-1"></i>登出
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="navbar-text text-light ms-2">
|
||||
<i class="far fa-clock me-1"></i>
|
||||
<span id="currentTime"></span>
|
||||
</span>
|
||||
|
||||
74
templates/admin/login.html
Normal file
74
templates/admin/login.html
Normal file
@@ -0,0 +1,74 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}管理员登录{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>管理员登录</h2>
|
||||
<p class="text-muted">墨水屏桌面屏幕系统</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密码</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">记住我</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin-bottom: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user