Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
View File
+54
View File
@@ -0,0 +1,54 @@
from sqladmin.authentication import AuthenticationBackend
from sqlalchemy import select
from starlette.requests import Request
from app.core.security import verify_password
from app.db.session import sync_engine
from app.models.user import User
from sqlalchemy.orm import Session
class AdminAuthBackend(AuthenticationBackend):
async def login(self, request: Request) -> bool:
form = await request.form()
username = form.get("username", "")
password = form.get("password", "")
with Session(sync_engine) as session:
result = session.execute(
select(User).where(
(User.phone == username) | (User.email == username)
)
)
user = result.scalar_one_or_none()
if not user:
return False
if user.role not in ("admin", "moderator"):
return False
if not verify_password(str(password), user.password_hash):
return False
request.session.update({"admin_user_id": user.id})
return True
async def logout(self, request: Request) -> bool:
request.session.clear()
return True
async def authenticate(self, request: Request) -> bool:
user_id = request.session.get("admin_user_id")
if not user_id:
return False
with Session(sync_engine) as session:
result = session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or user.role not in ("admin", "moderator"):
return False
return True
+76
View File
@@ -0,0 +1,76 @@
"""Admin media library API - list & upload images (session-authenticated)."""
import os
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from app.core.config import settings
router = APIRouter(prefix="/admin/api/media", tags=["admin-media"])
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_SIZE = 10 * 1024 * 1024
def _check_admin_session(request: Request):
uid = request.session.get("admin_user_id")
if not uid:
raise HTTPException(status_code=401, detail="未登录")
@router.get("/list")
async def list_media(request: Request):
_check_admin_session(request)
images = []
if settings.STORAGE_BACKEND == "local":
upload_dir = Path(settings.LOCAL_STORAGE_PATH)
if upload_dir.exists():
for f in sorted(upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if f.is_file() and f.suffix.lower() in ALLOWED_EXTENSIONS:
images.append({
"url": f"/uploads/{f.name}",
"name": f.name,
"size": f.stat().st_size,
})
else:
storage = request.app.state.storage
try:
resp = storage.client.list_objects_v2(Bucket=storage.bucket, Prefix="images/", MaxKeys=200)
for obj in resp.get("Contents", []):
key = obj["Key"]
ext = Path(key).suffix.lower()
if ext in ALLOWED_EXTENSIONS:
images.append({
"url": storage._get_url(key),
"name": Path(key).name,
"size": obj.get("Size", 0),
})
except Exception:
pass
return JSONResponse({"images": images})
@router.post("/upload")
async def upload_media(request: Request, file: UploadFile):
_check_admin_session(request)
if not file.filename:
raise HTTPException(status_code=400, detail="缺少文件")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {ext}")
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(status_code=413, detail="文件过大,最大10MB")
storage = request.app.state.storage
url = storage.upload(data, file.filename, file.content_type or "")
return JSONResponse({"url": url, "name": file.filename})
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
"""Custom WTForms fields/widgets for the admin image picker."""
from markupsafe import Markup
from wtforms import StringField
from wtforms.widgets import TextInput
class ImagePickerWidget(TextInput):
def __call__(self, field, **kwargs):
kwargs.setdefault("class", "form-control image-picker-input")
kwargs["style"] = kwargs.get("style", "") + ";display:none"
kwargs["id"] = field.id
field_id = field.id
val = field._value() if hasattr(field, "_value") else (field.data or "")
hidden = (
f'<input type="text" name="{field.name}" id="{field_id}" '
f'value="{val}" class="form-control image-picker-input" style="display:none">'
)
preview_src = val or ""
preview_display = "block" if preview_src else "none"
html = f"""
<div class="image-picker-wrapper" data-field="{field_id}">
{hidden}
<div class="image-picker-preview" id="preview-{field_id}" style="display:{preview_display}">
<img src="{preview_src}" id="preview-img-{field_id}" alt="预览">
<button type="button" class="image-picker-remove" onclick="imagePickerClear('{field_id}')"
title="移除图片">&times;</button>
</div>
<div class="image-picker-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="imagePickerOpen('{field_id}')">
<i class="fa-solid fa-images"></i> 从素材库选择
</button>
<button type="button" class="btn btn-sm btn-outline-success"
onclick="imagePickerUpload('{field_id}')">
<i class="fa-solid fa-upload"></i> 上传新图片
</button>
<input type="file" id="file-{field_id}" accept="image/*"
style="display:none" onchange="imagePickerFileSelected('{field_id}', this)">
</div>
<div class="image-picker-url-display" id="url-display-{field_id}"
style="margin-top:6px;font-size:12px;color:#94a3b8;word-break:break-all">
{val}
</div>
</div>
"""
return Markup(html)
class ImagePickerField(StringField):
widget = ImagePickerWidget()