add search function
This commit is contained in:
+114
@@ -4,6 +4,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_db, init_db
|
||||
@@ -81,6 +82,100 @@ def _image_response_or_404(target) -> Response:
|
||||
return Response(content=target.image_blob, media_type=target.image_mime_type)
|
||||
|
||||
|
||||
def _build_search_results(db: Session, query: str) -> list[dict]:
|
||||
keyword = f"%{query.lower()}%"
|
||||
results: list[dict] = []
|
||||
|
||||
box_matches = (
|
||||
db.query(Box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Box.name).like(keyword),
|
||||
func.lower(func.coalesce(Box.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.order_by(Box.id.desc())
|
||||
.all()
|
||||
)
|
||||
for box in box_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "Box",
|
||||
"name": box.name,
|
||||
"note": box.note,
|
||||
"detail_url": f"/boxes/{box.id}",
|
||||
"detail_label": "查看箱子",
|
||||
"secondary_url": None,
|
||||
"secondary_label": None,
|
||||
"path": "顶层箱子",
|
||||
"is_container": None,
|
||||
"image_url": f"/boxes/{box.id}/image" if box.image_blob else None,
|
||||
}
|
||||
)
|
||||
|
||||
item_matches = (
|
||||
db.query(Item)
|
||||
.join(Item.box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Item.name).like(keyword),
|
||||
func.lower(func.coalesce(Item.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.order_by(Item.id.desc())
|
||||
.all()
|
||||
)
|
||||
for item in item_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "Item",
|
||||
"name": item.name,
|
||||
"note": item.note,
|
||||
"detail_url": f"/items/{item.id}",
|
||||
"detail_label": "查看物品",
|
||||
"secondary_url": f"/boxes/{item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": f"位于箱子:{item.box.name}",
|
||||
"is_container": item.is_container,
|
||||
"image_url": f"/items/{item.id}/image" if item.image_blob else None,
|
||||
}
|
||||
)
|
||||
|
||||
subitem_matches = (
|
||||
db.query(SubItem)
|
||||
.join(SubItem.parent_item)
|
||||
.join(Item.box)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(SubItem.name).like(keyword),
|
||||
func.lower(func.coalesce(SubItem.note, "")).like(keyword),
|
||||
)
|
||||
)
|
||||
.order_by(SubItem.id.desc())
|
||||
.all()
|
||||
)
|
||||
for subitem in subitem_matches:
|
||||
results.append(
|
||||
{
|
||||
"type": "SubItem",
|
||||
"name": subitem.name,
|
||||
"note": subitem.note,
|
||||
"detail_url": f"/items/{subitem.parent_item.id}",
|
||||
"detail_label": "查看所属物品",
|
||||
"secondary_url": f"/boxes/{subitem.parent_item.box.id}",
|
||||
"secondary_label": "查看所属箱子",
|
||||
"path": (
|
||||
f"位于物品:{subitem.parent_item.name} / "
|
||||
f"箱子:{subitem.parent_item.box.name}"
|
||||
),
|
||||
"is_container": None,
|
||||
"image_url": f"/subitems/{subitem.id}/image" if subitem.image_blob else None,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -94,6 +189,25 @@ def create_app() -> FastAPI:
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@app.get("/search")
|
||||
def search_page(
|
||||
request: Request,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = (q or "").strip()
|
||||
results = _build_search_results(db, query) if query else []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="search/index.html",
|
||||
context={
|
||||
"page_title": "搜索",
|
||||
"query": query,
|
||||
"results": results,
|
||||
"searched": bool(query),
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/boxes")
|
||||
def list_boxes(request: Request, db: Session = Depends(get_db)):
|
||||
boxes = db.query(Box).order_by(Box.id.desc()).all()
|
||||
|
||||
@@ -69,6 +69,8 @@ button:hover {
|
||||
|
||||
.top-nav {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
@@ -92,6 +94,30 @@ button:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
flex: 1 1 320px;
|
||||
min-width: 220px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.result-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<main class="container">
|
||||
<nav class="top-nav">
|
||||
<a href="/boxes">箱子</a>
|
||||
<a href="/search">搜索</a>
|
||||
</nav>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
<a href="/search">去搜索</a>
|
||||
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
|
||||
<a href="/search">去搜索</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>全局搜索</h1>
|
||||
<p class="muted">按名称或备注搜索箱子、物品和子物品,快速定位它们所在的位置。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" action="/search" class="search-form">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ query }}"
|
||||
placeholder="例如:锅、电源线、冬衣、文件袋"
|
||||
>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if searched %}
|
||||
{% if results %}
|
||||
<section class="stack">
|
||||
<p class="muted">共找到 {{ results|length }} 条结果。</p>
|
||||
{% for result in results %}
|
||||
<article class="card search-result">
|
||||
{% if result.image_url %}
|
||||
<img src="{{ result.image_url }}" alt="{{ result.name }}" class="thumb-image">
|
||||
{% endif %}
|
||||
<div class="result-body">
|
||||
<p class="meta">类型:{{ result.type }}</p>
|
||||
<h2>{{ result.name }}</h2>
|
||||
<p><strong>定位:</strong>{{ result.path }}</p>
|
||||
{% if result.type == "Item" and result.is_container %}
|
||||
<p><strong>容器:</strong>是</p>
|
||||
{% endif %}
|
||||
{% if result.note %}
|
||||
<p><strong>备注:</strong>{{ result.note }}</p>
|
||||
{% endif %}
|
||||
<div class="actions">
|
||||
<a href="{{ result.detail_url }}">{{ result.detail_label }}</a>
|
||||
{% if result.secondary_url %}
|
||||
<a href="{{ result.secondary_url }}">{{ result.secondary_label }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>没有找到匹配结果。</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>输入关键词后,可以跨 Box、Item、SubItem 进行搜索。</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user