Compare commits
2 Commits
49a5452141
...
22ea44d8cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ea44d8cd | |||
| ed1e3311a5 |
@@ -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 部署注意事项
|
||||
|
||||
- 生产环境应使用 HTTPS;Android 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
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
self.addEventListener("install", function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user