{{ result.name }}
+定位:{{ result.path }}
+ {% if result.type == "Item" and result.is_container %} +容器:是
+ {% endif %} + {% if result.note %} +备注:{{ result.note }}
+ {% endif %} +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 @@
按名称或备注搜索箱子、物品和子物品,快速定位它们所在的位置。
+共找到 {{ results|length }} 条结果。
+ {% for result in results %} +定位:{{ result.path }}
+ {% if result.type == "Item" and result.is_container %} +容器:是
+ {% endif %} + {% if result.note %} +备注:{{ result.note }}
+ {% endif %} +没有找到匹配结果。
+输入关键词后,可以跨 Box、Item、SubItem 进行搜索。
+