{{ item.name }}
+是否容器: {{ "是" if item.is_container else "否" }}
+ {% if item.quantity is not none %}数量: {{ item.quantity }}
{% endif %} + {% if item.note %}备注: {{ item.note }}
{% endif %} + +diff --git a/README.md b/README.md index c544453..057da78 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,71 @@ -# Moving Helper Scaffold +# Moving Helper -这是一个面向可信家庭内网环境的小型工具项目,目前阶段只完成了基础脚手架: +这是一个面向可信家庭内网环境的小型搬家记录工具,当前采用轻量技术栈: - FastAPI -- Jinja2 +- Jinja2 服务端渲染 - SQLAlchemy - SQLite - pytest / FastAPI TestClient - Docker / Docker Compose -当前还不是完整业务应用,现阶段重点是把运行方式、工程结构、配置和基础测试打稳,后续再继续补 CRUD、图片、搜索等能力。 +项目目标是小而稳、容易继续扩展。目前已经从“纯脚手架”进入第二阶段,支持固定三层的数据结构和基础 CRUD,但还没有开始做图片、搜索和复杂界面。 + +## 当前数据模型 + +这个项目不是无限树结构,而是固定最多 3 级: + +- `Box` +- `Item` +- `SubItem` + +关系如下: + +- 一个 `Box` 包含多个 `Item` +- 一个 `Item` 属于一个 `Box` +- `Item` 通过 `is_container` 区分是否为“小容器” +- 只有 `is_container = true` 的 `Item` 才允许拥有 `SubItem` +- `SubItem` 是最后一级,不允许继续向下嵌套 + +换句话说,结构固定为: + +```text +Box +└── Item + └── SubItem +``` + +## 当前已支持 + +目前已支持的基础能力: + +- Box 列表、详情、新建、编辑、删除 +- Item 新建、详情、编辑、删除 +- SubItem 新建、编辑、删除 +- `/` 重定向到 `/boxes` +- Jinja2 模板渲染 +- 静态文件挂载 +- SQLite 持久化 +- Docker 长期运行 +- 基础自动化测试 + +删除规则: + +- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem` +- 删除容器型 `Item` 时,会级联删除其下 `SubItem` + +## 当前未实现 + +这一阶段仍然没有实现以下内容: + +- 图片上传 +- 图片字段 +- 图片处理 +- 搜索 +- 登录 / 鉴权 +- 标签系统 +- 前后端分离 +- 复杂 UI ## 项目结构 @@ -25,32 +81,23 @@ │ │ └── style.css │ └── templates │ ├── base.html -│ └── boxes.html +│ ├── boxes +│ ├── items +│ └── subitems ├── data ├── tests │ ├── conftest.py │ └── test_app.py ├── docker-compose.yml ├── Dockerfile +├── pytest.ini ├── README.md -├── requirements.txt -└── .gitignore +└── requirements.txt ``` -## 当前已接好的基础能力 - -- FastAPI 应用入口:`app/main.py` -- Jinja2 模板渲染 -- 静态文件挂载:`/static` -- `/` 自动重定向到 `/boxes` -- SQLite 数据库连接 -- SQLAlchemy Base / Session -- 启动时自动建表 -- 基础自动化测试 - ## 轻量配置 -项目通过环境变量支持下面几个配置项: +项目通过环境变量支持以下配置项: - `DATABASE_URL` - `HOST` @@ -62,8 +109,6 @@ - `HOST=0.0.0.0` - `PORT=10000` -本地开发和 Docker 运行都可以直接使用这些默认值,也可以按需覆盖。 - ## 本地开发模式 推荐使用本地 Python `venv` 开发和调试。 @@ -93,23 +138,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 10000 http://localhost:10000 ``` -本地开发默认会使用: +本地默认数据库位置: ```text ./data/app.db ``` -如果你想临时改端口或数据库路径,也可以这样运行: - -```bash -PORT=10000 DATABASE_URL=sqlite:///./data/app.db uvicorn app.main:app --reload --host 0.0.0.0 --port 10000 -``` - ## Docker 部署模式 Docker / Compose 是这个项目面向长期运行环境的方式。 -### 启动 +启动: ```bash docker compose up --build @@ -121,65 +160,34 @@ docker compose up --build http://localhost:10000 ``` -### 运行说明 +说明: - 默认暴露 `10000` 端口 -- `restart: unless-stopped`,适合长期运行 -- 容器内应用以用户 `1000:1000` 运行 -- SQLite 数据文件保存在宿主机 `./data/app.db` -- 容器重建不会丢失数据库数据 +- `restart: unless-stopped` +- 容器使用 `1000:1000` 运行 +- SQLite 文件持久化到宿主机 `./data/app.db` +- 容器重建不会丢失数据 -### 数据持久化与备份 - -`docker-compose.yml` 会将宿主机目录: - -```text -./data -``` - -挂载到容器内: - -```text -/app/data -``` - -因此数据库文件通常位于: +备份时直接复制 SQLite 文件即可: ```text ./data/app.db ``` -备份时直接复制这个 SQLite 文件即可。 - ## 测试 -这个项目已经接入了最基础的测试设施: - -- `pytest` -- `FastAPI TestClient` - 运行测试: ```bash -pytest +python -m pytest ``` -测试会使用独立的测试数据库文件,不会污染本地开发使用的 `data/app.db`。 +测试使用独立测试数据库,不会污染真实开发数据。 -当前测试覆盖: +当前测试覆盖包括: -- 应用可正常启动 -- `/` 会重定向到 `/boxes` -- `/boxes` 可正常返回 `200` -- 测试数据库与真实开发数据库隔离 - -## 当前阶段未实现 - -这一轮仍然没有开始做完整业务功能,下面这些内容后续再补: - -- Box / Item 完整 CRUD -- 图片上传与处理 -- 搜索 -- 登录 / 鉴权 -- 复杂前端样式 -- 前后端分离 +- Box / Item / SubItem 基础 CRUD +- 404 返回 +- 非容器 Item 不能创建 SubItem +- Box / Item 删除后的级联删除 +- 关键 POST 请求后的重定向行为 diff --git a/app/db.py b/app/db.py index 9e8934e..8b07e46 100644 --- a/app/db.py +++ b/app/db.py @@ -1,6 +1,7 @@ from typing import Generator -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event +from sqlalchemy.engine import make_url from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from app.config import get_settings @@ -14,8 +15,31 @@ class Base(DeclarativeBase): def _build_engine(database_url: str): + _ensure_sqlite_directory(database_url) connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} - return create_engine(database_url, connect_args=connect_args) + created_engine = create_engine(database_url, connect_args=connect_args) + + if database_url.startswith("sqlite"): + @event.listens_for(created_engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return created_engine + + +def _ensure_sqlite_directory(database_url: str) -> None: + if not database_url.startswith("sqlite"): + return + + database_path = make_url(database_url).database + if not database_path or database_path == ":memory:": + return + + from pathlib import Path + + Path(database_path).parent.mkdir(parents=True, exist_ok=True) def configure_database(database_url: str | None = None) -> None: @@ -23,6 +47,8 @@ def configure_database(database_url: str | None = None) -> None: settings = get_settings() resolved_database_url = database_url or settings.database_url + if engine is not None: + engine.dispose() engine = _build_engine(resolved_database_url) SessionLocal.configure(bind=engine) diff --git a/app/main.py b/app/main.py index 41b1143..4ae276d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,36 +1,328 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Form, HTTPException, Request, status from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session -from app.db import init_db +from app.db import get_db, init_db +from app.models import Box, Item, SubItem templates = Jinja2Templates(directory="app/templates") +def _clean_text(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.strip() + return cleaned or None + + +def _parse_quantity(value: str | None) -> int | None: + cleaned = _clean_text(value) + if cleaned is None: + return None + return int(cleaned) + + +def _is_checked(value: str | None) -> bool: + return value == "on" + + +def _get_box_or_404(db: Session, box_id: int) -> Box: + box = db.get(Box, box_id) + if box is None: + raise HTTPException(status_code=404, detail="Box not found") + return box + + +def _get_item_or_404(db: Session, item_id: int) -> Item: + item = db.get(Item, item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +def _get_subitem_or_404(db: Session, subitem_id: int) -> SubItem: + subitem = db.get(SubItem, subitem_id) + if subitem is None: + raise HTTPException(status_code=404, detail="Sub-item not found") + return subitem + + +def _require_container_item(item: Item) -> None: + if not item.is_container: + raise HTTPException(status_code=400, detail="Only container items can have sub-items") + + def create_app() -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): init_db() yield - app = FastAPI(title="Moving Helper", lifespan=lifespan) + app = FastAPI(title="搬家助手", lifespan=lifespan) app.mount("/static", StaticFiles(directory="app/static"), name="static") @app.get("/", include_in_schema=False) def root() -> RedirectResponse: - return RedirectResponse(url="/boxes", status_code=302) + return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND) @app.get("/boxes") - def boxes_page(request: Request): + def list_boxes(request: Request, db: Session = Depends(get_db)): + boxes = db.query(Box).order_by(Box.id.desc()).all() return templates.TemplateResponse( request=request, - name="boxes.html", - context={"page_title": "Boxes"}, + name="boxes/index.html", + context={"page_title": "箱子", "boxes": boxes}, ) + @app.get("/boxes/new") + def new_box_page(request: Request): + return templates.TemplateResponse( + request=request, + name="boxes/form.html", + context={ + "page_title": "新建箱子", + "box": None, + "form_action": "/boxes", + "submit_label": "创建箱子", + }, + ) + + @app.post("/boxes") + def create_box( + name: str = Form(...), + note: str | None = Form(default=None), + room: str | None = Form(default=None), + status_text: str | None = Form(default=None, alias="status"), + db: Session = Depends(get_db), + ) -> RedirectResponse: + box = Box( + name=name.strip(), + note=_clean_text(note), + room=_clean_text(room), + status=_clean_text(status_text), + ) + db.add(box) + db.commit() + db.refresh(box) + return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.get("/boxes/{box_id}") + def show_box(box_id: int, request: Request, db: Session = Depends(get_db)): + box = _get_box_or_404(db, box_id) + return templates.TemplateResponse( + request=request, + name="boxes/show.html", + context={"page_title": box.name, "box": box}, + ) + + @app.get("/boxes/{box_id}/edit") + def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)): + box = _get_box_or_404(db, box_id) + return templates.TemplateResponse( + request=request, + name="boxes/form.html", + context={ + "page_title": f"编辑箱子:{box.name}", + "box": box, + "form_action": f"/boxes/{box.id}/update", + "submit_label": "保存箱子", + }, + ) + + @app.post("/boxes/{box_id}/update") + def update_box( + box_id: int, + name: str = Form(...), + note: str | None = Form(default=None), + room: str | None = Form(default=None), + status_text: str | None = Form(default=None, alias="status"), + db: Session = Depends(get_db), + ) -> RedirectResponse: + box = _get_box_or_404(db, box_id) + box.name = name.strip() + box.note = _clean_text(note) + box.room = _clean_text(room) + box.status = _clean_text(status_text) + db.commit() + return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/boxes/{box_id}/delete") + def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse: + box = _get_box_or_404(db, box_id) + db.delete(box) + db.commit() + return RedirectResponse(url="/boxes", status_code=status.HTTP_303_SEE_OTHER) + + @app.get("/boxes/{box_id}/items/new") + def new_item_page(box_id: int, request: Request, db: Session = Depends(get_db)): + box = _get_box_or_404(db, box_id) + return templates.TemplateResponse( + request=request, + name="items/form.html", + context={ + "page_title": f"为箱子“{box.name}”添加物品", + "box": box, + "item": None, + "form_action": f"/boxes/{box.id}/items", + "submit_label": "创建物品", + }, + ) + + @app.post("/boxes/{box_id}/items") + def create_item( + box_id: int, + name: str = Form(...), + note: str | None = Form(default=None), + quantity: str | None = Form(default=None), + is_container: str | None = Form(default=None), + db: Session = Depends(get_db), + ) -> RedirectResponse: + box = _get_box_or_404(db, box_id) + item = Item( + box=box, + name=name.strip(), + note=_clean_text(note), + quantity=_parse_quantity(quantity), + is_container=_is_checked(is_container), + ) + db.add(item) + db.commit() + db.refresh(item) + return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.get("/items/{item_id}") + def show_item(item_id: int, request: Request, db: Session = Depends(get_db)): + item = _get_item_or_404(db, item_id) + return templates.TemplateResponse( + request=request, + name="items/show.html", + context={"page_title": item.name, "item": item}, + ) + + @app.get("/items/{item_id}/edit") + def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)): + item = _get_item_or_404(db, item_id) + return templates.TemplateResponse( + request=request, + name="items/form.html", + context={ + "page_title": f"编辑物品:{item.name}", + "box": item.box, + "item": item, + "form_action": f"/items/{item.id}/update", + "submit_label": "保存物品", + }, + ) + + @app.post("/items/{item_id}/update") + def update_item( + item_id: int, + name: str = Form(...), + note: str | None = Form(default=None), + quantity: str | None = Form(default=None), + is_container: str | None = Form(default=None), + db: Session = Depends(get_db), + ) -> RedirectResponse: + item = _get_item_or_404(db, item_id) + item.name = name.strip() + item.note = _clean_text(note) + item.quantity = _parse_quantity(quantity) + item.is_container = _is_checked(is_container) + if not item.is_container: + item.subitems.clear() + db.commit() + return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/items/{item_id}/delete") + def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse: + item = _get_item_or_404(db, item_id) + box_id = item.box_id + db.delete(item) + db.commit() + return RedirectResponse(url=f"/boxes/{box_id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.get("/items/{item_id}/subitems/new") + def new_subitem_page(item_id: int, request: Request, db: Session = Depends(get_db)): + item = _get_item_or_404(db, item_id) + _require_container_item(item) + return templates.TemplateResponse( + request=request, + name="subitems/form.html", + context={ + "page_title": f"为“{item.name}”添加子物品", + "item": item, + "subitem": None, + "form_action": f"/items/{item.id}/subitems", + "submit_label": "创建子物品", + }, + ) + + @app.post("/items/{item_id}/subitems") + def create_subitem( + item_id: int, + name: str = Form(...), + note: str | None = Form(default=None), + quantity: str | None = Form(default=None), + db: Session = Depends(get_db), + ) -> RedirectResponse: + item = _get_item_or_404(db, item_id) + _require_container_item(item) + subitem = SubItem( + parent_item=item, + name=name.strip(), + note=_clean_text(note), + quantity=_parse_quantity(quantity), + ) + db.add(subitem) + db.commit() + db.refresh(subitem) + return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER) + + @app.get("/subitems/{subitem_id}/edit") + def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)): + subitem = _get_subitem_or_404(db, subitem_id) + return templates.TemplateResponse( + request=request, + name="subitems/form.html", + context={ + "page_title": f"编辑子物品:{subitem.name}", + "item": subitem.parent_item, + "subitem": subitem, + "form_action": f"/subitems/{subitem.id}/update", + "submit_label": "保存子物品", + }, + ) + + @app.post("/subitems/{subitem_id}/update") + def update_subitem( + subitem_id: int, + name: str = Form(...), + note: str | None = Form(default=None), + quantity: str | None = Form(default=None), + db: Session = Depends(get_db), + ) -> RedirectResponse: + subitem = _get_subitem_or_404(db, subitem_id) + subitem.name = name.strip() + subitem.note = _clean_text(note) + subitem.quantity = _parse_quantity(quantity) + db.commit() + return RedirectResponse( + url=f"/items/{subitem.parent_item_id}", + status_code=status.HTTP_303_SEE_OTHER, + ) + + @app.post("/subitems/{subitem_id}/delete") + def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse: + subitem = _get_subitem_or_404(db, subitem_id) + item_id = subitem.parent_item_id + db.delete(subitem) + db.commit() + return RedirectResponse(url=f"/items/{item_id}", status_code=status.HTTP_303_SEE_OTHER) + return app diff --git a/app/models.py b/app/models.py index d2c61ae..0e40a25 100644 --- a/app/models.py +++ b/app/models.py @@ -1,15 +1,80 @@ -from datetime import datetime +from datetime import UTC, datetime -from sqlalchemy import DateTime, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base +def utcnow() -> datetime: + return datetime.now(UTC) + + class Box(Base): __tablename__ = "boxes" id: Mapped[int] = mapped_column(primary_key=True, index=True) - name: Mapped[str] = mapped_column(String(100), nullable=False, default="Sample Box") - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + room: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + nullable=False, + ) + items: Mapped[list["Item"]] = relationship( + back_populates="box", + cascade="all, delete-orphan", + order_by="Item.id", + ) + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + box_id: Mapped[int] = mapped_column(ForeignKey("boxes.id", ondelete="CASCADE"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + quantity: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + nullable=False, + ) + + box: Mapped[Box] = relationship(back_populates="items") + subitems: Mapped[list["SubItem"]] = relationship( + back_populates="parent_item", + cascade="all, delete-orphan", + order_by="SubItem.id", + ) + + +class SubItem(Base): + __tablename__ = "subitems" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + parent_item_id: Mapped[int] = mapped_column( + ForeignKey("items.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + quantity: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utcnow, + onupdate=utcnow, + nullable=False, + ) + + parent_item: Mapped[Item] = relationship(back_populates="subitems") diff --git a/app/static/style.css b/app/static/style.css index 2ea7ea7..afbe040 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -3,10 +3,11 @@ body { font-family: Arial, sans-serif; background: #f4f4f4; color: #222; + line-height: 1.5; } .container { - max-width: 720px; + max-width: 840px; margin: 48px auto; padding: 24px; background: #fff; @@ -18,3 +19,107 @@ h1 { margin-top: 0; } +h2, +h3, +p { + margin-top: 0; +} + +a { + color: #0b57d0; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +label { + display: block; + font-weight: 600; +} + +input, +textarea, +button { + width: 100%; + box-sizing: border-box; + margin-top: 6px; + padding: 10px 12px; + font: inherit; +} + +button, +.button { + display: inline-block; + width: auto; + background: #0b57d0; + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; + padding: 10px 14px; +} + +.button:hover, +button:hover { + opacity: 0.92; + text-decoration: none; +} + +.top-nav { + margin-bottom: 24px; +} + +.page-header, +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.stack { + display: grid; + gap: 16px; +} + +.card { + border: 1px solid #ddd; + border-radius: 10px; + padding: 16px; + background: #fafafa; +} + +.meta, +.muted { + color: #666; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; + margin: 0; +} + +.actions form { + margin: 0; +} + +.link-button { + background: none; + border: none; + color: #b42318; + padding: 0; + cursor: pointer; +} + +.link-button:hover { + text-decoration: underline; +} diff --git a/app/templates/base.html b/app/templates/base.html index 5866900..54f3130 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,15 +1,17 @@ - +
-This is the minimal starter page for the boxes module.
-{% endblock %} - diff --git a/app/templates/boxes/form.html b/app/templates/boxes/form.html new file mode 100644 index 0000000..d818581 --- /dev/null +++ b/app/templates/boxes/form.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block content %} +这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。
+房间:{{ box.room }}
{% endif %} + {% if box.status %}状态:{{ box.status }}
{% endif %} + {% if box.note %}{{ box.note }}
{% endif %} + +还没有箱子。
+ 创建第一个箱子 +房间: {{ box.room or '-' }}
+状态: {{ box.status or '-' }}
+备注: {{ box.note or '-' }}
+是否容器: {{ "是" if item.is_container else "否" }}
+ {% if item.quantity is not none %}数量: {{ item.quantity }}
{% endif %} + {% if item.note %}备注: {{ item.note }}
{% endif %} + +这个箱子里还没有物品。
+所属箱子:{{ box.name }}
+位于箱子 {{ item.box.name }} 中
+是否容器: {{ "是" if item.is_container else "否" }}
+数量: {{ item.quantity if item.quantity is not none else '-' }}
+备注: {{ item.note or '-' }}
+数量: {{ subitem.quantity }}
{% endif %} + {% if subitem.note %}备注: {{ subitem.note }}
{% endif %} +还没有子物品。
+这个物品不是容器,因此不能包含子物品。
+上级物品:{{ item.name }}
+