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
This commit is contained in:
2026-04-23 15:23:20 +02:00
parent 49a5452141
commit ed1e3311a5
10 changed files with 140 additions and 1 deletions
+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
+17 -1
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:
@@ -193,6 +195,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,
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> <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;
+33
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",