7 Commits

Author SHA1 Message Date
tliu93 facf82c898 add summary
test / pytest (push) Successful in 40s
docker-image / build-and-push (push) Successful in 4m17s
2026-04-27 20:43:57 +02:00
tliu93 d24c41d05f Merge pull request 'ui change move image element' (#3) from ui/move_image_element into main
test / pytest (push) Successful in 40s
Reviewed-on: #3
2026-04-27 20:31:27 +02:00
tliu93 4955c87d78 ui change move image element
test / pytest (push) Successful in 41s
2026-04-27 20:31:12 +02:00
tliu93 bfa554b407 Merge pull request 'bug fixed' (#2) from bugfix/sub_item_delete into main
test / pytest (push) Successful in 40s
Reviewed-on: #2
2026-04-27 20:22:36 +02:00
tliu93 e5fee32098 bug fixed
test / pytest (push) Successful in 43s
2026-04-27 20:22:01 +02:00
tliu93 22ea44d8cd Merge pull request 'Add minimal installable PWA support' (#1) from feature/pwa into main
test / pytest (push) Successful in 38s
docker-image / build-and-push (push) Successful in 4m12s
Reviewed-on: #1
2026-04-23 15:26:19 +02:00
tliu93 ed1e3311a5 Add minimal installable PWA support
test / pytest (push) Successful in 37s
- serve manifest and service worker from the app root for install compatibility
- add manifest metadata, service worker registration, and Apple touch icon links to the base template
- add install icon assets for Android, iOS, and desktop install flows
- document deployment and validation notes for the new PWA support
- cover the new endpoints and template output with tests
2026-04-23 15:23:20 +02:00
16 changed files with 285 additions and 25 deletions
+1
View File
@@ -14,3 +14,4 @@ data/*.db
**/.Spotlight-V100 **/.Spotlight-V100
.Trashes .Trashes
**/.Trashes **/.Trashes
.codex
+38
View File
@@ -18,10 +18,29 @@
- Box / Item / SubItem 基础 CRUD - Box / Item / SubItem 基础 CRUD
- Box / Item / SubItem 单图上传、替换、删除、展示 - Box / Item / SubItem 单图上传、替换、删除、展示
- Box / Item / SubItem 全局搜索 - Box / Item / SubItem 全局搜索
- 最小 PWA 安装支持(主屏幕 / 桌面安装)
- Docker / Compose 长期运行 - Docker / Compose 长期运行
- SQLite 数据持久化 - SQLite 数据持久化
- 基础自动化测试 - 基础自动化测试
## PWA 安装支持
当前版本在不改变 FastAPI + Jinja2 SSR 结构的前提下,补充了最小可维护的 PWA 能力:
- 提供根路径 `manifest.webmanifest`
- 提供根路径 `service-worker.js`
- 在基础模板中注入 `manifest``theme-color``apple-touch-icon` 和安装相关 meta
- 支持 Android Chrome 添加到主屏幕
- 支持 iPhone Safari 添加到主屏幕
- 支持桌面 Chrome / Edge 安装为独立 app 窗口
当前新增的安装图标尺寸:
- `180x180`Apple touch icon
- `192x192`Android / Chromium 安装图标
- `512x512`:高分辨率安装图标
- `512x512`maskable 图标
## 当前数据模型 ## 当前数据模型
这个项目不是无限树结构,而是固定最多 3 级: 这个项目不是无限树结构,而是固定最多 3 级:
@@ -110,6 +129,9 @@ Box
这一阶段仍然没有实现以下内容: 这一阶段仍然没有实现以下内容:
- 离线访问
- 离线缓存策略
- 离线数据同步
- 多图上传 - 多图上传
- OCR - OCR
- AI 识别物品 - AI 识别物品
@@ -223,6 +245,22 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
http://localhost:10000 http://localhost:10000
``` ```
本地开发验证 PWA 时,页面与安装元数据可以直接检查;如果要完整验证桌面安装体验,优先在 HTTPS 或受信任反向代理环境下测试。
## PWA 部署注意事项
- 生产环境应使用 HTTPSAndroid Chrome 和桌面 Chrome / Edge 的安装能力通常要求安全上下文
- `manifest.webmanifest` 需要返回 `application/manifest+json`
- `service-worker.js` 需要从站点根路径返回,保证作用域覆盖整个应用
- 如果前面有 nginx 或其他反向代理,不要拦截或改写这两个根路径资源
- iPhone Safari 的“添加到主屏幕”主要依赖 meta 和 `apple-touch-icon`,不包含离线能力
## PWA 简单验收
1. Android Chrome:打开站点,确认浏览器菜单或地址栏出现“添加到主屏幕”或“安装应用”。
2. iPhone Safari:打开站点,点击分享菜单,确认可见“添加到主屏幕”。
3. Desktop Chrome / Edge:打开站点,确认地址栏或菜单中出现“安装应用”。
本地默认数据库位置: 本地默认数据库位置:
```text ```text
+48 -2
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import RedirectResponse, Response from fastapi.responses import FileResponse, 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 import func, or_
@@ -12,6 +13,7 @@ from app.images import process_upload
from app.models import Box, Item, SubItem from app.models import Box, Item, SubItem
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
STATIC_DIR = Path("app/static")
def _clean_text(value: str | None) -> str | None: def _clean_text(value: str | None) -> str | None:
@@ -86,6 +88,35 @@ def _wants_add_next(submit_action: str | None) -> bool:
return submit_action == "save_and_add_next" return submit_action == "save_and_add_next"
def _format_average(total: int, divisor: int) -> str:
if divisor == 0:
return "0.0"
return f"{total / divisor:.1f}"
def _build_boxes_overview_summary(db: Session) -> dict[str, int | str]:
box_count = db.query(func.count(Box.id)).scalar() or 0
item_count = db.query(func.count(Item.id)).scalar() or 0
subitem_count = db.query(func.count(SubItem.id)).scalar() or 0
container_item_count = (
db.query(func.count(Item.id))
.filter(Item.is_container.is_(True))
.scalar()
or 0
)
return {
"box_count": box_count,
"item_count": item_count,
"item_and_subitem_count": item_count + subitem_count,
"avg_items_per_box": _format_average(item_count, box_count),
"avg_subitems_per_container_item": _format_average(
subitem_count,
container_item_count,
),
}
def _build_search_results(db: Session, query: str) -> list[dict]: def _build_search_results(db: Session, query: str) -> list[dict]:
keyword = f"%{query.lower()}%" keyword = f"%{query.lower()}%"
results: list[dict] = [] results: list[dict] = []
@@ -193,6 +224,20 @@ 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("/manifest.webmanifest", include_in_schema=False)
def manifest() -> FileResponse:
return FileResponse(
path=STATIC_DIR / "manifest.webmanifest",
media_type="application/manifest+json",
)
@app.get("/service-worker.js", include_in_schema=False)
def service_worker() -> FileResponse:
return FileResponse(
path=STATIC_DIR / "service-worker.js",
media_type="application/javascript",
)
@app.get("/search") @app.get("/search")
def search_page( def search_page(
request: Request, request: Request,
@@ -215,10 +260,11 @@ def create_app() -> FastAPI:
@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()
summary = _build_boxes_overview_summary(db)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="boxes/index.html", name="boxes/index.html",
context={"page_title": "箱子", "boxes": boxes}, context={"page_title": "箱子", "boxes": boxes, "summary": summary},
) )
@app.get("/boxes/new") @app.get("/boxes/new")
Binary file not shown.

After

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"name": "搬家助手",
"short_name": "搬家助手",
"description": "用于记录搬家装箱内容并快速搜索的轻量工具。",
"start_url": "/boxes",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f4f4f4",
"theme_color": "#0b57d0",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
self.addEventListener("install", function () {
self.skipWaiting();
});
self.addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
+19
View File
@@ -231,6 +231,25 @@ button:focus-visible {
gap: 10px; gap: 10px;
} }
.summary-section {
margin-bottom: 18px;
}
.summary-block {
padding-top: 12px;
padding-bottom: 12px;
}
.summary-list {
margin: 0;
padding-left: 18px;
color: #333;
}
.summary-list li + li {
margin-top: 6px;
}
.compact-row { .compact-row {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
+14
View File
@@ -3,7 +3,15 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0b57d0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="搬家助手">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>{{ page_title or "搬家助手" }}</title> <title>{{ page_title or "搬家助手" }}</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icons/icon-192.png" sizes="192x192" type="image/png">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
@@ -15,6 +23,12 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script> <script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js");
});
}
document.addEventListener("click", function (event) { document.addEventListener("click", function (event) {
const card = event.target.closest(".clickable-card[data-href]"); const card = event.target.closest(".clickable-card[data-href]");
if (!card) return; if (!card) return;
+16
View File
@@ -15,6 +15,22 @@
<a class="button button-primary" href="/boxes/new">新建箱子</a> <a class="button button-primary" href="/boxes/new">新建箱子</a>
</div> </div>
<section class="stack summary-section">
<div class="section-heading">
<h2>当前概览</h2>
<p class="muted">快速查看当前装箱记录的核心统计。</p>
</div>
<section class="card summary-block">
<ul class="summary-list">
<li><strong>箱子总数:</strong>{{ summary.box_count }}</li>
<li><strong>物品总数(不含子物品):</strong>{{ summary.item_count }}</li>
<li><strong>物品总数(含子物品):</strong>{{ summary.item_and_subitem_count }}</li>
<li><strong>平均每箱物品数:</strong>{{ summary.avg_items_per_box }}</li>
<li><strong>平均每个容器型 Item 的子物品数:</strong>{{ summary.avg_subitems_per_container_item }}</li>
</ul>
</section>
</section>
{% if boxes %} {% if boxes %}
<div class="overview-grid"> <div class="overview-grid">
{% for box in boxes %} {% for box in boxes %}
+15 -15
View File
@@ -44,21 +44,6 @@
名称 名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus> <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 '1' }}">
</label>
<label class="checkbox-row">
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
这个物品本身是一个小容器
</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 class="form-field"> <label class="form-field">
图片 图片
<input type="file" name="image_file" accept="image/*"> <input type="file" name="image_file" accept="image/*">
@@ -77,6 +62,21 @@
</button> </button>
</section> </section>
{% endif %} {% endif %}
<label class="form-field">
数量
<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>
<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>
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button> <button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not item %} {% if not item %}
+5
View File
@@ -70,6 +70,11 @@
<span>上级容器:{{ item.name }}</span> <span>上级容器:{{ item.name }}</span>
</div> </div>
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %} {% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
<div class="actions">
<form method="post" action="/subitems/{{ subitem.id }}/delete">
<button type="submit" class="button button-danger button-small">删除子物品</button>
</form>
</div>
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
+8 -8
View File
@@ -46,14 +46,6 @@
名称 名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus> <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 '1' }}">
</label>
<label class="form-field">
备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label>
<label class="form-field"> <label class="form-field">
图片 图片
<input type="file" name="image_file" accept="image/*"> <input type="file" name="image_file" accept="image/*">
@@ -72,6 +64,14 @@
</button> </button>
</section> </section>
{% endif %} {% endif %}
<label class="form-field">
数量
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
</label>
<label class="form-field">
备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label>
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button> <button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not subitem %} {% if not subitem %}
+83
View File
@@ -111,6 +111,39 @@ def test_boxes_page_uses_relative_stylesheet_path(client):
assert "http://" not in response.text.split("/static/style.css")[0] assert "http://" not in response.text.split("/static/style.css")[0]
def test_base_template_includes_pwa_metadata(client):
response = client.get("/boxes")
assert response.status_code == 200
assert 'rel="manifest" href="/manifest.webmanifest"' in response.text
assert 'name="theme-color" content="#0b57d0"' in response.text
assert 'rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"' in response.text
assert 'navigator.serviceWorker.register("/service-worker.js")' in response.text
def test_manifest_is_served_with_pwa_fields(client):
response = client.get("/manifest.webmanifest")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/manifest+json")
manifest = response.json()
assert manifest["name"] == "搬家助手"
assert manifest["short_name"] == "搬家助手"
assert manifest["start_url"] == "/boxes"
assert manifest["display"] == "standalone"
assert any(icon["sizes"] == "192x192" for icon in manifest["icons"])
assert any(icon["purpose"] == "maskable" for icon in manifest["icons"])
def test_service_worker_is_served_from_root(client):
response = client.get("/service-worker.js")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/javascript")
assert 'self.addEventListener("install"' in response.text
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session): def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
box = Box( box = Box(
name="Kitchen Box", name="Kitchen Box",
@@ -145,6 +178,53 @@ def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
assert "状态:" not in response.text assert "状态:" not in response.text
def test_boxes_overview_summary_shows_expected_counts_and_averages(client, db_session):
first_box = Box(name="卧室箱")
second_box = Box(name="书房箱")
regular_item = Item(name="", box=first_box, is_container=False)
container_item = Item(name="配件袋", box=first_box, is_container=True)
second_container_item = Item(name="文件袋", box=second_box, is_container=True)
db_session.add_all(
[
first_box,
second_box,
regular_item,
container_item,
second_container_item,
SubItem(name="转接头", parent_item=container_item),
SubItem(name="数据线", parent_item=container_item),
]
)
db_session.commit()
response = client.get("/boxes")
assert response.status_code == 200
assert "当前概览" in response.text
assert "箱子总数" in response.text
assert "物品总数(不含子物品)" in response.text
assert "物品总数(含子物品)" in response.text
assert "平均每箱物品数" in response.text
assert "平均每个容器型 Item 的子物品数" in response.text
assert "箱子总数:</strong>2" in response.text
assert "物品总数(不含子物品):</strong>3" in response.text
assert "物品总数(含子物品):</strong>5" in response.text
assert "平均每箱物品数:</strong>1.5" in response.text
assert "平均每个容器型 Item 的子物品数:</strong>1.0" in response.text
def test_boxes_overview_summary_handles_empty_data_safely(client):
response = client.get("/boxes")
assert response.status_code == 200
assert "当前概览" in response.text
assert "箱子总数:</strong>0" in response.text
assert "物品总数(不含子物品):</strong>0" in response.text
assert "物品总数(含子物品):</strong>0" in response.text
assert "平均每箱物品数:</strong>0.0" in response.text
assert "平均每个容器型 Item 的子物品数:</strong>0.0" in response.text
def test_can_create_box(client, db_session): def test_can_create_box(client, db_session):
response = create_box(client, name="Kitchen Box") response = create_box(client, name="Kitchen Box")
@@ -842,6 +922,7 @@ def test_new_item_page_shows_clear_context_and_default_quantity(client, db_sessi
assert "主卧箱" in response.text assert "主卧箱" in response.text
assert 'name="quantity"' in response.text assert 'name="quantity"' in response.text
assert 'value="1"' in response.text assert 'value="1"' in response.text
assert response.text.index('name="name"') < response.text.index('name="image_file"') < response.text.index('name="quantity"')
assert "这个物品本身是一个小容器" in response.text assert "这个物品本身是一个小容器" in response.text
assert "保存并添加下一个" in response.text assert "保存并添加下一个" in response.text
@@ -860,6 +941,7 @@ def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_se
assert "文件袋" in response.text assert "文件袋" in response.text
assert 'name="quantity"' in response.text assert 'name="quantity"' in response.text
assert 'value="1"' in response.text assert 'value="1"' in response.text
assert response.text.index('name="name"') < response.text.index('name="image_file"') < response.text.index('name="quantity"')
assert "保存并添加下一个" in response.text assert "保存并添加下一个" in response.text
@@ -893,6 +975,7 @@ def test_item_detail_page_renders_clear_hierarchy(client, db_session):
assert "书房箱" in response.text assert "书房箱" in response.text
assert "SubItem" in response.text assert "SubItem" in response.text
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
assert f'action="/subitems/{subitem.id}/delete"' in response.text
assert "overview-grid" in response.text assert "overview-grid" in response.text