Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| facf82c898 | |||
| d24c41d05f | |||
| 4955c87d78 | |||
| bfa554b407 | |||
| e5fee32098 | |||
| 22ea44d8cd | |||
| ed1e3311a5 | |||
| 49a5452141 | |||
| eb29f03b74 |
+26
-8
@@ -1,14 +1,32 @@
|
|||||||
# Runtime
|
# This file is sourced by shell scripts. Keep values shell-compatible.
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=10000
|
|
||||||
|
|
||||||
# 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
|
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
|
DATA_DIR=./data
|
||||||
|
|
||||||
# Container user mapping
|
# Optional compose project name.
|
||||||
UID=1000
|
COMPOSE_PROJECT_NAME=moving-helper
|
||||||
GID=1000
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.env
|
||||||
data/*.db
|
data/*.db
|
||||||
|
|
||||||
# macOS generated files
|
# macOS generated files
|
||||||
@@ -13,3 +14,4 @@ data/*.db
|
|||||||
**/.Spotlight-V100
|
**/.Spotlight-V100
|
||||||
.Trashes
|
.Trashes
|
||||||
**/.Trashes
|
**/.Trashes
|
||||||
|
.codex
|
||||||
+1
-3
@@ -3,8 +3,6 @@ FROM python:3.12-slim
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
HOST=0.0.0.0 \
|
|
||||||
PORT=10000 \
|
|
||||||
DATABASE_URL=sqlite:////app/data/app.db
|
DATABASE_URL=sqlite:////app/data/app.db
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -18,4 +16,4 @@ RUN mkdir -p /app/data
|
|||||||
|
|
||||||
EXPOSE 10000
|
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"]
|
||||||
|
|||||||
@@ -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 识别物品
|
||||||
@@ -138,6 +160,9 @@ Box
|
|||||||
├── scripts
|
├── scripts
|
||||||
│ ├── backup_db.sh
|
│ ├── backup_db.sh
|
||||||
│ └── deploy.sh
|
│ └── deploy.sh
|
||||||
|
│ ├── install.sh
|
||||||
|
│ └── nginx
|
||||||
|
│ └── moving-helper.nginx.template
|
||||||
├── tests
|
├── tests
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── .env.example
|
├── .env.example
|
||||||
@@ -152,12 +177,15 @@ Box
|
|||||||
|
|
||||||
项目通过环境变量支持以下部署时真正需要关心的配置:
|
项目通过环境变量支持以下部署时真正需要关心的配置:
|
||||||
|
|
||||||
- `HOST`
|
- `HOST_DOMAIN`
|
||||||
- `PORT`
|
- `SSL_PATH`
|
||||||
- `DATABASE_URL`
|
- `APP_DIR`
|
||||||
|
- `BACKUP_DIR`
|
||||||
|
- `BACKUP_REMOTE`
|
||||||
|
- `APP_PORT`
|
||||||
- `DATA_DIR`
|
- `DATA_DIR`
|
||||||
- `UID`
|
- `DATABASE_URL`
|
||||||
- `GID`
|
- `COMPOSE_PROJECT_NAME`
|
||||||
|
|
||||||
推荐从示例文件开始:
|
推荐从示例文件开始:
|
||||||
|
|
||||||
@@ -168,20 +196,25 @@ cp .env.example .env
|
|||||||
默认值如下:
|
默认值如下:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
HOST=0.0.0.0
|
HOST_DOMAIN=moving-helper.lan
|
||||||
PORT=10000
|
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
|
DATABASE_URL=sqlite:////app/data/app.db
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
UID=1000
|
COMPOSE_PROJECT_NAME=moving-helper
|
||||||
GID=1000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- 本地开发默认数据库仍然是 `./data/app.db`
|
- 容器内应用固定监听 `0.0.0.0:10000`
|
||||||
- Docker 内建议继续使用 `sqlite:////app/data/app.db`
|
- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口
|
||||||
- `DATA_DIR` 控制宿主机上的持久化目录
|
- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录
|
||||||
- `UID/GID` 用来让容器内文件权限更贴近宿主机用户
|
- `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
|
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
|
```text
|
||||||
@@ -222,6 +271,18 @@ http://localhost:10000
|
|||||||
|
|
||||||
Docker / Compose 是这个项目面向长期运行环境的方式。
|
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
|
```bash
|
||||||
@@ -229,12 +290,21 @@ cp .env.example .env
|
|||||||
mkdir -p data
|
mkdir -p data
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动 / 更新
|
### 启动 / 更新:本地代码构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
这个模式会使用当前仓库代码重新构建镜像,适合本地开发、调试或尚未发布 tag 的阶段。
|
||||||
|
|
||||||
|
### 启动 / 更新:直接拉取已发布镜像
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
### 查看状态
|
### 查看状态
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -257,15 +327,47 @@ http://localhost:10000
|
|||||||
|
|
||||||
当前 `docker-compose.yml` 保持尽量简单:
|
当前 `docker-compose.yml` 保持尽量简单:
|
||||||
|
|
||||||
- 默认暴露 `10000` 端口
|
- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
|
||||||
|
- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000`
|
||||||
- `restart: unless-stopped`
|
- `restart: unless-stopped`
|
||||||
- 容器用户来自 `UID:GID`
|
- 容器固定使用 `1000:1000`
|
||||||
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
|
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
|
||||||
- SQLite 默认写入 `/app/data/app.db`
|
- 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
|
```bash
|
||||||
./scripts/deploy.sh
|
./scripts/deploy.sh
|
||||||
@@ -276,7 +378,8 @@ http://localhost:10000
|
|||||||
1. 检查 `.env`
|
1. 检查 `.env`
|
||||||
2. 准备数据目录
|
2. 准备数据目录
|
||||||
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
|
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
|
||||||
4. 执行 `docker compose up -d --build`
|
4. 执行 `docker compose pull web`
|
||||||
|
5. 执行 `docker compose up -d`
|
||||||
5. 输出容器状态
|
5. 输出容器状态
|
||||||
6. 输出最近日志
|
6. 输出最近日志
|
||||||
|
|
||||||
@@ -285,7 +388,8 @@ http://localhost:10000
|
|||||||
如果你不想自动拉代码,也可以直接手动运行:
|
如果你不想自动拉代码,也可以直接手动运行:
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
cp data/app.db backups/app.db
|
sh "$APP_DIR/backup_db.sh"
|
||||||
```
|
```
|
||||||
|
|
||||||
或者使用附带脚本:
|
如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/backup_db.sh
|
./scripts/backup_db.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
它会在 `backups/` 目录下生成一个带时间戳的副本。
|
前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板。
|
||||||
|
|
||||||
### 最简单的恢复方式
|
### 恢复大致步骤
|
||||||
|
|
||||||
停止容器后,把备份文件覆盖回去:
|
停止容器后,把备份文件覆盖回去:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd "$APP_DIR"
|
||||||
docker compose stop
|
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
|
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. 查看容器日志
|
### 1. 查看容器日志
|
||||||
|
|||||||
+48
-2
@@ -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 |
@@ -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());
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
+15
-1
@@ -3,8 +3,16 @@
|
|||||||
<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="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
+3
-4
@@ -1,14 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
container_name: moving-helper
|
container_name: moving-helper
|
||||||
|
image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest"
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
user: "${UID:-1000}:${GID:-1000}"
|
user: "1000:1000"
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-10000}:${PORT:-10000}"
|
- "127.0.0.1:${APP_PORT:-10000}:10000"
|
||||||
environment:
|
environment:
|
||||||
HOST: ${HOST:-0.0.0.0}
|
|
||||||
PORT: ${PORT:-10000}
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
|
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_DIR:-./data}:/app/data
|
- ${DATA_DIR:-./data}:/app/data
|
||||||
|
|||||||
+69
-13
@@ -1,27 +1,83 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
APP_DIR="__APP_DIR__"
|
||||||
cd "$PROJECT_ROOT"
|
DEFAULT_BACKUP_DIR="__BACKUP_DIR__"
|
||||||
|
ENV_FILE="$APP_DIR/.env"
|
||||||
|
|
||||||
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
|
require_command() {
|
||||||
echo "未找到 .env,先从 .env.example 复制一份:"
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
echo " cp .env.example .env"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
|
set -a
|
||||||
DATA_DIR=${DATA_DIR_VALUE:-./data}
|
. "$ENV_FILE"
|
||||||
DB_PATH="$DATA_DIR/app.db"
|
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
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
echo "未找到数据库文件:$DB_PATH"
|
echo "Database file not found: $DB_PATH" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p backups
|
mkdir -p "$BACKUP_DIR"
|
||||||
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
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"
|
cleanup() {
|
||||||
echo "备份已创建:$DESTINATION"
|
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
@@ -5,33 +5,39 @@ PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
|
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"
|
echo " cp .env.example .env"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
|
set -a
|
||||||
DATA_DIR=${DATA_DIR_VALUE:-./data}
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
DATA_DIR=${DATA_DIR:-./data}
|
||||||
|
APP_PORT=${APP_PORT:-10000}
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR"
|
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
|
if [ -d ".git" ]; then
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
else
|
else
|
||||||
echo "跳过:当前目录不是 git 仓库"
|
echo "Skipped: current directory is not a git repository"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[2/4] 构建并更新容器"
|
echo "[2/4] Pull and update containers"
|
||||||
docker compose up -d --build
|
docker compose pull web
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
echo "[3/4] 当前容器状态"
|
echo "[3/4] Current container status"
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
echo "[4/4] 最近日志"
|
echo "[4/4] Recent logs"
|
||||||
docker compose logs --tail=50 web
|
docker compose logs --tail=50 web
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "部署完成。应用默认地址:"
|
echo "Deployment complete. Default application URLs:"
|
||||||
echo " http://localhost:$(grep '^PORT=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || echo 10000)"
|
echo " https://${HOST_DOMAIN:-localhost}"
|
||||||
|
echo " Backend port mapping: localhost:$APP_PORT -> container:10000"
|
||||||
|
|
||||||
|
|||||||
Executable
+137
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,47 @@ def test_boxes_page_returns_200(client):
|
|||||||
assert "箱子" in response.text
|
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):
|
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",
|
||||||
@@ -137,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")
|
||||||
|
|
||||||
@@ -834,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
|
||||||
|
|
||||||
@@ -852,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
|
||||||
|
|
||||||
|
|
||||||
@@ -885,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user