9 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
tliu93 49a5452141 Add local-network deployment automation and tighten runtime defaults
test / pytest (push) Successful in 35s
docker-image / build-and-push (push) Successful in 4m8s
This commit adds the first complete local-network deployment path for the project. It normalizes the runtime contract around a fixed container listener on 0.0.0.0:10000, binds the published compose port to 127.0.0.1, and keeps the image/build workflow aligned with the released container image.

It also introduces an installation script, an nginx reverse-proxy template, and a safer SQLite backup flow based on sqlite3 .backup with retention and optional rclone upload support. Deployment-oriented configuration has been consolidated into .env.example, repository-local .env files are now ignored, and the deployment scripts are executable.

In addition, the frontend mixed-content issue is fixed by switching the stylesheet reference to a root-relative static path, with tests updated to cover the regression. README guidance has been expanded to document the new install, nginx, backup, and restore conventions.
2026-04-21 22:39:47 +02:00
tliu93 eb29f03b74 add compose file for pulling
test / pytest (push) Successful in 38s
2026-04-21 22:01:23 +02:00
23 changed files with 720 additions and 91 deletions
+26 -8
View File
@@ -1,14 +1,32 @@
# Runtime
HOST=0.0.0.0
PORT=10000
# This file is sourced by shell scripts. Keep values shell-compatible.
# In Docker, keep the database inside the mounted /app/data directory.
# Local TLS domain used by nginx and your own certificate files.
HOST_DOMAIN=moving-helper.lan
# Certificate directory prepared by the user.
# Place fullchain.pem and privkey.key in this directory.
# If you use acme.sh, this is typically /etc/acme.sh/$HOST_DOMAIN
SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
# Deployment target directory used by the install script.
APP_DIR=$HOME/.local/share/moving-helper
# Backup destination directory used by the deployed backup script.
BACKUP_DIR=$HOME/.local/backup/moving-helper
# Optional rclone remote target, for example: remote:folder/moving-helper
BACKUP_REMOTE=
# Host port published by docker compose. The container always listens on 10000.
APP_PORT=10000
# Database location inside the container.
DATABASE_URL=sqlite:////app/data/app.db
# Host-side persistent data directory
# Host-side persistent data directory.
# Relative paths are resolved from APP_DIR after installation.
DATA_DIR=./data
# Container user mapping
UID=1000
GID=1000
# Optional compose project name.
COMPOSE_PROJECT_NAME=moving-helper
+2
View File
@@ -2,6 +2,7 @@
__pycache__/
.pytest_cache/
*.pyc
.env
data/*.db
# macOS generated files
@@ -13,3 +14,4 @@ data/*.db
**/.Spotlight-V100
.Trashes
**/.Trashes
.codex
+1 -3
View File
@@ -3,8 +3,6 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
HOST=0.0.0.0 \
PORT=10000 \
DATABASE_URL=sqlite:////app/data/app.db
WORKDIR /app
@@ -18,4 +16,4 @@ RUN mkdir -p /app/data
EXPOSE 10000
CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-10000}"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10000"]
+181 -26
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 识别物品
@@ -138,6 +160,9 @@ Box
├── scripts
│ ├── backup_db.sh
│ └── deploy.sh
│ ├── install.sh
│ └── nginx
│ └── moving-helper.nginx.template
├── tests
├── .dockerignore
├── .env.example
@@ -152,12 +177,15 @@ Box
项目通过环境变量支持以下部署时真正需要关心的配置:
- `HOST`
- `PORT`
- `DATABASE_URL`
- `HOST_DOMAIN`
- `SSL_PATH`
- `APP_DIR`
- `BACKUP_DIR`
- `BACKUP_REMOTE`
- `APP_PORT`
- `DATA_DIR`
- `UID`
- `GID`
- `DATABASE_URL`
- `COMPOSE_PROJECT_NAME`
推荐从示例文件开始:
@@ -168,20 +196,25 @@ cp .env.example .env
默认值如下:
```env
HOST=0.0.0.0
PORT=10000
HOST_DOMAIN=moving-helper.lan
SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
APP_DIR=$HOME/.local/share/moving-helper
BACKUP_DIR=$HOME/.local/backup/moving-helper
BACKUP_REMOTE=
APP_PORT=10000
DATABASE_URL=sqlite:////app/data/app.db
DATA_DIR=./data
UID=1000
GID=1000
COMPOSE_PROJECT_NAME=moving-helper
```
说明:
- 本地开发默认数据库仍然是 `./data/app.db`
- Docker 内建议继续使用 `sqlite:////app/data/app.db`
- `DATA_DIR` 控制宿主机上的持久化目录
- `UID/GID` 用来让容器内文件权限更贴近宿主机用户
- 容器内应用固定监听 `0.0.0.0:10000`
- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口
- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录
- `DATA_DIR` 默认为相对路径 `./data`,安装后会相对于 `APP_DIR` 解析
- `SSL_PATH` 由用户自行准备证书目录,安装脚本不会签发证书
- `.env` 会被 shell 脚本直接 `source`,请保持 shell 兼容写法
## 本地开发模式
@@ -212,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
@@ -222,6 +271,18 @@ http://localhost:10000
Docker / Compose 是这个项目面向长期运行环境的方式。
当前 compose 同时保留了:
- `image`:固定指向 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
- `build`:用于本地开发时从当前代码构建镜像
当前部署约定已经收敛为:
- 容器内应用固定监听 `0.0.0.0:10000`
- compose 固定使用 `user: 1000:1000`
- 宿主机仅在 `127.0.0.1:${APP_PORT}` 暴露后端端口
- SQLite 固定写入容器内 `/app/data/app.db`
### 首次准备
```bash
@@ -229,12 +290,21 @@ cp .env.example .env
mkdir -p data
```
### 启动 / 更新
### 启动 / 更新:本地代码构建
```bash
docker compose up -d --build
```
这个模式会使用当前仓库代码重新构建镜像,适合本地开发、调试或尚未发布 tag 的阶段。
### 启动 / 更新:直接拉取已发布镜像
```bash
docker compose pull
docker compose up -d
```
### 查看状态
```bash
@@ -257,15 +327,47 @@ http://localhost:10000
当前 `docker-compose.yml` 保持尽量简单:
- 默认暴露 `10000` 端口
- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000`
- `restart: unless-stopped`
- 容器用户来自 `UID:GID`
- 容器固定使用 `1000:1000`
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
- SQLite 默认写入 `/app/data/app.db`
因此同一个 compose 文件可以覆盖两种使用方式:
- 本地开发容器:`docker compose up -d --build`
- 远端部署发布镜像:`docker compose pull && docker compose up -d`
## 自动化部署
这个项目没有复杂 CI/CD,只提供一个适合家用项目的轻量部署脚本:
这个项目现在额外提供一个面向本地网络环境的最小安装脚本:
```bash
sh scripts/install.sh
```
安装脚本会执行:
1. 检查项目根目录下是否存在 `.env`
2. 读取 `.env`
3.`docker-compose.yml``.env` 和渲染后的 `backup_db.sh` 复制到 `APP_DIR`
4.`HOST_DOMAIN``SSL_PATH``APP_PORT` 渲染 nginx 配置
5. 写入 `/etc/nginx/sites-available/moving-helper-nginx`
6. 创建到 `/etc/nginx/sites-enabled/` 的符号链接
7. 执行 `nginx -t` 并 reload nginx
8.`APP_DIR` 下执行 `docker compose pull``docker compose up -d`
9. 为当前用户写入每日 `02:10` 的 backup cron
其中以下步骤需要 root 或 sudo:
- 写入 nginx 配置
- 执行 `nginx -t`
- reload nginx
如果 `.env` 不存在,脚本会直接退出,不会继续做任何安装动作。
如果你只想在仓库目录里做一次手动更新,也保留了一个轻量部署脚本:
```bash
./scripts/deploy.sh
@@ -276,7 +378,8 @@ http://localhost:10000
1. 检查 `.env`
2. 准备数据目录
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
4. 执行 `docker compose up -d --build`
4. 执行 `docker compose pull web`
5. 执行 `docker compose up -d`
5. 输出容器状态
6. 输出最近日志
@@ -285,7 +388,8 @@ http://localhost:10000
如果你不想自动拉代码,也可以直接手动运行:
```bash
docker compose up -d --build
docker compose pull
docker compose up -d
```
## 数据持久化
@@ -315,32 +419,83 @@ ${DATA_DIR:-./data}
## 备份与恢复
### 最简单的备份方式
### 备份机制
直接复制 SQLite 文件即可
安装脚本会把渲染后的备份脚本安装到
- `APP_DIR/backup_db.sh`
并为当前用户创建一条 cron
- `10 2 * * *`
备份行为如下:
- 目标目录是 `BACKUP_DIR`
- 备份文件名带时间戳,例如 `app-20260421-021000.db`
- 最多保留 5 个本地备份
- 如果 `BACKUP_REMOTE` 非空,会在本地备份完成后调用 `rclone copyto`
SQLite 一致性策略:
- 备份脚本优先使用 `sqlite3``.backup`
- 不停容器
- 不直接 `cp` 正在写入的数据库文件
这样可以在应用仍然运行时生成事务一致的快照,避免简单文件复制带来的损坏风险。
### 手动执行备份
如果你想手动触发一次备份:
```bash
cp data/app.db backups/app.db
sh "$APP_DIR/backup_db.sh"
```
或者使用附带脚本
如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行
```bash
./scripts/backup_db.sh
```
它会在 `backups/` 目录下生成一个带时间戳的副本
前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板
### 最简单的恢复方式
### 恢复大致步骤
停止容器后,把备份文件覆盖回去:
```bash
cd "$APP_DIR"
docker compose stop
cp backups/app-YYYYMMDD-HHMMSS.db data/app.db
cp "$BACKUP_DIR/app-YYYYMMDD-HHMMSS.db" "${DATA_DIR:-./data}/app.db"
docker compose up -d
```
如果 `DATA_DIR` 是相对路径,记得在 `APP_DIR` 下执行这些命令。
### nginx 与证书约定
仓库提供的 nginx 模板位于:
- `scripts/nginx/moving-helper.nginx.template`
安装脚本会把它渲染成 Debian / Ubuntu 风格的站点配置:
- `/etc/nginx/sites-available/moving-helper-nginx`
- `/etc/nginx/sites-enabled/moving-helper-nginx`
模板约定:
- 80 端口强制跳转到 443
- 443 默认启用 SSL
- 反代到仅绑定在本机回环地址上的 `127.0.0.1:${APP_PORT}`
- `client_max_body_size 0`
证书文件需要由用户自己准备在 `SSL_PATH` 下,当前模板默认引用:
- `fullchain.pem`
- `privkey.key`
## 常见排查
### 1. 查看容器日志
+48 -2
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:
@@ -86,6 +88,35 @@ def _wants_add_next(submit_action: str | None) -> bool:
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]:
keyword = f"%{query.lower()}%"
results: list[dict] = []
@@ -193,6 +224,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,
@@ -215,10 +260,11 @@ def create_app() -> FastAPI:
@app.get("/boxes")
def list_boxes(request: Request, db: Session = Depends(get_db)):
boxes = db.query(Box).order_by(Box.id.desc()).all()
summary = _build_boxes_overview_summary(db)
return templates.TemplateResponse(
request=request,
name="boxes/index.html",
context={"page_title": "箱子", "boxes": boxes},
context={"page_title": "箱子", "boxes": boxes, "summary": summary},
)
@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;
}
.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 {
display: grid;
grid-template-columns: auto 1fr auto;
+15 -1
View File
@@ -3,8 +3,16 @@
<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="stylesheet" href="{{ url_for('static', path='/style.css') }}">
<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>
<main class="container">
@@ -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;
+16
View File
@@ -15,6 +15,22 @@
<a class="button button-primary" href="/boxes/new">新建箱子</a>
</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 %}
<div class="overview-grid">
{% 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>
</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 %}
+3 -4
View File
@@ -1,14 +1,13 @@
services:
web:
container_name: moving-helper
image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest"
build:
context: .
user: "${UID:-1000}:${GID:-1000}"
user: "1000:1000"
ports:
- "${PORT:-10000}:${PORT:-10000}"
- "127.0.0.1:${APP_PORT:-10000}:10000"
environment:
HOST: ${HOST:-0.0.0.0}
PORT: ${PORT:-10000}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
volumes:
- ${DATA_DIR:-./data}:/app/data
+69 -13
View File
@@ -1,27 +1,83 @@
#!/usr/bin/env sh
set -eu
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_ROOT"
APP_DIR="__APP_DIR__"
DEFAULT_BACKUP_DIR="__BACKUP_DIR__"
ENV_FILE="$APP_DIR/.env"
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo "未找到 .env,先从 .env.example 复制一份:"
echo " cp .env.example .env"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
resolve_path() {
case "$1" in
/*) printf '%s\n' "$1" ;;
*) printf '%s/%s\n' "$APP_DIR" "$1" ;;
esac
}
if [ ! -f "$ENV_FILE" ]; then
echo "Deployed .env file not found: $ENV_FILE" >&2
exit 1
fi
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
DATA_DIR=${DATA_DIR_VALUE:-./data}
DB_PATH="$DATA_DIR/app.db"
set -a
. "$ENV_FILE"
set +a
require_command sqlite3
if [ -n "${BACKUP_REMOTE:-}" ]; then
require_command rclone
fi
BACKUP_DIR=${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}
DATA_DIR=${DATA_DIR:-./data}
DB_PATH="$(resolve_path "$DATA_DIR")/app.db"
if [ ! -f "$DB_PATH" ]; then
echo "未找到数据库文件:$DB_PATH"
echo "Database file not found: $DB_PATH" >&2
exit 1
fi
mkdir -p backups
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
DESTINATION="backups/app-$TIMESTAMP.db"
TMP_BACKUP="$BACKUP_DIR/.app-$TIMESTAMP.db.tmp"
FINAL_BACKUP="$BACKUP_DIR/app-$TIMESTAMP.db"
cp "$DB_PATH" "$DESTINATION"
echo "备份已创建:$DESTINATION"
cleanup() {
rm -f "$TMP_BACKUP"
}
trap cleanup EXIT INT TERM
# Prefer sqlite3 .backup so the snapshot stays transactionally consistent without
# stopping the running container or racing with SQLite writes.
sqlite3 "$DB_PATH" <<EOF
.timeout 5000
.backup $TMP_BACKUP
EOF
mv "$TMP_BACKUP" "$FINAL_BACKUP"
trap - EXIT INT TERM
count=0
for backup_file in $(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'app-*.db' | sort -r); do
count=$((count + 1))
if [ "$count" -gt 5 ]; then
rm -f "$backup_file"
fi
done
if [ -n "${BACKUP_REMOTE:-}" ]; then
remote_target=${BACKUP_REMOTE%/}/$(basename "$FINAL_BACKUP")
rclone copyto "$FINAL_BACKUP" "$remote_target"
echo "Backup uploaded to remote: $remote_target"
else
echo "BACKUP_REMOTE is empty; skipping remote upload"
fi
echo "Backup created: $FINAL_BACKUP"
+17 -11
View File
@@ -5,33 +5,39 @@ PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_ROOT"
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
echo "未找到 .env,先从 .env.example 复制一份:"
echo ".env not found. Create it first from .env.example:"
echo " cp .env.example .env"
exit 1
fi
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
DATA_DIR=${DATA_DIR_VALUE:-./data}
set -a
. ./.env
set +a
DATA_DIR=${DATA_DIR:-./data}
APP_PORT=${APP_PORT:-10000}
mkdir -p "$DATA_DIR"
echo "[1/4] 拉取最新代码(如果当前目录是 git 仓库)"
echo "[1/4] Pull latest code if this directory is a git repository"
if [ -d ".git" ]; then
git pull --ff-only
else
echo "跳过:当前目录不是 git 仓库"
echo "Skipped: current directory is not a git repository"
fi
echo "[2/4] 构建并更新容器"
docker compose up -d --build
echo "[2/4] Pull and update containers"
docker compose pull web
docker compose up -d
echo "[3/4] 当前容器状态"
echo "[3/4] Current container status"
docker compose ps
echo "[4/4] 最近日志"
echo "[4/4] Recent logs"
docker compose logs --tail=50 web
echo
echo "部署完成。应用默认地址:"
echo " http://localhost:$(grep '^PORT=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || echo 10000)"
echo "Deployment complete. Default application URLs:"
echo " https://${HOST_DOMAIN:-localhost}"
echo " Backend port mapping: localhost:$APP_PORT -> container:10000"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
SOURCE_ENV="$PROJECT_ROOT/.env"
COMPOSE_SOURCE="$PROJECT_ROOT/docker-compose.yml"
BACKUP_TEMPLATE="$SCRIPT_DIR/backup_db.sh"
NGINX_TEMPLATE="$SCRIPT_DIR/nginx/moving-helper.nginx.template"
NGINX_SITE_NAME="moving-helper-nginx"
CRON_MARKER="# moving-helper-backup"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "This step requires root privileges, but sudo is not available: $*" >&2
exit 1
fi
}
escape_sed_replacement() {
printf '%s' "$1" | sed 's/[|&]/\\&/g'
}
render_template() {
src=$1
dst=$2
host_domain_escaped=$(escape_sed_replacement "$HOST_DOMAIN")
ssl_path_escaped=$(escape_sed_replacement "$SSL_PATH")
app_port_escaped=$(escape_sed_replacement "$APP_PORT")
app_dir_escaped=$(escape_sed_replacement "$APP_DIR")
backup_dir_escaped=$(escape_sed_replacement "$BACKUP_DIR")
sed \
-e "s|__HOST_DOMAIN__|$host_domain_escaped|g" \
-e "s|__SSL_PATH__|$ssl_path_escaped|g" \
-e "s|__APP_PORT__|$app_port_escaped|g" \
-e "s|__APP_DIR__|$app_dir_escaped|g" \
-e "s|__BACKUP_DIR__|$backup_dir_escaped|g" \
"$src" > "$dst"
}
if [ ! -f "$SOURCE_ENV" ]; then
echo "Missing $SOURCE_ENV" >&2
echo "Create it first: cp .env.example .env" >&2
exit 1
fi
require_command docker
require_command crontab
if ! docker compose version >/dev/null 2>&1; then
echo "The docker compose plugin is not available in the current environment" >&2
exit 1
fi
set -a
. "$SOURCE_ENV"
set +a
HOST_DOMAIN=${HOST_DOMAIN:-}
SSL_PATH=${SSL_PATH:-}
APP_DIR=${APP_DIR:-$HOME/.local/share/moving-helper}
BACKUP_DIR=${BACKUP_DIR:-$HOME/.local/backup/moving-helper}
APP_PORT=${APP_PORT:-10000}
DATA_DIR=${DATA_DIR:-./data}
if [ -z "$HOST_DOMAIN" ]; then
echo "HOST_DOMAIN is not configured" >&2
exit 1
fi
if [ -z "$SSL_PATH" ]; then
echo "SSL_PATH is not configured" >&2
exit 1
fi
mkdir -p "$APP_DIR" "$BACKUP_DIR" "$APP_DIR/logs"
case "$DATA_DIR" in
/*) mkdir -p "$DATA_DIR" ;;
*) mkdir -p "$APP_DIR/$DATA_DIR" ;;
esac
cp "$COMPOSE_SOURCE" "$APP_DIR/docker-compose.yml"
cp "$SOURCE_ENV" "$APP_DIR/.env"
rendered_backup=$(mktemp)
rendered_nginx=$(mktemp)
trap 'rm -f "$rendered_backup" "$rendered_nginx"' EXIT INT TERM
render_template "$BACKUP_TEMPLATE" "$rendered_backup"
install -m 0755 "$rendered_backup" "$APP_DIR/backup_db.sh"
render_template "$NGINX_TEMPLATE" "$rendered_nginx"
run_as_root install -d /etc/nginx/sites-available /etc/nginx/sites-enabled
run_as_root install -m 0644 "$rendered_nginx" "/etc/nginx/sites-available/$NGINX_SITE_NAME"
run_as_root ln -sfn "/etc/nginx/sites-available/$NGINX_SITE_NAME" "/etc/nginx/sites-enabled/$NGINX_SITE_NAME"
run_as_root nginx -t
if command -v systemctl >/dev/null 2>&1; then
run_as_root systemctl reload nginx
else
run_as_root service nginx reload
fi
(
cd "$APP_DIR"
docker compose pull web
docker compose up -d
)
cron_tmp=$(mktemp)
existing_cron=$(mktemp)
trap 'rm -f "$rendered_backup" "$rendered_nginx" "$cron_tmp" "$existing_cron"' EXIT INT TERM
crontab -l 2>/dev/null > "$existing_cron" || true
grep -v "moving-helper-backup" "$existing_cron" > "$cron_tmp" || true
printf '10 2 * * * %s/backup_db.sh >> %s/logs/backup.log 2>&1 %s\n' "$APP_DIR" "$APP_DIR" "$CRON_MARKER" >> "$cron_tmp"
crontab "$cron_tmp"
echo "Installation complete."
echo "- Application directory: $APP_DIR"
echo "- Backup directory: $BACKUP_DIR"
echo "- Nginx config: /etc/nginx/sites-available/$NGINX_SITE_NAME"
echo "- URL: https://$HOST_DOMAIN"
echo "- Scheduled backup: daily at 02:10 via the current user's crontab"
@@ -0,0 +1,29 @@
server {
listen 80;
server_name __HOST_DOMAIN__;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name __HOST_DOMAIN__;
ssl_certificate __SSL_PATH__/fullchain.pem;
ssl_certificate_key __SSL_PATH__/privkey.key;
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:__APP_PORT__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_read_timeout 300;
}
}
+91
View File
@@ -103,6 +103,47 @@ def test_boxes_page_returns_200(client):
assert "箱子" in response.text
def test_boxes_page_uses_relative_stylesheet_path(client):
response = client.get("/boxes")
assert response.status_code == 200
assert 'href="/static/style.css"' in response.text
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",
@@ -137,6 +178,53 @@ def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
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):
response = create_box(client, name="Kitchen Box")
@@ -834,6 +922,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
@@ -852,6 +941,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
@@ -885,6 +975,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