feat: initial postcard manager - FastAPI + Jinja2 + SQLite
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
86
app/auth.py
Normal file
86
app/auth.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Cookie, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import SECRET_KEY, SESSION_COOKIE_NAME
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, User
|
||||
|
||||
# ---------- Password ----------
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode(), password_hash.encode())
|
||||
|
||||
|
||||
# ---------- Session ----------
|
||||
|
||||
# In-memory session store: token -> user_id
|
||||
_sessions: dict[str, int] = {}
|
||||
|
||||
|
||||
def create_session(user_id: int) -> str:
|
||||
token = uuid4().hex
|
||||
_sessions[token] = user_id
|
||||
return token
|
||||
|
||||
|
||||
def destroy_session(token: str) -> None:
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
def get_session_user_id(token: str | None) -> int | None:
|
||||
if token is None:
|
||||
return None
|
||||
return _sessions.get(token)
|
||||
|
||||
|
||||
# ---------- Web Dependencies ----------
|
||||
|
||||
def get_current_user_web(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
uid = get_session_user_id(token)
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
return user
|
||||
|
||||
|
||||
def redirect_if_not_logged_in(request: Request, db: Session) -> User | None:
|
||||
"""Return user if logged in, else None — caller decides redirect."""
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
uid = get_session_user_id(token)
|
||||
if uid is None:
|
||||
return None
|
||||
return db.query(User).filter(User.id == uid).first()
|
||||
|
||||
|
||||
# ---------- API Dependencies ----------
|
||||
|
||||
def get_current_user_api(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||
token = auth_header[7:]
|
||||
api_key = db.query(ApiKey).filter(ApiKey.key == token).first()
|
||||
if api_key is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
7
app/config.py
Normal file
7
app/config.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATABASE_URL = f"sqlite:///{BASE_DIR / 'postcard.db'}"
|
||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||
SECRET_KEY = "postcard-manager-secret-change-in-production"
|
||||
SESSION_COOKIE_NAME = "pc_session"
|
||||
25
app/database.py
Normal file
25
app/database.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
from app.config import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
from app import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
23
app/main.py
Normal file
23
app/main.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.database import init_db
|
||||
from app.routers import api, web
|
||||
|
||||
app = FastAPI(title="Postcard Manager")
|
||||
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
app.include_router(web.router)
|
||||
app.include_router(api.router)
|
||||
|
||||
init_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
74
app/models.py
Normal file
74
app/models.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(128), nullable=False)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
||||
profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
key = Column(String(64), unique=True, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
@staticmethod
|
||||
def generate_key() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
__tablename__ = "profiles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
nickname = Column(String(64), nullable=False)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
|
||||
user = relationship("User", back_populates="profiles")
|
||||
postcards = relationship("Postcard", back_populates="profile", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Postcard(Base):
|
||||
__tablename__ = "postcards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
|
||||
card_number = Column(String(64), nullable=False)
|
||||
status = Column(String(16), nullable=False) # sent / delivered / received
|
||||
# sent & delivered
|
||||
recipient_name = Column(String(64))
|
||||
country = Column(String(64))
|
||||
send_time = Column(DateTime)
|
||||
arrival_time = Column(DateTime)
|
||||
# received
|
||||
sender_name = Column(String(64))
|
||||
receive_time = Column(DateTime)
|
||||
# images
|
||||
image_front = Column(String(256))
|
||||
image_back = Column(String(256))
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
profile = relationship("Profile", back_populates="postcards")
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
209
app/routers/api.py
Normal file
209
app/routers/api.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user_api, hash_password, verify_password
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, Postcard, Profile, User
|
||||
from app.schemas import (
|
||||
ApiKeyOut,
|
||||
LoginRequest,
|
||||
PostcardCreate,
|
||||
PostcardOut,
|
||||
PostcardUpdate,
|
||||
ProfileCreate,
|
||||
ProfileOut,
|
||||
RegisterRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api"])
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
def api_register(body: RegisterRequest, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.username == body.username).first():
|
||||
raise HTTPException(400, "Username already exists")
|
||||
user = User(username=body.username, password_hash=hash_password(body.password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
def api_login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == body.username).first()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
api_key = ApiKey(user_id=user.id, key=ApiKey.generate_key())
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
return {"api_key": api_key.key}
|
||||
|
||||
|
||||
# ---------- Profile ----------
|
||||
|
||||
def _get_profile(profile_id: int, user: User, db: Session) -> Profile:
|
||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if not p:
|
||||
raise HTTPException(404, "Profile not found")
|
||||
return p
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=list[ProfileOut])
|
||||
def api_list_profiles(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
return db.query(Profile).filter(Profile.user_id == user.id).all()
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileOut, status_code=201)
|
||||
def api_create_profile(body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
p = Profile(user_id=user.id, nickname=body.nickname)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
|
||||
def api_update_profile(profile_id: int, body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
p = _get_profile(profile_id, user, db)
|
||||
p.nickname = body.nickname
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def api_delete_profile(profile_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
p = _get_profile(profile_id, user, db)
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
# ---------- Postcard ----------
|
||||
|
||||
def _get_postcard(postcard_id: int, user: User, db: Session) -> Postcard:
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
raise HTTPException(404, "Postcard not found")
|
||||
return pc
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
|
||||
def api_list_postcards(
|
||||
profile_id: int,
|
||||
status: str | None = None,
|
||||
country: str | None = None,
|
||||
user: User = Depends(get_current_user_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_profile(profile_id, user, db)
|
||||
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
||||
if status:
|
||||
q = q.filter(Postcard.status == status)
|
||||
if country:
|
||||
q = q.filter(Postcard.country.ilike(f"%{country}%"))
|
||||
return q.order_by(Postcard.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
|
||||
def api_create_postcard(
|
||||
profile_id: int,
|
||||
body: PostcardCreate,
|
||||
user: User = Depends(get_current_user_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_profile(profile_id, user, db)
|
||||
pc = Postcard(profile_id=profile_id, **body.model_dump())
|
||||
db.add(pc)
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
@router.get("/postcards/{postcard_id}", response_model=PostcardOut)
|
||||
def api_get_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
return _get_postcard(postcard_id, user, db)
|
||||
|
||||
|
||||
@router.put("/postcards/{postcard_id}", response_model=PostcardOut)
|
||||
def api_update_postcard(
|
||||
postcard_id: int,
|
||||
body: PostcardUpdate,
|
||||
user: User = Depends(get_current_user_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
pc = _get_postcard(postcard_id, user, db)
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(pc, k, v)
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
@router.delete("/postcards/{postcard_id}")
|
||||
def api_delete_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
pc = _get_postcard(postcard_id, user, db)
|
||||
db.delete(pc)
|
||||
db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
# ---------- Image Upload ----------
|
||||
|
||||
@router.post("/postcards/{postcard_id}/image/{side}", response_model=PostcardOut)
|
||||
async def api_upload_image(
|
||||
postcard_id: int,
|
||||
side: str,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if side not in ("front", "back"):
|
||||
raise HTTPException(400, "side must be 'front' or 'back'")
|
||||
pc = _get_postcard(postcard_id, user, db)
|
||||
ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
|
||||
filename = f"{uuid4().hex}{ext}"
|
||||
dest = UPLOAD_DIR / filename
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
setattr(pc, f"image_{side}", f"/uploads/{filename}")
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
# ---------- API Key ----------
|
||||
|
||||
@router.get("/api-keys", response_model=list[ApiKeyOut])
|
||||
def api_list_keys(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
return db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model=ApiKeyOut, status_code=201)
|
||||
def api_create_key(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
ak = ApiKey(user_id=user.id, key=ApiKey.generate_key())
|
||||
db.add(ak)
|
||||
db.commit()
|
||||
db.refresh(ak)
|
||||
return ak
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}")
|
||||
def api_delete_key(key_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not ak:
|
||||
raise HTTPException(404, "API key not found")
|
||||
db.delete(ak)
|
||||
db.commit()
|
||||
return {"message": "ok"}
|
||||
359
app/routers/web.py
Normal file
359
app/routers/web.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import (
|
||||
create_session,
|
||||
destroy_session,
|
||||
get_current_user_web,
|
||||
hash_password,
|
||||
redirect_if_not_logged_in,
|
||||
verify_password,
|
||||
)
|
||||
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, Postcard, Profile, User
|
||||
|
||||
router = APIRouter(tags=["web"])
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||
|
||||
|
||||
def _redirect(url: str) -> RedirectResponse:
|
||||
return RedirectResponse(url, status_code=303)
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request, db: Session = Depends(get_db)):
|
||||
user = redirect_if_not_logged_in(request, db)
|
||||
if user:
|
||||
return _redirect("/dashboard")
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
return templates.TemplateResponse(
|
||||
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401
|
||||
)
|
||||
resp = _redirect("/dashboard")
|
||||
resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/register", response_class=HTMLResponse)
|
||||
def register_page(request: Request):
|
||||
return templates.TemplateResponse("register.html", {"request": request})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
password2: str = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if password != password2:
|
||||
return templates.TemplateResponse(
|
||||
"register.html", {"request": request, "error": "两次密码不一致"}, status_code=400
|
||||
)
|
||||
if db.query(User).filter(User.username == username).first():
|
||||
return templates.TemplateResponse(
|
||||
"register.html", {"request": request, "error": "用户名已存在"}, status_code=400
|
||||
)
|
||||
user = User(username=username, password_hash=hash_password(password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
resp = _redirect("/dashboard")
|
||||
resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
destroy_session(token or "")
|
||||
resp = _redirect("/login")
|
||||
resp.delete_cookie(SESSION_COOKIE_NAME)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------- Dashboard ----------
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def root(user: User = Depends(get_current_user_web)):
|
||||
return _redirect("/dashboard")
|
||||
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
|
||||
# stats per profile
|
||||
stats = {}
|
||||
for p in profiles:
|
||||
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
|
||||
stats[p.id] = {
|
||||
"total": len(cards),
|
||||
"sent": sum(1 for c in cards if c.status == "sent"),
|
||||
"delivered": sum(1 for c in cards if c.status == "delivered"),
|
||||
"received": sum(1 for c in cards if c.status == "received"),
|
||||
}
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "profiles": profiles, "stats": stats})
|
||||
|
||||
|
||||
# ---------- Profiles ----------
|
||||
|
||||
@router.get("/profiles", response_class=HTMLResponse)
|
||||
def profile_list(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
|
||||
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles})
|
||||
|
||||
|
||||
@router.post("/profiles")
|
||||
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
db.add(Profile(user_id=user.id, nickname=nickname))
|
||||
db.commit()
|
||||
return _redirect("/profiles")
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/delete")
|
||||
def profile_delete(profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if p:
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return _redirect("/profiles")
|
||||
|
||||
|
||||
# ---------- Postcards ----------
|
||||
|
||||
@router.get("/profiles/{profile_id}/postcards", response_class=HTMLResponse)
|
||||
def postcard_list(
|
||||
request: Request,
|
||||
profile_id: int,
|
||||
status: str = "",
|
||||
country: str = "",
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if not profile:
|
||||
return _redirect("/profiles")
|
||||
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
||||
if status:
|
||||
q = q.filter(Postcard.status == status)
|
||||
if country:
|
||||
q = q.filter(Postcard.country.ilike(f"%{country}%"))
|
||||
postcards = q.order_by(Postcard.created_at.desc()).all()
|
||||
return templates.TemplateResponse(
|
||||
"postcards.html",
|
||||
{"request": request, "user": user, "profile": profile, "postcards": postcards, "status": status, "country": country},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse)
|
||||
def postcard_new(request: Request, profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if not profile:
|
||||
return _redirect("/profiles")
|
||||
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None})
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/postcards")
|
||||
def postcard_create(
|
||||
request: Request,
|
||||
profile_id: int,
|
||||
card_number: str = Form(...),
|
||||
status: str = Form(...),
|
||||
recipient_name: str = Form(""),
|
||||
country: str = Form(""),
|
||||
send_time: str = Form(""),
|
||||
arrival_time: str = Form(""),
|
||||
sender_name: str = Form(""),
|
||||
receive_time: str = Form(""),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if not profile:
|
||||
return _redirect("/profiles")
|
||||
|
||||
def _parse_dt(s: str) -> datetime | None:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
pc = Postcard(
|
||||
profile_id=profile_id,
|
||||
card_number=card_number,
|
||||
status=status,
|
||||
recipient_name=recipient_name or None,
|
||||
country=country or None,
|
||||
send_time=_parse_dt(send_time),
|
||||
arrival_time=_parse_dt(arrival_time),
|
||||
sender_name=sender_name or None,
|
||||
receive_time=_parse_dt(receive_time),
|
||||
)
|
||||
db.add(pc)
|
||||
db.commit()
|
||||
return _redirect(f"/profiles/{profile_id}/postcards")
|
||||
|
||||
|
||||
@router.get("/postcards/{postcard_id}", response_class=HTMLResponse)
|
||||
def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc})
|
||||
|
||||
|
||||
@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse)
|
||||
def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc})
|
||||
|
||||
|
||||
@router.post("/postcards/{postcard_id}/edit")
|
||||
def postcard_update(
|
||||
request: Request,
|
||||
postcard_id: int,
|
||||
card_number: str = Form(...),
|
||||
status: str = Form(...),
|
||||
recipient_name: str = Form(""),
|
||||
country: str = Form(""),
|
||||
send_time: str = Form(""),
|
||||
arrival_time: str = Form(""),
|
||||
sender_name: str = Form(""),
|
||||
receive_time: str = Form(""),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
|
||||
def _parse_dt(s: str) -> datetime | None:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
pc.card_number = card_number
|
||||
pc.status = status
|
||||
pc.recipient_name = recipient_name or None
|
||||
pc.country = country or None
|
||||
pc.send_time = _parse_dt(send_time)
|
||||
pc.arrival_time = _parse_dt(arrival_time)
|
||||
pc.sender_name = sender_name or None
|
||||
pc.receive_time = _parse_dt(receive_time)
|
||||
db.commit()
|
||||
return _redirect(f"/postcards/{postcard_id}")
|
||||
|
||||
|
||||
@router.post("/postcards/{postcard_id}/delete")
|
||||
def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
pid = pc.profile_id
|
||||
db.delete(pc)
|
||||
db.commit()
|
||||
return _redirect(f"/profiles/{pid}/postcards")
|
||||
|
||||
|
||||
# ---------- Image Upload ----------
|
||||
|
||||
@router.post("/postcards/{postcard_id}/image/{side}")
|
||||
async def upload_image(
|
||||
postcard_id: int,
|
||||
side: str,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if side not in ("front", "back"):
|
||||
return _redirect("/profiles")
|
||||
pc = (
|
||||
db.query(Postcard)
|
||||
.join(Profile)
|
||||
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
|
||||
.first()
|
||||
)
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
|
||||
filename = f"{uuid4().hex}{ext}"
|
||||
dest = UPLOAD_DIR / filename
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
setattr(pc, f"image_{side}", f"/uploads/{filename}")
|
||||
db.commit()
|
||||
return _redirect(f"/postcards/{postcard_id}")
|
||||
|
||||
|
||||
# ---------- API Keys ----------
|
||||
|
||||
@router.get("/api-keys", response_class=HTMLResponse)
|
||||
def api_keys_page(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
|
||||
return templates.TemplateResponse("api_keys.html", {"request": request, "user": user, "keys": keys})
|
||||
|
||||
|
||||
@router.post("/api-keys")
|
||||
def api_key_create(user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
ak = ApiKey(user_id=user.id, key=ApiKey.generate_key())
|
||||
db.add(ak)
|
||||
db.commit()
|
||||
return _redirect("/api-keys")
|
||||
|
||||
|
||||
@router.post("/api-keys/{key_id}/delete")
|
||||
def api_key_delete(key_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if ak:
|
||||
db.delete(ak)
|
||||
db.commit()
|
||||
return _redirect("/api-keys")
|
||||
82
app/schemas.py
Normal file
82
app/schemas.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------- Auth ----------
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
# ---------- Profile ----------
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------- Postcard ----------
|
||||
|
||||
class PostcardCreate(BaseModel):
|
||||
card_number: str
|
||||
status: str # sent / delivered / received
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
send_time: datetime | None = None
|
||||
arrival_time: datetime | None = None
|
||||
sender_name: str | None = None
|
||||
receive_time: datetime | None = None
|
||||
|
||||
|
||||
class PostcardUpdate(BaseModel):
|
||||
card_number: str | None = None
|
||||
status: str | None = None
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
send_time: datetime | None = None
|
||||
arrival_time: datetime | None = None
|
||||
sender_name: str | None = None
|
||||
receive_time: datetime | None = None
|
||||
|
||||
|
||||
class PostcardOut(BaseModel):
|
||||
id: int
|
||||
profile_id: int
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None
|
||||
country: str | None
|
||||
send_time: datetime | None
|
||||
arrival_time: datetime | None
|
||||
sender_name: str | None
|
||||
receive_time: datetime | None
|
||||
image_front: str | None
|
||||
image_back: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------- ApiKey ----------
|
||||
|
||||
class ApiKeyOut(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
118
app/static/style.css
Normal file
118
app/static/style.css
Normal file
@@ -0,0 +1,118 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--bg: #f8fafc;
|
||||
--card-bg: #fff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--sent: #f59e0b;
|
||||
--delivered: #10b981;
|
||||
--received: #8b5cf6;
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Navbar */
|
||||
.navbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||
.nav-brand { font-size: 1.15rem; font-weight: 700; color: var(--text); }
|
||||
.nav-brand:hover { text-decoration: none; }
|
||||
.nav-links { display: flex; gap: 1.25rem; align-items: center; }
|
||||
.nav-links a { color: var(--text-muted); font-size: .9rem; }
|
||||
.nav-links a:hover { color: var(--primary); text-decoration: none; }
|
||||
.nav-logout { color: var(--danger) !important; }
|
||||
|
||||
/* Container */
|
||||
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
|
||||
|
||||
/* Auth forms */
|
||||
.auth-form { max-width: 360px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 8px; border: 1px solid var(--border); }
|
||||
.auth-form h2 { margin-bottom: 1.25rem; }
|
||||
.auth-link { margin-top: 1rem; text-align: center; font-size: .9rem; }
|
||||
|
||||
/* Forms */
|
||||
label { display: block; margin-top: .75rem; font-size: .85rem; font-weight: 500; color: var(--text-muted); }
|
||||
input[type="text"], input[type="password"], input[type="datetime-local"], select, textarea {
|
||||
width: 100%; padding: .5rem .75rem; margin-top: .25rem; border: 1px solid var(--border); border-radius: 6px; font-size: .95rem; background: #fff;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.form-card { background: var(--card-bg); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border); }
|
||||
.form-actions { display: flex; gap: .75rem; margin-top: 1.25rem; }
|
||||
.form-row { display: flex; gap: .5rem; align-items: center; }
|
||||
.inline-form { margin-bottom: 1.25rem; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { display: inline-block; padding: .5rem 1rem; border: 1px solid var(--border); border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; text-align: center; }
|
||||
.btn:hover { background: var(--bg); text-decoration: none; }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger-hover); }
|
||||
.btn-sm { padding: .25rem .6rem; font-size: .8rem; }
|
||||
|
||||
/* Alert */
|
||||
.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9rem; }
|
||||
.alert-error { background: #fef2f2; color: var(--danger); border: 1px solid #fecaca; }
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
|
||||
th, td { padding: .65rem 1rem; text-align: left; border-bottom: 1px solid var(--border); font-size: .9rem; }
|
||||
th { background: var(--bg); font-weight: 600; color: var(--text-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-size: .85rem; word-break: break-all; }
|
||||
.inline { display: inline; }
|
||||
|
||||
/* Section */
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||
|
||||
/* Card grid */
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.card { display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; transition: box-shadow .15s; }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; }
|
||||
.card h3 { margin-bottom: .5rem; }
|
||||
.stats { display: flex; gap: .75rem; font-size: .85rem; color: var(--text-muted); flex-wrap: wrap; }
|
||||
.stat { white-space: nowrap; }
|
||||
|
||||
/* Postcard grid */
|
||||
.postcard-card { display: flex; gap: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: .75rem; transition: box-shadow .15s; }
|
||||
.postcard-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; }
|
||||
.thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
|
||||
.thumb.placeholder { display: flex; align-items: center; justify-content: center; background: var(--bg); font-size: 1.5rem; }
|
||||
.postcard-info { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; }
|
||||
|
||||
/* Badge */
|
||||
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; color: #fff; }
|
||||
.badge-sent { background: var(--sent); }
|
||||
.badge-delivered { background: var(--delivered); }
|
||||
.badge-received { background: var(--received); }
|
||||
|
||||
/* Detail page */
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem; }
|
||||
.detail-images { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.image-block h3 { margin-bottom: .5rem; font-size: .95rem; }
|
||||
.detail-img { width: 100%; max-width: 400px; border-radius: 8px; border: 1px solid var(--border); }
|
||||
.upload-form { margin-top: .5rem; display: flex; gap: .5rem; align-items: center; }
|
||||
.upload-form input[type="file"] { flex: 1; font-size: .85rem; }
|
||||
.detail-meta dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; }
|
||||
.detail-meta dt { font-weight: 600; font-size: .85rem; color: var(--text-muted); }
|
||||
.detail-meta dd { font-size: .95rem; }
|
||||
|
||||
/* Filter */
|
||||
.filter-bar { margin-bottom: 1.25rem; }
|
||||
|
||||
/* Empty state */
|
||||
.empty { color: var(--text-muted); padding: 2rem; text-align: center; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
34
app/templates/api_keys.html
Normal file
34
app/templates/api_keys.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}API Keys - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>API Keys</h1>
|
||||
<form method="post" action="/api-keys">
|
||||
<button type="submit" class="btn btn-primary">+ 创建新 Key</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if keys %}
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for k in keys %}
|
||||
<tr>
|
||||
<td><code>{{ k.key }}</code></td>
|
||||
<td>{{ k.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form method="post" action="/api-keys/{{ k.id }}/delete" class="inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除该 Key?')">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">还没有 API Key,点击上方按钮创建一个。</p>
|
||||
{% endif %}
|
||||
<section class="section" style="margin-top:2rem">
|
||||
<h2>使用说明</h2>
|
||||
<pre>curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8000/api/profiles</pre>
|
||||
</section>
|
||||
{% endblock %}
|
||||
24
app/templates/base.html
Normal file
24
app/templates/base.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!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 %}Postcard Manager{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/dashboard" class="nav-brand">📮 Postcard Manager</a>
|
||||
<div class="nav-links">
|
||||
{% if user is defined and user %}
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/api-keys">API Keys</a>
|
||||
<a href="/logout" class="nav-logout">Logout</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
27
app/templates/dashboard.html
Normal file
27
app/templates/dashboard.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>👋 欢迎,{{ user.username }}</h1>
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Profiles</h2>
|
||||
</div>
|
||||
{% if profiles %}
|
||||
<div class="card-grid">
|
||||
{% for p in profiles %}
|
||||
<a href="/profiles/{{ p.id }}/postcards" class="card">
|
||||
<h3>{{ p.nickname }}</h3>
|
||||
<div class="stats">
|
||||
<span class="stat">📬 {{ stats[p.id].total }}</span>
|
||||
<span class="stat sent">寄出 {{ stats[p.id].sent }}</span>
|
||||
<span class="stat delivered">送达 {{ stats[p.id].delivered }}</span>
|
||||
<span class="stat received">收到 {{ stats[p.id].received }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">还没有 Profile,<a href="/profiles">去创建一个</a>。</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
18
app/templates/login.html
Normal file
18
app/templates/login.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录 - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-form">
|
||||
<h2>登录</h2>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required autofocus>
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required>
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
</form>
|
||||
<p class="auth-link">没有账号?<a href="/register">注册</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
app/templates/postcard_detail.html
Normal file
55
app/templates/postcard_detail.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}明信片详情 - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>{{ postcard.card_number }}</h1>
|
||||
<div>
|
||||
<a href="/postcards/{{ postcard.id }}/edit" class="btn">编辑</a>
|
||||
<form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('确定删除?')">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-{{ postcard.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }}</span>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-images">
|
||||
<div class="image-block">
|
||||
<h3>正面</h3>
|
||||
{% if postcard.image_front %}
|
||||
<img src="{{ postcard.image_front }}" alt="正面" class="detail-img">
|
||||
{% endif %}
|
||||
<form method="post" action="/postcards/{{ postcard.id }}/image/front" enctype="multipart/form-data" class="inline upload-form">
|
||||
<input type="file" name="file" accept="image/*" required>
|
||||
<button type="submit" class="btn btn-sm">上传正面</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="image-block">
|
||||
<h3>反面</h3>
|
||||
{% if postcard.image_back %}
|
||||
<img src="{{ postcard.image_back }}" alt="反面" class="detail-img">
|
||||
{% endif %}
|
||||
<form method="post" action="/postcards/{{ postcard.id }}/image/back" enctype="multipart/form-data" class="inline upload-form">
|
||||
<input type="file" name="file" accept="image/*" required>
|
||||
<button type="submit" class="btn btn-sm">上传反面</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<dl>
|
||||
<dt>编号</dt><dd>{{ postcard.card_number }}</dd>
|
||||
<dt>状态</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }}</dd>
|
||||
{% if postcard.status in ('sent','delivered') %}
|
||||
<dt>收件人</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
|
||||
<dt>国家/地区</dt><dd>{{ postcard.country or '-' }}</dd>
|
||||
<dt>寄出时间</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d %H:%M') if postcard.send_time else '-' }}</dd>
|
||||
<dt>到达时间</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d %H:%M') if postcard.arrival_time else '-' }}</dd>
|
||||
{% else %}
|
||||
<dt>发件人</dt><dd>{{ postcard.sender_name or '-' }}</dd>
|
||||
<dt>收到时间</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d %H:%M') if postcard.receive_time else '-' }}</dd>
|
||||
{% endif %}
|
||||
<dt>所属 Profile</dt><dd>{{ postcard.profile.nickname }}</dd>
|
||||
</dl>
|
||||
<a href="/profiles/{{ postcard.profile_id }}/postcards">← 返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
49
app/templates/postcard_form.html
Normal file
49
app/templates/postcard_form.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if postcard else '新建' }}明信片 - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>{{ '编辑' if postcard else '新建' }}明信片</h1>
|
||||
</div>
|
||||
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if postcard else '/profiles/%d/postcards' % profile.id }}" class="form-card">
|
||||
<label>编号 *</label>
|
||||
<input type="text" name="card_number" value="{{ postcard.card_number if postcard else '' }}" required>
|
||||
|
||||
<label>状态 *</label>
|
||||
<select name="status" id="status-select" onchange="toggleFields()">
|
||||
<option value="sent" {% if postcard and postcard.status=='sent' %}selected{% endif %}>已寄出</option>
|
||||
<option value="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>已送达</option>
|
||||
<option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>已收到</option>
|
||||
</select>
|
||||
|
||||
<div id="fields-sent-delivered">
|
||||
<label>收件人</label>
|
||||
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if postcard else '' }}">
|
||||
<label>国家/地区</label>
|
||||
<input type="text" name="country" value="{{ postcard.country if postcard else '' }}">
|
||||
<label>寄出时间</label>
|
||||
<input type="datetime-local" name="send_time" value="{{ postcard.send_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.send_time else '' }}">
|
||||
<label>到达时间</label>
|
||||
<input type="datetime-local" name="arrival_time" value="{{ postcard.arrival_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.arrival_time else '' }}">
|
||||
</div>
|
||||
|
||||
<div id="fields-received" style="display:none">
|
||||
<label>发件人</label>
|
||||
<input type="text" name="sender_name" value="{{ postcard.sender_name if postcard else '' }}">
|
||||
<label>收到时间</label>
|
||||
<input type="datetime-local" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.receive_time else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<a href="/profiles/{{ profile.id }}/postcards" class="btn">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
function toggleFields() {
|
||||
var s = document.getElementById('status-select').value;
|
||||
document.getElementById('fields-sent-delivered').style.display = (s === 'received') ? 'none' : '';
|
||||
document.getElementById('fields-received').style.display = (s === 'received') ? '' : 'none';
|
||||
}
|
||||
toggleFields();
|
||||
</script>
|
||||
{% endblock %}
|
||||
44
app/templates/postcards.html
Normal file
44
app/templates/postcards.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ profile.nickname }} - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>{{ profile.nickname }} 的明信片</h1>
|
||||
<a href="/profiles/{{ profile.id }}/postcards/new" class="btn btn-primary">+ 新明信片</a>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="form-row">
|
||||
<select name="status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="sent" {% if status == 'sent' %}selected{% endif %}>已寄出</option>
|
||||
<option value="delivered" {% if status == 'delivered' %}selected{% endif %}>已送达</option>
|
||||
<option value="received" {% if status == 'received' %}selected{% endif %}>已收到</option>
|
||||
</select>
|
||||
<input type="text" name="country" placeholder="国家/地区" value="{{ country }}">
|
||||
<button type="submit" class="btn">筛选</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if postcards %}
|
||||
<div class="card-grid">
|
||||
{% for pc in postcards %}
|
||||
<a href="/postcards/{{ pc.id }}" class="postcard-card">
|
||||
{% if pc.image_front %}
|
||||
<img src="{{ pc.image_front }}" alt="正面" class="thumb">
|
||||
{% else %}
|
||||
<div class="thumb placeholder">📷</div>
|
||||
{% endif %}
|
||||
<div class="postcard-info">
|
||||
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status, pc.status) }}</span>
|
||||
<strong>{{ pc.card_number }}</strong>
|
||||
{% if pc.status in ('sent','delivered') %}
|
||||
<span>→ {{ pc.recipient_name or '-' }} ({{ pc.country or '-' }})</span>
|
||||
{% else %}
|
||||
<span>← {{ pc.sender_name or '-' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty">没有找到明信片。</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
33
app/templates/profiles.html
Normal file
33
app/templates/profiles.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profiles - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="section-header">
|
||||
<h1>Profiles</h1>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<form method="post" action="/profiles" class="form-row">
|
||||
<input type="text" name="nickname" placeholder="新 Profile 昵称" required>
|
||||
<button type="submit" class="btn btn-primary">添加</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if profiles %}
|
||||
<table>
|
||||
<thead><tr><th>昵称</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in profiles %}
|
||||
<tr>
|
||||
<td><a href="/profiles/{{ p.id }}/postcards">{{ p.nickname }}</a></td>
|
||||
<td>{{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<form method="post" action="/profiles/{{ p.id }}/delete" class="inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除该 Profile 及其所有明信片?')">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">还没有 Profile。</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
20
app/templates/register.html
Normal file
20
app/templates/register.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}注册 - Postcard Manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-form">
|
||||
<h2>注册</h2>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/register">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" required autofocus>
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" required>
|
||||
<label>确认密码</label>
|
||||
<input type="password" name="password2" required>
|
||||
<button type="submit" class="btn btn-primary">注册</button>
|
||||
</form>
|
||||
<p class="auth-link">已有账号?<a href="/login">登录</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user