add image flow

This commit is contained in:
2026-04-19 12:54:25 +02:00
parent 57800f2123
commit 5fdf3f4ab2
14 changed files with 606 additions and 21 deletions
+39 -1
View File
@@ -1,6 +1,6 @@
from typing import Generator
from sqlalchemy import create_engine, event
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import make_url
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
@@ -68,3 +68,41 @@ def init_db(database_url: str | None = None) -> None:
configure_database(database_url)
Base.metadata.create_all(bind=engine)
_sync_sqlite_image_columns()
def _sync_sqlite_image_columns() -> None:
if engine is None or engine.dialect.name != "sqlite":
return
image_columns = {
"boxes": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
"items": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
"subitems": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
}
with engine.begin() as connection:
for table_name, columns in image_columns.items():
existing_columns = {
row[1] for row in connection.execute(text(f"PRAGMA table_info({table_name})"))
}
for column_name, column_type in columns.items():
if column_name not in existing_columns:
connection.execute(
text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
)
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import dataclass
from io import BytesIO
from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
MAX_IMAGE_SIDE = 1600
JPEG_QUALITY = 80
JPEG_MIME_TYPE = "image/jpeg"
@dataclass(slots=True)
class ProcessedImage:
blob: bytes
mime_type: str
width: int
height: int
def process_upload(file: UploadFile | None) -> ProcessedImage | None:
if file is None or not file.filename:
return None
try:
raw_bytes = file.file.read()
if not raw_bytes:
raise HTTPException(status_code=400, detail="上传的图片内容为空")
with Image.open(BytesIO(raw_bytes)) as source_image:
processed_image = _prepare_image(source_image)
except UnidentifiedImageError as exc:
raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail="图片处理失败,请尝试更换图片") from exc
finally:
file.file.close()
return processed_image
def _prepare_image(source_image: Image.Image) -> ProcessedImage:
prepared = _strip_metadata_and_convert(source_image)
prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
output = BytesIO()
prepared.save(output, format="JPEG", quality=JPEG_QUALITY, optimize=True)
blob = output.getvalue()
return ProcessedImage(
blob=blob,
mime_type=JPEG_MIME_TYPE,
width=prepared.width,
height=prepared.height,
)
def _strip_metadata_and_convert(source_image: Image.Image) -> Image.Image:
if source_image.mode in ("RGBA", "LA"):
background = Image.new("RGB", source_image.size, (255, 255, 255))
alpha = source_image.getchannel("A")
background.paste(source_image.convert("RGBA"), mask=alpha)
return background
if source_image.mode == "P":
return source_image.convert("RGB")
if source_image.mode != "RGB":
return source_image.convert("RGB")
return source_image.copy()
+72 -2
View File
@@ -1,12 +1,13 @@
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.db import get_db, init_db
from app.images import process_upload
from app.models import Box, Item, SubItem
templates = Jinja2Templates(directory="app/templates")
@@ -56,6 +57,30 @@ def _require_container_item(item: Item) -> None:
raise HTTPException(status_code=400, detail="Only container items can have sub-items")
def _set_image_fields(target, processed_image) -> None:
if processed_image is None:
return
target.image_blob = processed_image.blob
target.image_mime_type = processed_image.mime_type
target.image_width = processed_image.width
target.image_height = processed_image.height
def _clear_image_fields(target) -> None:
target.image_blob = None
target.image_mime_type = None
target.image_width = None
target.image_height = None
def _image_response_or_404(target) -> Response:
if target.image_blob is None or target.image_mime_type is None:
raise HTTPException(status_code=404, detail="Image not found")
return Response(content=target.image_blob, media_type=target.image_mime_type)
def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -97,6 +122,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = Box(
@@ -105,6 +131,7 @@ def create_app() -> FastAPI:
room=_clean_text(room),
status=_clean_text(status_text),
)
_set_image_fields(box, process_upload(image_file))
db.add(box)
db.commit()
db.refresh(box)
@@ -119,6 +146,10 @@ def create_app() -> FastAPI:
context={"page_title": box.name, "box": box},
)
@app.get("/boxes/{box_id}/image")
def get_box_image(box_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_box_or_404(db, box_id))
@app.get("/boxes/{box_id}/edit")
def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)):
box = _get_box_or_404(db, box_id)
@@ -140,6 +171,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -147,9 +179,17 @@ def create_app() -> FastAPI:
box.note = _clean_text(note)
box.room = _clean_text(room)
box.status = _clean_text(status_text)
_set_image_fields(box, process_upload(image_file))
db.commit()
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/boxes/{box_id}/image/delete")
def delete_box_image(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
_clear_image_fields(box)
db.commit()
return RedirectResponse(url=f"/boxes/{box.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/boxes/{box_id}/delete")
def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -179,6 +219,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -189,6 +230,7 @@ def create_app() -> FastAPI:
quantity=_parse_quantity(quantity),
is_container=_is_checked(is_container),
)
_set_image_fields(item, process_upload(image_file))
db.add(item)
db.commit()
db.refresh(item)
@@ -203,6 +245,10 @@ def create_app() -> FastAPI:
context={"page_title": item.name, "item": item},
)
@app.get("/items/{item_id}/image")
def get_item_image(item_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_item_or_404(db, item_id))
@app.get("/items/{item_id}/edit")
def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)):
item = _get_item_or_404(db, item_id)
@@ -225,6 +271,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -234,9 +281,17 @@ def create_app() -> FastAPI:
item.is_container = _is_checked(is_container)
if not item.is_container:
item.subitems.clear()
_set_image_fields(item, process_upload(image_file))
db.commit()
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/items/{item_id}/image/delete")
def delete_item_image(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
_clear_image_fields(item)
db.commit()
return RedirectResponse(url=f"/items/{item.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/items/{item_id}/delete")
def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -267,6 +322,7 @@ def create_app() -> FastAPI:
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -277,11 +333,16 @@ def create_app() -> FastAPI:
note=_clean_text(note),
quantity=_parse_quantity(quantity),
)
_set_image_fields(subitem, process_upload(image_file))
db.add(subitem)
db.commit()
db.refresh(subitem)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/subitems/{subitem_id}/image")
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_subitem_or_404(db, subitem_id))
@app.get("/subitems/{subitem_id}/edit")
def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)):
subitem = _get_subitem_or_404(db, subitem_id)
@@ -303,18 +364,27 @@ def create_app() -> FastAPI:
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id)
subitem.name = name.strip()
subitem.note = _clean_text(note)
subitem.quantity = _parse_quantity(quantity)
_set_image_fields(subitem, process_upload(image_file))
db.commit()
return RedirectResponse(
url=f"/items/{subitem.parent_item_id}",
status_code=status.HTTP_303_SEE_OTHER,
)
@app.post("/subitems/{subitem_id}/image/delete")
def delete_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id)
_clear_image_fields(subitem)
db.commit()
return RedirectResponse(url=f"/subitems/{subitem.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/subitems/{subitem_id}/delete")
def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id)
+13 -1
View File
@@ -1,6 +1,6 @@
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
@@ -18,6 +18,10 @@ class Box(Base):
note: Mapped[str | None] = mapped_column(Text, nullable=True)
room: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -42,6 +46,10 @@ class Item(Base):
note: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -69,6 +77,10 @@ class SubItem(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
+19
View File
@@ -92,6 +92,25 @@ button:hover {
background: #fafafa;
}
.detail-image {
display: block;
width: 100%;
max-width: 480px;
height: auto;
border-radius: 8px;
margin-bottom: 16px;
}
.thumb-image {
display: block;
width: 120px;
max-width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 12px;
border: 1px solid #ddd;
}
.meta,
.muted {
color: #666;
+19 -1
View File
@@ -6,7 +6,7 @@
<a href="/boxes">返回箱子列表</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
名称
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
@@ -23,6 +23,24 @@
备注
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
</label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if box and box.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/boxes/{{ box.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}
+6
View File
@@ -13,6 +13,9 @@
</div>
<section class="card">
{% if box.image_blob %}
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
{% endif %}
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
<p><strong>状态:</strong> {{ box.status or '-' }}</p>
<p><strong>备注:</strong> {{ box.note or '-' }}</p>
@@ -29,6 +32,9 @@
{% if box.items %}
{% for item in box.items %}
<article class="card">
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image">
{% endif %}
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
+19 -1
View File
@@ -9,7 +9,7 @@
<a href="/boxes/{{ box.id }}">返回箱子</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
@@ -26,6 +26,24 @@
备注
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
</label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if item and item.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/items/{{ item.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}
+6
View File
@@ -13,6 +13,9 @@
</div>
<section class="card">
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
{% endif %}
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
<p><strong>备注:</strong> {{ item.note or '-' }}</p>
@@ -32,6 +35,9 @@
{% if item.subitems %}
{% for subitem in item.subitems %}
<article class="card">
{% if subitem.image_blob %}
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
{% endif %}
<h3>{{ subitem.name }}</h3>
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
+19 -1
View File
@@ -9,7 +9,7 @@
<a href="/items/{{ item.id }}">返回物品</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label>
名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
@@ -22,6 +22,24 @@
备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if subitem and subitem.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/subitems/{{ subitem.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}