6 Commits

Author SHA1 Message Date
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
14 changed files with 172 additions and 24 deletions
+1
View File
@@ -14,3 +14,4 @@ data/*.db
**/.Spotlight-V100
.Trashes
**/.Trashes
.codex
+38
View File
@@ -18,10 +18,29 @@
- Box / Item / SubItem 基础 CRUD
- Box / Item / SubItem 单图上传、替换、删除、展示
- Box / Item / SubItem 全局搜索
- 最小 PWA 安装支持(主屏幕 / 桌面安装)
- Docker / Compose 长期运行
- 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 级:
@@ -110,6 +129,9 @@ Box
这一阶段仍然没有实现以下内容:
- 离线访问
- 离线缓存策略
- 离线数据同步
- 多图上传
- OCR
- AI 识别物品
@@ -223,6 +245,22 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 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
+17 -1
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager
from pathlib import Path
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.templating import Jinja2Templates
from sqlalchemy import func, or_
@@ -12,6 +13,7 @@ from app.images import process_upload
from app.models import Box, Item, SubItem
templates = Jinja2Templates(directory="app/templates")
STATIC_DIR = Path("app/static")
def _clean_text(value: str | None) -> str | None:
@@ -193,6 +195,20 @@ def create_app() -> FastAPI:
def root() -> RedirectResponse:
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")
def search_page(
request: Request,
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());
});
+14
View File
@@ -3,7 +3,15 @@
<head>
<meta charset="UTF-8">
<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>
<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">
</head>
<body>
@@ -15,6 +23,12 @@
{% block content %}{% endblock %}
</main>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js");
});
}
document.addEventListener("click", function (event) {
const card = event.target.closest(".clickable-card[data-href]");
if (!card) return;
+15 -15
View File
@@ -44,21 +44,6 @@
名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
</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">
图片
<input type="file" name="image_file" accept="image/*">
@@ -77,6 +62,21 @@
</button>
</section>
{% 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">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not item %}
+5
View File
@@ -70,6 +70,11 @@
<span>上级容器:{{ item.name }}</span>
</div>
{% 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>
</article>
{% endfor %}
+8 -8
View File
@@ -46,14 +46,6 @@
名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
</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">
图片
<input type="file" name="image_file" accept="image/*">
@@ -72,6 +64,14 @@
</button>
</section>
{% 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">
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
{% if not subitem %}
+36
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]
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):
box = Box(
name="Kitchen Box",
@@ -842,6 +875,7 @@ def test_new_item_page_shows_clear_context_and_default_quantity(client, db_sessi
assert "主卧箱" in response.text
assert 'name="quantity"' 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
@@ -860,6 +894,7 @@ def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_se
assert "文件袋" in response.text
assert 'name="quantity"' 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
@@ -893,6 +928,7 @@ def test_item_detail_page_renders_clear_hierarchy(client, db_session):
assert "书房箱" in response.text
assert "SubItem" 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