Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 314fc16b98 | |||
| 4c4ff61fab |
+18
-2
@@ -82,6 +82,10 @@ def _image_response_or_404(target) -> Response:
|
||||
return Response(content=target.image_blob, media_type=target.image_mime_type)
|
||||
|
||||
|
||||
def _wants_add_next(submit_action: str | None) -> bool:
|
||||
return submit_action == "save_and_add_next"
|
||||
|
||||
|
||||
def _build_search_results(db: Session, query: str) -> list[dict]:
|
||||
keyword = f"%{query.lower()}%"
|
||||
results: list[dict] = []
|
||||
@@ -333,6 +337,7 @@ def create_app() -> FastAPI:
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
is_container: str | None = Form(default=None),
|
||||
submit_action: str | None = Form(default=None),
|
||||
image_file: UploadFile | None = File(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
@@ -348,7 +353,13 @@ def create_app() -> FastAPI:
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
if _wants_add_next(submit_action):
|
||||
redirect_url = f"/boxes/{box.id}/items/new"
|
||||
elif item.is_container:
|
||||
redirect_url = f"/items/{item.id}"
|
||||
else:
|
||||
redirect_url = f"/boxes/{box.id}"
|
||||
return RedirectResponse(url=redirect_url, 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)):
|
||||
@@ -436,6 +447,7 @@ def create_app() -> FastAPI:
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
submit_action: str | None = Form(default=None),
|
||||
image_file: UploadFile | None = File(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
@@ -451,7 +463,11 @@ def create_app() -> FastAPI:
|
||||
db.add(subitem)
|
||||
db.commit()
|
||||
db.refresh(subitem)
|
||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
if _wants_add_next(submit_action):
|
||||
redirect_url = f"/items/{item.id}/subitems/new"
|
||||
else:
|
||||
redirect_url = f"/items/{item.id}"
|
||||
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/subitems/{subitem_id}/image")
|
||||
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
|
||||
|
||||
+199
-8
@@ -7,9 +7,9 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 840px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
max-width: 1100px;
|
||||
margin: 28px auto;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
@@ -17,6 +17,8 @@ body {
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
|
||||
h2,
|
||||
@@ -61,6 +63,12 @@ button,
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: #eef3f8;
|
||||
color: #1f2937;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
button:hover {
|
||||
opacity: 0.92;
|
||||
@@ -68,9 +76,11 @@ button:hover {
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
@@ -84,16 +94,57 @@ button:hover {
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-box {
|
||||
background: #e6f0ff;
|
||||
color: #0b57d0;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
background: #eef7e8;
|
||||
color: #2f6b1f;
|
||||
}
|
||||
|
||||
.type-container {
|
||||
background: #fff1da;
|
||||
color: #9a4d00;
|
||||
}
|
||||
|
||||
.type-subitem {
|
||||
background: #f2ebff;
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -129,14 +180,86 @@ button:hover {
|
||||
|
||||
.thumb-image {
|
||||
display: block;
|
||||
width: 120px;
|
||||
width: 88px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.compact-thumb {
|
||||
flex: 0 0 88px;
|
||||
}
|
||||
|
||||
.dense-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.compact-row-box {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.compact-row-container {
|
||||
border-left: 4px solid #d98700;
|
||||
}
|
||||
|
||||
.compact-row-item {
|
||||
border-left: 4px solid #3d7a2a;
|
||||
}
|
||||
|
||||
.compact-row-subitem {
|
||||
border-left: 4px solid #7b57c2;
|
||||
}
|
||||
|
||||
.compact-main h2,
|
||||
.compact-main h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row-title-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.row-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 4px 12px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.row-note {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.muted {
|
||||
color: #666;
|
||||
@@ -153,6 +276,50 @@ button:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-help {
|
||||
margin-top: -4px;
|
||||
color: #666;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
border: 1px solid #d9e2f2;
|
||||
border-radius: 10px;
|
||||
background: #f5f8fd;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.context-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.context-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.context-body:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.actions form {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -168,3 +335,27 @@ button:hover {
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.compact-row,
|
||||
.compact-row-box {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thumb-image,
|
||||
.compact-thumb {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,28 @@
|
||||
</nav>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.target.tagName === "TEXTAREA") return;
|
||||
if (event.target.type === "submit") return;
|
||||
if (!event.target.closest("form")) return;
|
||||
|
||||
const focusable = Array.from(
|
||||
event.target.form.querySelectorAll(
|
||||
'input:not([type="hidden"]):not([type="submit"]):not([type="checkbox"]), textarea, select'
|
||||
)
|
||||
).filter((element) => !element.disabled);
|
||||
|
||||
const index = focusable.indexOf(event.target);
|
||||
if (index === -1 || index === focusable.length - 1) return;
|
||||
|
||||
event.preventDefault();
|
||||
focusable[index + 1].focus();
|
||||
if (focusable[index + 1].select) {
|
||||
focusable[index + 1].select();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<strong>{{ "新建 Box" if not box else "编辑 Box" }}</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="type-tag type-box">Box</div>
|
||||
<h1>{{ page_title }}</h1>
|
||||
<p class="muted">
|
||||
{% if box %}
|
||||
你当前正在编辑一个顶层箱子。
|
||||
{% else %}
|
||||
你当前正在创建一个新的顶层箱子。
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
<section class="context-panel">
|
||||
<div class="context-title">当前操作</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag type-box">Box</span>
|
||||
<span>{{ "创建顶层箱子" if not box else "编辑顶层箱子" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<label class="form-field">
|
||||
名称
|
||||
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
|
||||
<input type="text" name="name" value="{{ box.name if box else '' }}" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
房间
|
||||
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
状态
|
||||
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
图片
|
||||
<input type="file" name="image_file" accept="image/*">
|
||||
</label>
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<span>首页</span>
|
||||
<span>/</span>
|
||||
<strong>箱子</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>箱子</h1>
|
||||
<div class="type-tag type-box">Box</div>
|
||||
<h1>箱子总览</h1>
|
||||
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
|
||||
</div>
|
||||
<a class="button" href="/boxes/new">新建箱子</a>
|
||||
</div>
|
||||
|
||||
{% if boxes %}
|
||||
<div class="stack">
|
||||
<div class="dense-list">
|
||||
{% for box in boxes %}
|
||||
<section class="card">
|
||||
<section class="compact-row compact-row-box">
|
||||
<div class="compact-main">
|
||||
<div class="row-title-line">
|
||||
<span class="type-tag type-box">Box</span>
|
||||
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
|
||||
<p class="meta">物品数:{{ box.items|length }}</p>
|
||||
{% if box.room %}<p>房间:{{ box.room }}</p>{% endif %}
|
||||
{% if box.status %}<p>状态:{{ box.status }}</p>{% endif %}
|
||||
{% if box.note %}<p>{{ box.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
</div>
|
||||
<div class="row-meta-grid">
|
||||
<span>物品数:{{ box.items|length }}</span>
|
||||
<span>房间:{{ box.room or '-' }}</span>
|
||||
<span>状态:{{ box.status or '-' }}</span>
|
||||
</div>
|
||||
{% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/boxes/{{ box.id }}">查看详情</a>
|
||||
<a href="/boxes/{{ box.id }}/edit">编辑</a>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<strong>{{ box.name }}</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="type-tag type-box">Box</div>
|
||||
<h1>{{ box.name }}</h1>
|
||||
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
|
||||
</div>
|
||||
@@ -31,16 +37,26 @@
|
||||
<section class="stack">
|
||||
<h2>物品</h2>
|
||||
{% if box.items %}
|
||||
<div class="dense-list">
|
||||
{% for item in box.items %}
|
||||
<article class="card">
|
||||
<article class="compact-row {{ 'compact-row-container' if item.is_container else 'compact-row-item' }}">
|
||||
{% if item.image_blob %}
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image">
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb">
|
||||
{% endif %}
|
||||
<div class="compact-main">
|
||||
<div class="row-title-line">
|
||||
<span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
|
||||
{{ "容器型 Item" if item.is_container else "Item" }}
|
||||
</span>
|
||||
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
|
||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
||||
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
|
||||
{% if item.note %}<p><strong>备注:</strong> {{ item.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
</div>
|
||||
<div class="row-meta-grid">
|
||||
<span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span>
|
||||
<span>是否容器:{{ "是" if item.is_container else "否" }}</span>
|
||||
</div>
|
||||
{% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/items/{{ item.id }}">查看详情</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑</a>
|
||||
{% if item.is_container %}
|
||||
@@ -52,6 +68,7 @@
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>这个箱子里还没有物品。</p>
|
||||
|
||||
@@ -1,32 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<a href="/boxes/{{ box.id }}">{{ box.name }}</a>
|
||||
<span>/</span>
|
||||
<strong>{{ "新建 Item" if not item else "编辑 Item" }}</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
|
||||
{{ "容器型 Item" if item and item.is_container else "Item" }}
|
||||
</div>
|
||||
<h1>{{ page_title }}</h1>
|
||||
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
|
||||
<p class="muted">
|
||||
{% if item %}
|
||||
你当前正在编辑这个物品,并可决定它是否是一个小容器。
|
||||
{% else %}
|
||||
你当前正在往这个箱子里添加一个 Item,可选择它是普通物品还是小容器。
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/boxes/{{ box.id }}">返回箱子</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
<section class="context-panel">
|
||||
<div class="context-title">当前上下文</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag type-box">Box</span>
|
||||
<span>{{ box.name }}</span>
|
||||
</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
|
||||
{{ "容器型 Item" if item and item.is_container else "Item" }}
|
||||
</span>
|
||||
<span>{{ "创建新的二级物品" if not item else "编辑当前二级物品" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<label class="form-field">
|
||||
名称
|
||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
|
||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
数量
|
||||
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '' }}">
|
||||
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '1' }}">
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
||||
这个物品本身是一个小容器
|
||||
</label>
|
||||
<label>
|
||||
<div class="checkbox-help">
|
||||
勾选后,这个 Item 将作为“第二层容器”,后续可以继续往里面添加最后一级的 SubItem。
|
||||
</div>
|
||||
<label class="form-field">
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
图片
|
||||
<input type="file" name="image_file" accept="image/*">
|
||||
</label>
|
||||
@@ -44,6 +77,13 @@
|
||||
</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" name="submit_action" value="save">{{ submit_label }}</button>
|
||||
{% if not item %}
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary">
|
||||
保存并添加下一个
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
|
||||
<span>/</span>
|
||||
<strong>{{ item.name }}</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
|
||||
{{ "容器型 Item" if item.is_container else "Item" }}
|
||||
</div>
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a> 中</p>
|
||||
</div>
|
||||
@@ -34,15 +44,24 @@
|
||||
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
||||
</div>
|
||||
{% if item.subitems %}
|
||||
<div class="dense-list">
|
||||
{% for subitem in item.subitems %}
|
||||
<article class="card">
|
||||
<article class="compact-row compact-row-subitem">
|
||||
{% if subitem.image_blob %}
|
||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
|
||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb">
|
||||
{% endif %}
|
||||
<div class="compact-main">
|
||||
<div class="row-title-line">
|
||||
<span class="type-tag type-subitem">SubItem</span>
|
||||
<h3>{{ subitem.name }}</h3>
|
||||
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
|
||||
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
</div>
|
||||
<div class="row-meta-grid">
|
||||
<span>数量:{{ subitem.quantity if subitem.quantity is not none else 1 }}</span>
|
||||
<span>上级容器:{{ item.name }}</span>
|
||||
</div>
|
||||
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
|
||||
<form method="post" action="/subitems/{{ subitem.id }}/delete">
|
||||
<button type="submit" class="link-button">删除</button>
|
||||
@@ -50,6 +69,7 @@
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>还没有子物品。</p>
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="/boxes">箱子</a>
|
||||
<span>/</span>
|
||||
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
|
||||
<span>/</span>
|
||||
<a href="/items/{{ item.id }}">{{ item.name }}</a>
|
||||
<span>/</span>
|
||||
<strong>{{ "新建 SubItem" if not subitem else "编辑 SubItem" }}</strong>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="type-tag type-subitem">SubItem</div>
|
||||
<h1>{{ page_title }}</h1>
|
||||
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
|
||||
<p class="muted">
|
||||
{% if subitem %}
|
||||
你当前正在编辑一个最后一级内容。
|
||||
{% else %}
|
||||
你当前正在这个容器型 Item 下面添加最后一级内容。
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/items/{{ item.id }}">返回物品</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
<section class="context-panel">
|
||||
<div class="context-title">当前上下文</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag type-box">Box</span>
|
||||
<span>{{ item.box.name }}</span>
|
||||
</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag type-container">容器型 Item</span>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="context-body">
|
||||
<span class="type-tag type-subitem">SubItem</span>
|
||||
<span>{{ "创建最后一级内容" if not subitem else "编辑最后一级内容" }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<label class="form-field">
|
||||
名称
|
||||
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
|
||||
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
数量
|
||||
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '' }}">
|
||||
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
<label class="form-field">
|
||||
图片
|
||||
<input type="file" name="image_file" accept="image/*">
|
||||
</label>
|
||||
@@ -40,6 +72,13 @@
|
||||
</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" name="submit_action" value="save">{{ submit_label }}</button>
|
||||
{% if not subitem %}
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary">
|
||||
保存并添加下一个
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
+161
-6
@@ -21,19 +21,28 @@ def create_item(
|
||||
quantity="2",
|
||||
is_container=False,
|
||||
image=None,
|
||||
submit_action="save",
|
||||
):
|
||||
data = {"name": name, "note": note, "quantity": quantity}
|
||||
data = {"name": name, "note": note, "quantity": quantity, "submit_action": submit_action}
|
||||
if is_container:
|
||||
data["is_container"] = "on"
|
||||
files = {"image_file": image} if image is not None else None
|
||||
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
|
||||
|
||||
|
||||
def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3", image=None):
|
||||
def create_subitem(
|
||||
client,
|
||||
item_id,
|
||||
name="SubItem A",
|
||||
note="Small",
|
||||
quantity="3",
|
||||
image=None,
|
||||
submit_action="save",
|
||||
):
|
||||
files = {"image_file": image} if image is not None else None
|
||||
return client.post(
|
||||
f"/items/{item_id}/subitems",
|
||||
data={"name": name, "note": note, "quantity": quantity},
|
||||
data={"name": name, "note": note, "quantity": quantity, "submit_action": submit_action},
|
||||
files=files,
|
||||
follow_redirects=False,
|
||||
)
|
||||
@@ -320,14 +329,13 @@ def test_post_redirects_are_reasonable(client, db_session):
|
||||
db_session.commit()
|
||||
|
||||
item_response = create_item(client, box.id, name="Lamp")
|
||||
item_id = int(item_response.headers["location"].split("/")[-1])
|
||||
item = db_session.get(Item, item_id)
|
||||
item = db_session.query(Item).one()
|
||||
item.is_container = True
|
||||
db_session.commit()
|
||||
|
||||
subitem_response = create_subitem(client, item.id, name="Bulb")
|
||||
|
||||
assert item_response.headers["location"] == f"/items/{item.id}"
|
||||
assert item_response.headers["location"] == f"/boxes/{box.id}"
|
||||
assert subitem_response.headers["location"] == f"/items/{item.id}"
|
||||
|
||||
|
||||
@@ -707,3 +715,150 @@ def test_search_result_without_image_does_not_break_template(client, db_session)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "无图物品" in response.text
|
||||
|
||||
|
||||
def test_new_box_page_shows_clear_context(client):
|
||||
response = client.get("/boxes/new")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "新建 Box" in response.text
|
||||
assert "创建顶层箱子" in response.text
|
||||
|
||||
|
||||
def test_new_item_page_shows_clear_context_and_default_quantity(client, db_session):
|
||||
box = Box(name="主卧箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/boxes/{box.id}/items/new")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "新建 Item" in response.text
|
||||
assert "主卧箱" in response.text
|
||||
assert 'name="quantity"' in response.text
|
||||
assert 'value="1"' in response.text
|
||||
assert "这个物品本身是一个小容器" in response.text
|
||||
assert "保存并添加下一个" in response.text
|
||||
|
||||
|
||||
def test_new_subitem_page_shows_clear_context_and_default_quantity(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(f"/items/{item.id}/subitems/new")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "新建 SubItem" in response.text
|
||||
assert "客厅箱" in response.text
|
||||
assert "文件袋" in response.text
|
||||
assert 'name="quantity"' in response.text
|
||||
assert 'value="1"' in response.text
|
||||
assert "保存并添加下一个" in response.text
|
||||
|
||||
|
||||
def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(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(f"/boxes/{box.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Box" in response.text
|
||||
assert "厨房箱" in response.text
|
||||
assert "compact-row" in response.text
|
||||
|
||||
|
||||
def test_item_detail_page_renders_clear_hierarchy(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(f"/items/{item.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "容器型 Item" in response.text
|
||||
assert "书房箱" in response.text
|
||||
assert "SubItem" in response.text
|
||||
|
||||
|
||||
def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
|
||||
box = Box(name="连续录入箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = create_item(client, box.id, name="剪刀", is_container=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/boxes/{box.id}"
|
||||
|
||||
|
||||
def test_creating_regular_subitem_redirects_back_to_parent_item(client, db_session):
|
||||
box = Box(name="配件箱")
|
||||
item = Item(name="线材袋", box=box, is_container=True)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = create_subitem(client, item.id, name="USB头")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/items/{item.id}"
|
||||
|
||||
|
||||
def test_creating_box_redirects_to_new_box_detail(client):
|
||||
response = create_box(client, name="新箱子")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"].startswith("/boxes/")
|
||||
assert not response.headers["location"].endswith("/items/new")
|
||||
|
||||
|
||||
def test_creating_container_item_redirects_to_item_detail(client, db_session):
|
||||
box = Box(name="子容器箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = create_item(client, box.id, name="小收纳盒", is_container=True)
|
||||
|
||||
created_item = db_session.query(Item).one()
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/items/{created_item.id}"
|
||||
|
||||
|
||||
def test_creating_item_with_save_and_add_next_returns_to_same_new_item_context(client, db_session):
|
||||
box = Box(name="快速录入箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = create_item(
|
||||
client,
|
||||
box.id,
|
||||
name="袜子",
|
||||
is_container=False,
|
||||
submit_action="save_and_add_next",
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/boxes/{box.id}/items/new"
|
||||
|
||||
|
||||
def test_creating_subitem_with_save_and_add_next_returns_to_same_new_subitem_context(client, db_session):
|
||||
box = Box(name="电子箱")
|
||||
item = Item(name="配件袋", box=box, is_container=True)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = create_subitem(
|
||||
client,
|
||||
item.id,
|
||||
name="转接头",
|
||||
submit_action="save_and_add_next",
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/items/{item.id}/subitems/new"
|
||||
|
||||
Reference in New Issue
Block a user