diff --git a/README.md b/README.md index 606a94d..b1b0a13 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - pytest / FastAPI TestClient - Docker / Docker Compose -项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD,以及单图上传能力,但仍然没有加入搜索、OCR、AI 识别或其他扩展功能。 +项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD、单图上传能力和全局搜索,但仍然没有加入 OCR、AI 识别或其他扩展功能。 ## 当前数据模型 @@ -44,6 +44,7 @@ Box - Item 新建、详情、编辑、删除 - SubItem 新建、编辑、删除 - Box / Item / SubItem 单张图片上传、替换、删除、展示 +- Box / Item / SubItem 全局搜索 - `/` 重定向到 `/boxes` - Jinja2 模板渲染 - 静态文件挂载 @@ -92,6 +93,33 @@ Box - `/items/{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 - Box / Item 删除后的级联删除 - 图片上传、转换为 JPEG、缩放、读取、替换、删除 +- 全局搜索 name / note,并展示对象类型与归属路径 - 无图片访问和非法图片上传等错误路径 - 关键 POST 请求后的重定向行为 diff --git a/app/main.py b/app/main.py index 054940b..ce6cd0f 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/static/style.css b/app/static/style.css index 24c44ec..a36726d 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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%; diff --git a/app/templates/base.html b/app/templates/base.html index 54f3130..6b4c150 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,6 +10,7 @@
{% block content %}{% endblock %}
diff --git a/app/templates/boxes/show.html b/app/templates/boxes/show.html index e974941..34b020f 100644 --- a/app/templates/boxes/show.html +++ b/app/templates/boxes/show.html @@ -8,6 +8,7 @@
返回箱子列表 + 去搜索 添加物品
diff --git a/app/templates/items/show.html b/app/templates/items/show.html index 78db883..4e09cfe 100644 --- a/app/templates/items/show.html +++ b/app/templates/items/show.html @@ -8,6 +8,7 @@
返回箱子 + 去搜索 编辑物品
diff --git a/app/templates/search/index.html b/app/templates/search/index.html new file mode 100644 index 0000000..c1c3beb --- /dev/null +++ b/app/templates/search/index.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block content %} + + +
+
+ + +
+
+ +{% if searched %} + {% if results %} +
+

共找到 {{ results|length }} 条结果。

+ {% for result in results %} +
+ {% if result.image_url %} + {{ result.name }} + {% endif %} +
+

类型:{{ result.type }}

+

{{ result.name }}

+

定位:{{ result.path }}

+ {% if result.type == "Item" and result.is_container %} +

容器:

+ {% endif %} + {% if result.note %} +

备注:{{ result.note }}

+ {% endif %} +
+ {{ result.detail_label }} + {% if result.secondary_url %} + {{ result.secondary_label }} + {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+

没有找到匹配结果。

+
+ {% endif %} +{% else %} +
+

输入关键词后,可以跨 Box、Item、SubItem 进行搜索。

+
+{% endif %} +{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index 9ded456..e6065a1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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_width 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