add search function
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user