add search function

This commit is contained in:
2026-04-19 13:00:11 +02:00
parent 5fdf3f4ab2
commit ea73b0c165
8 changed files with 380 additions and 1 deletions
+30 -1
View File
@@ -10,7 +10,7 @@
- pytest / FastAPI TestClient - pytest / FastAPI TestClient
- Docker / Docker Compose - Docker / Docker Compose
项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD,以及单图上传能力,但仍然没有加入搜索、OCR、AI 识别或其他扩展功能。 项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD单图上传能力和全局搜索,但仍然没有加入 OCR、AI 识别或其他扩展功能。
## 当前数据模型 ## 当前数据模型
@@ -44,6 +44,7 @@ Box
- Item 新建、详情、编辑、删除 - Item 新建、详情、编辑、删除
- SubItem 新建、编辑、删除 - SubItem 新建、编辑、删除
- Box / Item / SubItem 单张图片上传、替换、删除、展示 - Box / Item / SubItem 单张图片上传、替换、删除、展示
- Box / Item / SubItem 全局搜索
- `/` 重定向到 `/boxes` - `/` 重定向到 `/boxes`
- Jinja2 模板渲染 - Jinja2 模板渲染
- 静态文件挂载 - 静态文件挂载
@@ -92,6 +93,33 @@ Box
- `/items/{id}/image` - `/items/{id}/image`
- `/subitems/{id}/image` - `/subitems/{id}/image`
## 全局搜索
当前已经支持一个轻量的全局搜索页:
- 路由:`/search`
- 使用 query parameter,例如:`/search?q=电源线`
搜索范围包括:
- `Box.name`
- `Box.note`
- `Item.name`
- `Item.note`
- `SubItem.name`
- `SubItem.note`
当前使用 SQLite 上的简单模糊匹配完成搜索,不引入外部搜索引擎或复杂全文系统。
搜索结果会尽量帮助你快速定位:
- 显示对象类型:`Box / Item / SubItem`
- 显示名称和备注
- 显示归属路径
-`Item` 展示所属 `Box`
-`SubItem` 展示所属 `Item``Box`
- 如果对象已有图片,会显示一个小缩略图
## 当前未实现 ## 当前未实现
这一阶段仍然没有实现以下内容: 这一阶段仍然没有实现以下内容:
@@ -232,5 +260,6 @@ python -m pytest
- 非容器 Item 不能创建 SubItem - 非容器 Item 不能创建 SubItem
- Box / Item 删除后的级联删除 - Box / Item 删除后的级联删除
- 图片上传、转换为 JPEG、缩放、读取、替换、删除 - 图片上传、转换为 JPEG、缩放、读取、替换、删除
- 全局搜索 name / note,并展示对象类型与归属路径
- 无图片访问和非法图片上传等错误路径 - 无图片访问和非法图片上传等错误路径
- 关键 POST 请求后的重定向行为 - 关键 POST 请求后的重定向行为
+114
View File
@@ -4,6 +4,7 @@ from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, Upload
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import get_db, init_db 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) 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: def create_app() -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -94,6 +189,25 @@ def create_app() -> FastAPI:
def root() -> RedirectResponse: def root() -> RedirectResponse:
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND) 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") @app.get("/boxes")
def list_boxes(request: Request, db: Session = Depends(get_db)): def list_boxes(request: Request, db: Session = Depends(get_db)):
boxes = db.query(Box).order_by(Box.id.desc()).all() boxes = db.query(Box).order_by(Box.id.desc()).all()
+26
View File
@@ -69,6 +69,8 @@ button:hover {
.top-nav { .top-nav {
margin-bottom: 24px; margin-bottom: 24px;
display: flex;
gap: 16px;
} }
.page-header, .page-header,
@@ -92,6 +94,30 @@ button:hover {
background: #fafafa; 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 { .detail-image {
display: block; display: block;
width: 100%; width: 100%;
+1
View File
@@ -10,6 +10,7 @@
<main class="container"> <main class="container">
<nav class="top-nav"> <nav class="top-nav">
<a href="/boxes">箱子</a> <a href="/boxes">箱子</a>
<a href="/search">搜索</a>
</nav> </nav>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+1
View File
@@ -8,6 +8,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<a href="/boxes">返回箱子列表</a> <a href="/boxes">返回箱子列表</a>
<a href="/search">去搜索</a>
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a> <a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
</div> </div>
</div> </div>
+1
View File
@@ -8,6 +8,7 @@
</div> </div>
<div class="actions"> <div class="actions">
<a href="/boxes/{{ item.box.id }}">返回箱子</a> <a href="/boxes/{{ item.box.id }}">返回箱子</a>
<a href="/search">去搜索</a>
<a href="/items/{{ item.id }}/edit">编辑物品</a> <a href="/items/{{ item.id }}/edit">编辑物品</a>
</div> </div>
</div> </div>
+62
View File
@@ -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 %}
+145
View File
@@ -562,3 +562,148 @@ def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client
assert updated_box.image_mime_type is None assert updated_box.image_mime_type is None
assert updated_box.image_width is None assert updated_box.image_width is None
assert updated_box.image_height is None assert updated_box.image_height is None
def test_can_search_box_by_name(client, db_session):
box = Box(name="冬季衣物箱")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=衣物")
assert response.status_code == 200
assert "冬季衣物箱" in response.text
assert "类型:Box" in response.text
def test_can_search_box_by_note(client, db_session):
box = Box(name="普通箱子", note="里面放厨房锅具")
db_session.add(box)
db_session.commit()
response = client.get("/search?q=锅具")
assert response.status_code == 200
assert "普通箱子" in response.text
def test_can_search_item_by_name(client, db_session):
box = Box(name="书房箱")
item = Item(name="电源延长线", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=延长线")
assert response.status_code == 200
assert "电源延长线" in response.text
assert "位于箱子:书房箱" in response.text
def test_can_search_item_by_note(client, db_session):
box = Box(name="工具箱")
item = Item(name="杂项", note="放备用螺丝刀", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=螺丝刀")
assert response.status_code == 200
assert "杂项" in response.text
def test_can_search_subitem_by_name(client, db_session):
box = Box(name="文件箱")
item = Item(name="文件袋", box=box, is_container=True)
subitem = SubItem(name="护照复印件", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=护照")
assert response.status_code == 200
assert "护照复印件" in response.text
assert "位于物品:文件袋 / 箱子:文件箱" in response.text
def test_can_search_subitem_by_note(client, db_session):
box = Box(name="电子箱")
item = Item(name="配件盒", box=box, is_container=True)
subitem = SubItem(name="接口", note="备用转接头", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=转接头")
assert response.status_code == 200
assert "接口" in response.text
def test_search_result_shows_item_box_info(client, db_session):
box = Box(name="客厅箱")
item = Item(name="相机", box=box, is_container=True)
db_session.add_all([box, item])
db_session.commit()
response = client.get("/search?q=相机")
assert response.status_code == 200
assert "位于箱子:客厅箱" in response.text
assert "容器" in response.text
assert "" in response.text
def test_search_result_shows_subitem_item_and_box_info(client, db_session):
box = Box(name="卧室箱")
item = Item(name="收纳袋", box=box, is_container=True)
subitem = SubItem(name="袜子", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
response = client.get("/search?q=袜子")
assert response.status_code == 200
assert "位于物品:收纳袋 / 箱子:卧室箱" in response.text
def test_search_page_handles_empty_query(client):
response = client.get("/search")
assert response.status_code == 200
assert "输入关键词后,可以跨 Box、Item、SubItem 进行搜索。" in response.text
def test_search_page_handles_no_results(client):
response = client.get("/search?q=不存在的关键词")
assert response.status_code == 200
assert "没有找到匹配结果。" in response.text
def test_search_result_renders_thumbnail_link_when_image_exists(client, db_session):
box = Box(name="图片箱")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get("/search?q=图片箱")
assert response.status_code == 200
assert f'/boxes/{box.id}/image' in response.text
def test_search_result_without_image_does_not_break_template(client, db_session):
item = Item(name="无图物品", box=Box(name="普通箱"), is_container=False)
db_session.add(item)
db_session.commit()
response = client.get("/search?q=无图物品")
assert response.status_code == 200
assert "无图物品" in response.text