Compare commits
18 Commits
314fc16b98
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| facf82c898 | |||
| d24c41d05f | |||
| 4955c87d78 | |||
| bfa554b407 | |||
| e5fee32098 | |||
| 22ea44d8cd | |||
| ed1e3311a5 | |||
| 49a5452141 | |||
| eb29f03b74 | |||
| d39c1933b4 | |||
| 5aa87f60ad | |||
| 8fa3dace79 | |||
| c3ba361724 | |||
| 8d89caea0c | |||
| f315614657 | |||
| ef058765de | |||
| bda23909bf | |||
| e7a2719fa1 |
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.env
|
||||
.pytest_cache
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.db
|
||||
backups
|
||||
data
|
||||
tests
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# This file is sourced by shell scripts. Keep values shell-compatible.
|
||||
|
||||
# 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.
|
||||
# Relative paths are resolved from APP_DIR after installation.
|
||||
DATA_DIR=./data
|
||||
|
||||
# Optional compose project name.
|
||||
COMPOSE_PROJECT_NAME=moving-helper
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: docker-image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: code.wanderingbadger.dev
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify tag commit is on main
|
||||
run: |
|
||||
git fetch origin main --no-tags
|
||||
TAG_COMMIT="${GITHUB_SHA}"
|
||||
MAIN_COMMIT="$(git rev-parse origin/main)"
|
||||
|
||||
if ! git merge-base --is-ancestor "$TAG_COMMIT" "$MAIN_COMMIT"; then
|
||||
echo "Tag ${GITHUB_REF_NAME} does not point to a commit reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: amd64,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY_HOST }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: |
|
||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
|
||||
@@ -0,0 +1,28 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run pytest
|
||||
run: pytest
|
||||
+12
@@ -2,4 +2,16 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
.env
|
||||
data/*.db
|
||||
|
||||
# macOS generated files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
._*
|
||||
**/._*
|
||||
.Spotlight-V100
|
||||
**/.Spotlight-V100
|
||||
.Trashes
|
||||
**/.Trashes
|
||||
.codex
|
||||
+1
-4
@@ -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
|
||||
@@ -13,10 +11,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY tests ./tests
|
||||
|
||||
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"]
|
||||
|
||||
@@ -10,7 +10,36 @@
|
||||
- pytest / FastAPI TestClient
|
||||
- Docker / Docker Compose
|
||||
|
||||
项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD、单图上传能力和全局搜索,但仍然没有加入 OCR、AI 识别或其他扩展功能。
|
||||
项目目标是小而稳、容易继续扩展。它不是企业平台,也不是复杂运维系统,重点是本地开发简单、容器部署稳定、数据持久化清楚、后续几个月后自己回来看也能快速接上。
|
||||
|
||||
## 当前已支持
|
||||
|
||||
- 固定 3 级结构:`Box -> Item -> SubItem`
|
||||
- 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 图标
|
||||
|
||||
## 当前数据模型
|
||||
|
||||
@@ -36,30 +65,9 @@ Box
|
||||
└── SubItem
|
||||
```
|
||||
|
||||
## 当前已支持
|
||||
|
||||
目前已支持的基础能力:
|
||||
|
||||
- Box 列表、详情、新建、编辑、删除
|
||||
- Item 新建、详情、编辑、删除
|
||||
- SubItem 新建、编辑、删除
|
||||
- Box / Item / SubItem 单张图片上传、替换、删除、展示
|
||||
- Box / Item / SubItem 全局搜索
|
||||
- `/` 重定向到 `/boxes`
|
||||
- Jinja2 模板渲染
|
||||
- 静态文件挂载
|
||||
- SQLite 持久化
|
||||
- Docker 长期运行
|
||||
- 基础自动化测试
|
||||
|
||||
删除规则:
|
||||
|
||||
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
|
||||
- 删除容器型 `Item` 时,会级联删除其下 `SubItem`
|
||||
|
||||
## 图片能力说明
|
||||
|
||||
这一阶段的图片系统保持简单直接:
|
||||
图片系统保持简单直接:
|
||||
|
||||
- `Box` 最多支持 1 张图片
|
||||
- `Item` 最多支持 1 张图片
|
||||
@@ -67,10 +75,7 @@ Box
|
||||
- 支持上传、替换、删除
|
||||
- 不支持多图
|
||||
|
||||
图片的主要用途是帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。
|
||||
它不是一个原图归档系统。
|
||||
|
||||
### 图片处理方式
|
||||
图片主要用于帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。它不是原图归档系统。
|
||||
|
||||
上传图片后,系统会使用 Pillow 做统一处理:
|
||||
|
||||
@@ -87,7 +92,7 @@ Box
|
||||
- `image_width`
|
||||
- `image_height`
|
||||
|
||||
图片访问通过普通 HTTP 路由返回 JPEG 数据,例如:
|
||||
图片访问路由例如:
|
||||
|
||||
- `/boxes/{id}/image`
|
||||
- `/items/{id}/image`
|
||||
@@ -95,10 +100,10 @@ Box
|
||||
|
||||
## 全局搜索
|
||||
|
||||
当前已经支持一个轻量的全局搜索页:
|
||||
当前支持一个轻量的全局搜索页:
|
||||
|
||||
- 路由:`/search`
|
||||
- 使用 query parameter,例如:`/search?q=电源线`
|
||||
- 方式:`GET /search?q=关键词`
|
||||
|
||||
搜索范围包括:
|
||||
|
||||
@@ -109,13 +114,13 @@ Box
|
||||
- `SubItem.name`
|
||||
- `SubItem.note`
|
||||
|
||||
当前使用 SQLite 上的简单模糊匹配完成搜索,不引入外部搜索引擎或复杂全文系统。
|
||||
当前使用 SQLite 上的简单模糊匹配,不引入外部搜索引擎或复杂全文系统。
|
||||
|
||||
搜索结果会尽量帮助你快速定位:
|
||||
搜索结果会显示:
|
||||
|
||||
- 显示对象类型:`Box / Item / SubItem`
|
||||
- 显示名称和备注
|
||||
- 显示归属路径
|
||||
- 对象类型:`Box / Item / SubItem`
|
||||
- 名称和备注
|
||||
- 归属路径
|
||||
- 对 `Item` 展示所属 `Box`
|
||||
- 对 `SubItem` 展示所属 `Item` 和 `Box`
|
||||
- 如果对象已有图片,会显示一个小缩略图
|
||||
@@ -124,7 +129,9 @@ Box
|
||||
|
||||
这一阶段仍然没有实现以下内容:
|
||||
|
||||
- 搜索
|
||||
- 离线访问
|
||||
- 离线缓存策略
|
||||
- 离线数据同步
|
||||
- 多图上传
|
||||
- OCR
|
||||
- AI 识别物品
|
||||
@@ -149,14 +156,16 @@ Box
|
||||
│ ├── static
|
||||
│ │ └── style.css
|
||||
│ └── templates
|
||||
│ ├── base.html
|
||||
│ ├── boxes
|
||||
│ ├── items
|
||||
│ └── subitems
|
||||
├── data
|
||||
├── scripts
|
||||
│ ├── backup_db.sh
|
||||
│ └── deploy.sh
|
||||
│ ├── install.sh
|
||||
│ └── nginx
|
||||
│ └── moving-helper.nginx.template
|
||||
├── tests
|
||||
│ ├── conftest.py
|
||||
│ └── test_app.py
|
||||
├── .dockerignore
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── pytest.ini
|
||||
@@ -166,17 +175,46 @@ Box
|
||||
|
||||
## 轻量配置
|
||||
|
||||
项目通过环境变量支持以下配置项:
|
||||
项目通过环境变量支持以下部署时真正需要关心的配置:
|
||||
|
||||
- `HOST_DOMAIN`
|
||||
- `SSL_PATH`
|
||||
- `APP_DIR`
|
||||
- `BACKUP_DIR`
|
||||
- `BACKUP_REMOTE`
|
||||
- `APP_PORT`
|
||||
- `DATA_DIR`
|
||||
- `DATABASE_URL`
|
||||
- `HOST`
|
||||
- `PORT`
|
||||
- `COMPOSE_PROJECT_NAME`
|
||||
|
||||
默认值:
|
||||
推荐从示例文件开始:
|
||||
|
||||
- `DATABASE_URL=sqlite:///./data/app.db`
|
||||
- `HOST=0.0.0.0`
|
||||
- `PORT=10000`
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
默认值如下:
|
||||
|
||||
```env
|
||||
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
|
||||
COMPOSE_PROJECT_NAME=moving-helper
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 容器内应用固定监听 `0.0.0.0:10000`
|
||||
- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口
|
||||
- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录
|
||||
- `DATA_DIR` 默认为相对路径 `./data`,安装后会相对于 `APP_DIR` 解析
|
||||
- `SSL_PATH` 由用户自行准备证书目录,安装脚本不会签发证书
|
||||
- `.env` 会被 shell 脚本直接 `source`,请保持 shell 兼容写法
|
||||
|
||||
## 本地开发模式
|
||||
|
||||
@@ -207,20 +245,76 @@ 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
|
||||
./data/app.db
|
||||
```
|
||||
|
||||
## Docker 部署模式
|
||||
## Docker 运行方式
|
||||
|
||||
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
|
||||
docker compose up --build
|
||||
cp .env.example .env
|
||||
mkdir -p data
|
||||
```
|
||||
|
||||
### 启动 / 更新:本地代码构建
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
这个模式会使用当前仓库代码重新构建镜像,适合本地开发、调试或尚未发布 tag 的阶段。
|
||||
|
||||
### 启动 / 更新:直接拉取已发布镜像
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 查看状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f web
|
||||
```
|
||||
|
||||
访问:
|
||||
@@ -229,20 +323,214 @@ docker compose up --build
|
||||
http://localhost:10000
|
||||
```
|
||||
|
||||
说明:
|
||||
### Compose 配置说明
|
||||
|
||||
- 默认暴露 `10000` 端口
|
||||
当前 `docker-compose.yml` 保持尽量简单:
|
||||
|
||||
- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
|
||||
- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000`
|
||||
- `restart: unless-stopped`
|
||||
- 容器使用 `1000:1000` 运行
|
||||
- SQLite 文件持久化到宿主机 `./data/app.db`
|
||||
- 容器重建不会丢失数据
|
||||
- 容器固定使用 `1000:1000`
|
||||
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
|
||||
- SQLite 默认写入 `/app/data/app.db`
|
||||
|
||||
备份时直接复制 SQLite 文件即可:
|
||||
因此同一个 compose 文件可以覆盖两种使用方式:
|
||||
|
||||
- 本地开发容器:`docker compose up -d --build`
|
||||
- 远端部署发布镜像:`docker compose pull && docker compose up -d`
|
||||
|
||||
## 自动化部署
|
||||
|
||||
这个项目现在额外提供一个面向本地网络环境的最小安装脚本:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
它会按顺序执行:
|
||||
|
||||
1. 检查 `.env`
|
||||
2. 准备数据目录
|
||||
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
|
||||
4. 执行 `docker compose pull web`
|
||||
5. 执行 `docker compose up -d`
|
||||
5. 输出容器状态
|
||||
6. 输出最近日志
|
||||
|
||||
这个脚本的目标不是做平台化发布,而是让“更新代码并刷新容器”变成一个稳定、可重复执行的动作。
|
||||
|
||||
如果你不想自动拉代码,也可以直接手动运行:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 数据持久化
|
||||
|
||||
当前 SQLite 文件默认会保存在:
|
||||
|
||||
- 宿主机:`./data/app.db`
|
||||
- 容器内:`/app/data/app.db`
|
||||
|
||||
这是因为 `docker-compose.yml` 把:
|
||||
|
||||
```text
|
||||
./data/app.db
|
||||
${DATA_DIR:-./data}
|
||||
```
|
||||
|
||||
挂载到了容器内:
|
||||
|
||||
```text
|
||||
/app/data
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- 容器重建不会删除宿主机上的数据库文件
|
||||
- 更新镜像不会导致 SQLite 数据丢失
|
||||
- 只要保留 `DATA_DIR` 目录,数据就还在
|
||||
|
||||
## 备份与恢复
|
||||
|
||||
### 备份机制
|
||||
|
||||
安装脚本会把渲染后的备份脚本安装到:
|
||||
|
||||
- `APP_DIR/backup_db.sh`
|
||||
|
||||
并为当前用户创建一条 cron:
|
||||
|
||||
- `10 2 * * *`
|
||||
|
||||
备份行为如下:
|
||||
|
||||
- 目标目录是 `BACKUP_DIR`
|
||||
- 备份文件名带时间戳,例如 `app-20260421-021000.db`
|
||||
- 最多保留 5 个本地备份
|
||||
- 如果 `BACKUP_REMOTE` 非空,会在本地备份完成后调用 `rclone copyto`
|
||||
|
||||
SQLite 一致性策略:
|
||||
|
||||
- 备份脚本优先使用 `sqlite3` 的 `.backup`
|
||||
- 不停容器
|
||||
- 不直接 `cp` 正在写入的数据库文件
|
||||
|
||||
这样可以在应用仍然运行时生成事务一致的快照,避免简单文件复制带来的损坏风险。
|
||||
|
||||
### 手动执行备份
|
||||
|
||||
如果你想手动触发一次备份:
|
||||
|
||||
```bash
|
||||
sh "$APP_DIR/backup_db.sh"
|
||||
```
|
||||
|
||||
如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行:
|
||||
|
||||
```bash
|
||||
./scripts/backup_db.sh
|
||||
```
|
||||
|
||||
前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板。
|
||||
|
||||
### 恢复大致步骤
|
||||
|
||||
停止容器后,把备份文件覆盖回去:
|
||||
|
||||
```bash
|
||||
cd "$APP_DIR"
|
||||
docker compose stop
|
||||
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. 查看容器日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f web
|
||||
```
|
||||
|
||||
### 2. 确认服务是否已启动
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
如果状态是 `Up`,通常说明容器已经跑起来了。
|
||||
|
||||
### 3. 确认端口映射
|
||||
|
||||
```bash
|
||||
docker compose port web 10000
|
||||
```
|
||||
|
||||
### 4. 确认数据库文件还在
|
||||
|
||||
```bash
|
||||
ls -lh data
|
||||
```
|
||||
|
||||
如果看到 `app.db`,说明宿主机持久化文件还在。
|
||||
|
||||
### 5. 容器更新后数据为什么没丢
|
||||
|
||||
因为数据库不放在镜像里,而是放在宿主机挂载目录 `DATA_DIR` 中。
|
||||
镜像更新只会替换应用代码和运行环境,不会覆盖这个宿主机目录。
|
||||
|
||||
## 测试
|
||||
|
||||
运行测试:
|
||||
@@ -256,10 +544,164 @@ python -m pytest
|
||||
当前测试覆盖包括:
|
||||
|
||||
- Box / Item / SubItem 基础 CRUD
|
||||
- 404 返回
|
||||
- 非容器 Item 不能创建 SubItem
|
||||
- Box / Item 删除后的级联删除
|
||||
- 图片上传、转换为 JPEG、缩放、读取、替换、删除
|
||||
- 全局搜索 name / note,并展示对象类型与归属路径
|
||||
- 无图片访问和非法图片上传等错误路径
|
||||
- 关键 POST 请求后的重定向行为
|
||||
- 图片上传、替换、删除与错误路径
|
||||
- 全局搜索 name / note
|
||||
- 创建后的重定向行为
|
||||
- 关键页面结构和 UX 文案
|
||||
|
||||
## CI / CD
|
||||
|
||||
仓库现在包含两条基础自动化流程,文件位于:
|
||||
|
||||
- `.github/workflows/test.yml`
|
||||
- `.github/workflows/docker-image.yml`
|
||||
|
||||
### CI:branch push 自动跑 pytest
|
||||
|
||||
`test.yml` 会在任意 branch 的 `push` 上执行:
|
||||
|
||||
1. checkout 代码
|
||||
2. 使用 Python `3.12`
|
||||
3. 安装 `requirements.txt`
|
||||
4. 运行 `pytest`
|
||||
|
||||
当前测试不依赖外部数据库服务。
|
||||
|
||||
测试使用 `tmp_path` 创建独立 SQLite 文件,并通过 `configure_database(...)` 切换到临时数据库,因此:
|
||||
|
||||
- 不会污染 `./data/app.db`
|
||||
- 不要求额外启动 Docker 或 Compose
|
||||
- 不要求额外配置测试环境变量
|
||||
|
||||
### CD:tag 发布 Docker image
|
||||
|
||||
`docker-image.yml` 会在推送符合 `v*` 格式的 tag 时触发,例如:
|
||||
|
||||
- `v1.0.0`
|
||||
- `v1.2.3`
|
||||
|
||||
workflow 会先校验该 tag 指向的提交是否可从 `origin/main` 到达;只有满足这个条件的 tag 才会继续构建并推送镜像。
|
||||
|
||||
镜像发布目标:
|
||||
|
||||
- Registry Host: `code.wanderingbadger.dev`
|
||||
- Image Name: `${{ github.repository }}`
|
||||
- Platforms:
|
||||
- `linux/amd64`
|
||||
- `linux/arm64`
|
||||
|
||||
推送的 tag 策略:
|
||||
|
||||
- `${tag}`
|
||||
- `latest`
|
||||
|
||||
例如仓库名为 `tliu93/2026-moving-helper`,打出 `v1.0.0` 后会推送:
|
||||
|
||||
```text
|
||||
code.wanderingbadger.dev/tliu93/2026-moving-helper:v1.0.0
|
||||
code.wanderingbadger.dev/tliu93/2026-moving-helper:latest
|
||||
```
|
||||
|
||||
### Actions / Gitea Secrets
|
||||
|
||||
需要在仓库的 Actions secrets 中配置:
|
||||
|
||||
- `REGISTRY_USERNAME`
|
||||
- `REGISTRY_TOKEN`
|
||||
|
||||
推荐含义:
|
||||
|
||||
- `REGISTRY_USERNAME`: Gitea 用户名
|
||||
- `REGISTRY_TOKEN`: 具备 Container Registry 推送权限的 Access Token
|
||||
|
||||
如果你的 Gitea 实例对 package / registry 权限做了单独控制,确保这个 token 至少具备对应仓库的镜像推送权限。
|
||||
|
||||
### 如何触发镜像发布
|
||||
|
||||
建议流程:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag v1.0.0
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
如果只想推送单个 tag:
|
||||
|
||||
```bash
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 本地手动构建镜像
|
||||
|
||||
单架构本地构建:
|
||||
|
||||
```bash
|
||||
docker build -t moving-helper:local .
|
||||
```
|
||||
|
||||
本地运行:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 10000:10000 \
|
||||
-e DATABASE_URL=sqlite:////app/data/app.db \
|
||||
moving-helper:local
|
||||
```
|
||||
|
||||
如果要模拟发布时的多架构构建,可以使用 buildx:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t code.wanderingbadger.dev/${USER}/2026-moving-helper:test \
|
||||
--load \
|
||||
.
|
||||
```
|
||||
|
||||
## 一次性 Notion 导入
|
||||
|
||||
项目内附带了一个一次性迁移脚本:
|
||||
|
||||
```bash
|
||||
python scripts/import_notion.py --dry-run
|
||||
python scripts/import_notion.py --apply
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这是一次性 migration / import 工具,不是长期同步功能
|
||||
- 运行时会交互要求输入:
|
||||
- Notion API token
|
||||
- Notion 页面完整 URL
|
||||
- `--dry-run` 只读取和解析,不写数据库
|
||||
- `--apply` 会真正写入当前 SQLite 数据库
|
||||
- 建议导入前先备份 `data/app.db`
|
||||
|
||||
### 当前支持的 Notion 结构映射
|
||||
|
||||
- `heading_2` -> `Box`
|
||||
- 某个 `heading_2` 下的一级 bullet -> `Item`
|
||||
- 如果一级 bullet 下还有二级 bullet:
|
||||
- 一级 bullet -> 容器型 `Item`
|
||||
- 二级 bullet -> `SubItem`
|
||||
|
||||
当前最大只处理到这个层级:
|
||||
|
||||
```text
|
||||
heading_2
|
||||
└── 一级 bullet
|
||||
└── 二级 bullet
|
||||
```
|
||||
|
||||
更深层级会在日志中提示,但不会继续扩展成无限树。
|
||||
|
||||
### 这一版不导入图片
|
||||
|
||||
这一版导入脚本:
|
||||
|
||||
- 不下载图片
|
||||
- 不导入图片
|
||||
- 遇到图片或其他媒体 block 时会提示已跳过
|
||||
|
||||
图片后续可以在应用里手动补录。
|
||||
|
||||
+52
-2
@@ -1,8 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
try:
|
||||
from pillow_heif import register_heif_opener
|
||||
|
||||
register_heif_opener()
|
||||
HEIF_SUPPORT_ENABLED = True
|
||||
except ImportError:
|
||||
HEIF_SUPPORT_ENABLED = False
|
||||
|
||||
|
||||
MAX_IMAGE_SIDE = 1600
|
||||
@@ -22,6 +34,8 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
|
||||
if file is None or not file.filename:
|
||||
return None
|
||||
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
|
||||
try:
|
||||
raw_bytes = file.file.read()
|
||||
if not raw_bytes:
|
||||
@@ -30,6 +44,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
|
||||
with Image.open(BytesIO(raw_bytes)) as source_image:
|
||||
processed_image = _prepare_image(source_image)
|
||||
except UnidentifiedImageError as exc:
|
||||
if suffix in {".heic", ".heif"}:
|
||||
processed_image = _process_heic_with_fallback(raw_bytes, suffix)
|
||||
return processed_image
|
||||
raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -42,7 +59,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
|
||||
|
||||
|
||||
def _prepare_image(source_image: Image.Image) -> ProcessedImage:
|
||||
prepared = _strip_metadata_and_convert(source_image)
|
||||
# Normalize orientation from EXIF before resizing or stripping metadata.
|
||||
prepared = ImageOps.exif_transpose(source_image)
|
||||
prepared = _strip_metadata_and_convert(prepared)
|
||||
prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
|
||||
|
||||
output = BytesIO()
|
||||
@@ -71,3 +90,34 @@ def _strip_metadata_and_convert(source_image: Image.Image) -> Image.Image:
|
||||
return source_image.convert("RGB")
|
||||
|
||||
return source_image.copy()
|
||||
|
||||
|
||||
def _process_heic_with_fallback(raw_bytes: bytes, suffix: str) -> ProcessedImage:
|
||||
if shutil.which("sips") is None:
|
||||
if HEIF_SUPPORT_ENABLED:
|
||||
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="当前环境无法处理 HEIC/HEIF,请先转换为 JPG 或 PNG 后再上传",
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
source_path = Path(temp_dir) / f"upload{suffix}"
|
||||
output_path = Path(temp_dir) / "converted.jpg"
|
||||
source_path.write_bytes(raw_bytes)
|
||||
|
||||
result = subprocess.run(
|
||||
["sips", "-s", "format", "jpeg", str(source_path), "--out", str(output_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0 or not output_path.exists():
|
||||
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片")
|
||||
|
||||
try:
|
||||
with Image.open(output_path) as converted_image:
|
||||
return _prepare_image(converted_image)
|
||||
except UnidentifiedImageError as exc:
|
||||
raise HTTPException(status_code=400, detail="HEIC/HEIF 图片处理失败,请尝试更换图片") from exc
|
||||
|
||||
+48
-2
@@ -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")
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from requests import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import init_db
|
||||
from app.models import Box, Item, SubItem
|
||||
|
||||
NOTION_VERSION = "2026-03-11"
|
||||
NOTION_API_BASE = "https://api.notion.com/v1"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedSubItem:
|
||||
name: str
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedItem:
|
||||
name: str
|
||||
note: str | None = None
|
||||
is_container: bool = False
|
||||
subitems: list[ParsedSubItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParsedBox:
|
||||
name: str
|
||||
note: str | None = None
|
||||
items: list[ParsedItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ImportSummary:
|
||||
boxes: list[ParsedBox]
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def box_count(self) -> int:
|
||||
return len(self.boxes)
|
||||
|
||||
@property
|
||||
def item_count(self) -> int:
|
||||
return sum(len(box.items) for box in self.boxes)
|
||||
|
||||
@property
|
||||
def container_item_count(self) -> int:
|
||||
return sum(1 for box in self.boxes for item in box.items if item.is_container)
|
||||
|
||||
@property
|
||||
def subitem_count(self) -> int:
|
||||
return sum(len(item.subitems) for box in self.boxes for item in box.items)
|
||||
|
||||
|
||||
class NotionClient:
|
||||
def __init__(self, token: str):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Notion-Version": NOTION_VERSION,
|
||||
}
|
||||
)
|
||||
|
||||
def list_block_children(self, block_id: str) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
next_cursor: str | None = None
|
||||
|
||||
while True:
|
||||
params = {"page_size": 100}
|
||||
if next_cursor:
|
||||
params["start_cursor"] = next_cursor
|
||||
|
||||
response = self.session.get(
|
||||
f"{NOTION_API_BASE}/blocks/{block_id}/children",
|
||||
params=params,
|
||||
timeout=30,
|
||||
)
|
||||
self._raise_for_status(response)
|
||||
payload = response.json()
|
||||
results.extend(payload.get("results", []))
|
||||
|
||||
if not payload.get("has_more"):
|
||||
break
|
||||
next_cursor = payload.get("next_cursor")
|
||||
|
||||
return results
|
||||
|
||||
def _raise_for_status(self, response: Response) -> None:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
message = response.text
|
||||
raise RuntimeError(f"Notion API 请求失败: {response.status_code} {message}") from exc
|
||||
|
||||
|
||||
def extract_page_id(page_url: str) -> str:
|
||||
cleaned = page_url.strip()
|
||||
parsed = urlparse(cleaned)
|
||||
candidates = [segment for segment in parsed.path.split("/") if segment]
|
||||
if parsed.fragment:
|
||||
candidates.append(parsed.fragment)
|
||||
|
||||
matches: list[str] = []
|
||||
pattern = re.compile(
|
||||
r"([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"
|
||||
)
|
||||
for candidate in candidates:
|
||||
matches.extend(pattern.findall(candidate))
|
||||
|
||||
if not matches:
|
||||
raise ValueError("无法从 Notion 页面 URL 中提取 page id")
|
||||
|
||||
raw = matches[-1].replace("-", "").lower()
|
||||
return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:]}"
|
||||
|
||||
|
||||
def fetch_page_blocks(token: str, page_id: str) -> list[dict[str, Any]]:
|
||||
client = NotionClient(token)
|
||||
return _fetch_block_tree(client, page_id)
|
||||
|
||||
|
||||
def _fetch_block_tree(client: NotionClient, block_id: str) -> list[dict[str, Any]]:
|
||||
blocks = client.list_block_children(block_id)
|
||||
for block in blocks:
|
||||
if block.get("has_children"):
|
||||
block["_children"] = _fetch_block_tree(client, block["id"])
|
||||
else:
|
||||
block["_children"] = []
|
||||
return blocks
|
||||
|
||||
|
||||
def parse_notion_blocks(blocks: list[dict[str, Any]]) -> ImportSummary:
|
||||
boxes: list[ParsedBox] = []
|
||||
warnings: list[str] = []
|
||||
current_box: ParsedBox | None = None
|
||||
|
||||
for block in blocks:
|
||||
block_type = block.get("type")
|
||||
|
||||
if block_type == "heading_2":
|
||||
heading_text = extract_block_text(block)
|
||||
if not heading_text:
|
||||
warnings.append("发现空的 heading_2,已跳过")
|
||||
continue
|
||||
current_box = ParsedBox(name=heading_text)
|
||||
boxes.append(current_box)
|
||||
continue
|
||||
|
||||
if block_type == "bulleted_list_item":
|
||||
if current_box is None:
|
||||
warnings.append(
|
||||
f"发现未归属到任何 heading_2 的一级 bullet:{extract_block_text(block) or '[空文本]'}"
|
||||
)
|
||||
continue
|
||||
parsed_item = _parse_item_block(block, warnings, level=1)
|
||||
if parsed_item is not None:
|
||||
current_box.items.append(parsed_item)
|
||||
continue
|
||||
|
||||
warnings.extend(_warning_for_unsupported_block(block, level=0))
|
||||
|
||||
return ImportSummary(boxes=boxes, warnings=warnings)
|
||||
|
||||
|
||||
def _parse_item_block(
|
||||
block: dict[str, Any],
|
||||
warnings: list[str],
|
||||
*,
|
||||
level: int,
|
||||
) -> ParsedItem | None:
|
||||
item_name = extract_block_text(block)
|
||||
if not item_name:
|
||||
warnings.append(f"发现空的 bullet(层级 {level}),已跳过")
|
||||
return None
|
||||
|
||||
child_blocks = block.get("_children", [])
|
||||
subitems: list[ParsedSubItem] = []
|
||||
|
||||
for child in child_blocks:
|
||||
child_type = child.get("type")
|
||||
if child_type == "bulleted_list_item":
|
||||
child_name = extract_block_text(child)
|
||||
if not child_name:
|
||||
warnings.append(f"发现空的二级 bullet(父项:{item_name}),已跳过")
|
||||
continue
|
||||
subitems.append(ParsedSubItem(name=child_name))
|
||||
|
||||
if child.get("_children"):
|
||||
warnings.append(
|
||||
f"发现超出支持层级的三级内容(父项:{item_name} -> 子项:{child_name}),已忽略更深层级"
|
||||
)
|
||||
for deep_child in child["_children"]:
|
||||
warnings.extend(_warning_for_unsupported_block(deep_child, level=3))
|
||||
continue
|
||||
|
||||
warnings.extend(_warning_for_unsupported_block(child, level=2, parent_name=item_name))
|
||||
|
||||
return ParsedItem(
|
||||
name=item_name,
|
||||
is_container=bool(subitems),
|
||||
subitems=subitems,
|
||||
)
|
||||
|
||||
|
||||
def _warning_for_unsupported_block(
|
||||
block: dict[str, Any],
|
||||
*,
|
||||
level: int,
|
||||
parent_name: str | None = None,
|
||||
) -> list[str]:
|
||||
block_type = block.get("type", "unknown")
|
||||
text = extract_block_text(block) or "[无文本]"
|
||||
prefix = f"层级 {level} block"
|
||||
if parent_name:
|
||||
prefix += f"(父项:{parent_name})"
|
||||
|
||||
if block_type in {"image", "file", "video", "audio", "pdf"}:
|
||||
return [f"{prefix} 类型 {block_type} 已跳过(这版不导入图片或媒体):{text}"]
|
||||
|
||||
return [f"{prefix} 类型 {block_type} 未按导入规则处理,已跳过:{text}"]
|
||||
|
||||
|
||||
def extract_block_text(block: dict[str, Any]) -> str:
|
||||
block_type = block.get("type")
|
||||
block_data = block.get(block_type, {}) if block_type else {}
|
||||
rich_text = block_data.get("rich_text", [])
|
||||
return "".join(part.get("plain_text", "") for part in rich_text).strip()
|
||||
|
||||
|
||||
def print_summary(summary: ImportSummary) -> None:
|
||||
print()
|
||||
print("解析结果摘要")
|
||||
print(f"- Box: {summary.box_count}")
|
||||
print(f"- Item: {summary.item_count}")
|
||||
print(f"- 其中容器型 Item: {summary.container_item_count}")
|
||||
print(f"- SubItem: {summary.subitem_count}")
|
||||
print(f"- Warnings: {len(summary.warnings)}")
|
||||
print()
|
||||
|
||||
for box in summary.boxes:
|
||||
container_names = [item.name for item in box.items if item.is_container]
|
||||
print(f"[Box] {box.name}")
|
||||
print(f" - Item 数量: {len(box.items)}")
|
||||
if container_names:
|
||||
print(f" - 容器型 Item: {', '.join(container_names)}")
|
||||
for item in box.items:
|
||||
if item.is_container:
|
||||
print(f" * {item.name} -> SubItem {len(item.subitems)} 个")
|
||||
|
||||
if summary.warnings:
|
||||
print()
|
||||
print("Warnings")
|
||||
for warning in summary.warnings:
|
||||
print(f"- {warning}")
|
||||
|
||||
|
||||
def apply_import(summary: ImportSummary, db: Session) -> dict[str, int]:
|
||||
init_db()
|
||||
|
||||
created_boxes = 0
|
||||
created_items = 0
|
||||
created_subitems = 0
|
||||
|
||||
for parsed_box in summary.boxes:
|
||||
box = Box(name=parsed_box.name, note=parsed_box.note)
|
||||
db.add(box)
|
||||
db.flush()
|
||||
created_boxes += 1
|
||||
|
||||
for parsed_item in parsed_box.items:
|
||||
item = Item(
|
||||
box=box,
|
||||
name=parsed_item.name,
|
||||
note=parsed_item.note,
|
||||
quantity=1,
|
||||
is_container=parsed_item.is_container,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
created_items += 1
|
||||
|
||||
for parsed_subitem in parsed_item.subitems:
|
||||
subitem = SubItem(
|
||||
parent_item=item,
|
||||
name=parsed_subitem.name,
|
||||
note=parsed_subitem.note,
|
||||
quantity=1,
|
||||
)
|
||||
db.add(subitem)
|
||||
created_subitems += 1
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"boxes": created_boxes,
|
||||
"items": created_items,
|
||||
"subitems": created_subitems,
|
||||
}
|
||||
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());
|
||||
});
|
||||
+140
-29
@@ -61,6 +61,13 @@ button,
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: #0b57d0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
@@ -69,9 +76,21 @@ button,
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: #b42318;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.button-small {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
button:hover {
|
||||
opacity: 0.92;
|
||||
button:hover,
|
||||
.button:focus-visible,
|
||||
button:focus-visible {
|
||||
opacity: 0.94;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -92,6 +111,10 @@ button:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -178,9 +201,14 @@ button:hover {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-image-compact {
|
||||
max-width: 180px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.thumb-image {
|
||||
display: block;
|
||||
width: 88px;
|
||||
width: 64px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
@@ -189,7 +217,7 @@ button:hover {
|
||||
}
|
||||
|
||||
.compact-thumb {
|
||||
flex: 0 0 88px;
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
|
||||
.dense-list {
|
||||
@@ -197,21 +225,58 @@ button:hover {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
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;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compact-row-box {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.overview-card-box {
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.compact-row-container {
|
||||
border-left: 4px solid #d98700;
|
||||
}
|
||||
@@ -227,6 +292,12 @@ button:hover {
|
||||
.compact-main h2,
|
||||
.compact-main h3 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.overview-card .compact-main h2,
|
||||
.overview-card .compact-main h3 {
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.row-title-line {
|
||||
@@ -240,24 +311,54 @@ button:hover {
|
||||
.row-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 4px 12px;
|
||||
gap: 2px 10px;
|
||||
color: #555;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.overview-card .row-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.row-note {
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
white-space: nowrap;
|
||||
.clickable-card {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.clickable-card:hover,
|
||||
.clickable-card:focus-within {
|
||||
border-color: #9fb8e8;
|
||||
box-shadow: 0 0 0 3px rgba(11, 87, 208, 0.08);
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.detail-card-compact {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 4px 12px;
|
||||
}
|
||||
|
||||
.detail-note {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.meta,
|
||||
@@ -324,18 +425,6 @@ button:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #b42318;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container {
|
||||
margin: 0;
|
||||
@@ -356,6 +445,28 @@ button:hover {
|
||||
|
||||
.thumb-image,
|
||||
.compact-thumb {
|
||||
width: 100px;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.detail-card-compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-1
@@ -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,30 @@
|
||||
{% 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;
|
||||
if (event.target.closest("a, button, form, input, textarea, select, label")) return;
|
||||
|
||||
window.location.href = card.dataset.href;
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
const card = event.target.closest(".clickable-card[data-href]");
|
||||
if (!card) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
if (event.target.closest("a, button, form, input, textarea, select, label")) return;
|
||||
|
||||
event.preventDefault();
|
||||
window.location.href = card.dataset.href;
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.target.tagName === "TEXTAREA") return;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
@@ -55,7 +55,7 @@
|
||||
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
|
||||
<button
|
||||
type="submit"
|
||||
class="link-button"
|
||||
class="button button-danger button-small"
|
||||
formaction="/boxes/{{ box.id }}/image/delete"
|
||||
formmethod="post"
|
||||
>
|
||||
@@ -63,6 +63,8 @@
|
||||
</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="button button-primary">{{ submit_label }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,36 +12,52 @@
|
||||
<h1>箱子总览</h1>
|
||||
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
|
||||
</div>
|
||||
<a class="button" href="/boxes/new">新建箱子</a>
|
||||
<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="dense-list">
|
||||
<div class="overview-grid">
|
||||
{% for box in boxes %}
|
||||
<section class="compact-row compact-row-box">
|
||||
<section
|
||||
class="compact-row compact-row-box clickable-card overview-card overview-card-box"
|
||||
data-href="/boxes/{{ box.id }}"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
aria-label="查看箱子 {{ box.name }}"
|
||||
>
|
||||
<div class="compact-main">
|
||||
<div class="row-title-line">
|
||||
<span class="type-tag type-box">Box</span>
|
||||
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
|
||||
<h2>{{ box.name }}</h2>
|
||||
</div>
|
||||
<div class="row-meta-grid">
|
||||
<span>物品数:{{ box.items|length }}</span>
|
||||
<span>房间:{{ box.room or '-' }}</span>
|
||||
<span>状态:{{ box.status or '-' }}</span>
|
||||
</div>
|
||||
{% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/boxes/{{ box.id }}">查看详情</a>
|
||||
<a href="/boxes/{{ box.id }}/edit">编辑</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>还没有箱子。</p>
|
||||
<a href="/boxes/new">创建第一个箱子</a>
|
||||
<a class="button button-primary" href="/boxes/new">创建第一个箱子</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,33 +13,44 @@
|
||||
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
<a href="/search">去搜索</a>
|
||||
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
||||
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
|
||||
<a class="button button-secondary button-small" href="/search">去搜索</a>
|
||||
<a class="button button-primary" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<section class="card detail-card detail-card-compact">
|
||||
{% if box.image_blob %}
|
||||
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
|
||||
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image detail-image-compact">
|
||||
{% endif %}
|
||||
<div class="detail-meta-grid">
|
||||
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
|
||||
<p><strong>状态:</strong> {{ box.status or '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ box.note or '-' }}</p>
|
||||
<p class="detail-note"><strong>备注:</strong> {{ box.note or '-' }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ box.id }}/edit">编辑箱子</a>
|
||||
<a class="button button-secondary button-small" href="/boxes/{{ box.id }}/edit">编辑箱子</a>
|
||||
<form method="post" action="/boxes/{{ box.id }}/delete">
|
||||
<button type="submit" class="link-button">删除箱子</button>
|
||||
<button type="submit" class="button button-danger button-small">删除箱子</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stack">
|
||||
<h2>物品</h2>
|
||||
<div class="section-heading">
|
||||
<h2>内部物品</h2>
|
||||
<p class="muted">重点浏览区域,点击任意一行可进入物品详情。</p>
|
||||
</div>
|
||||
{% if box.items %}
|
||||
<div class="dense-list">
|
||||
<div class="overview-grid">
|
||||
{% for item in box.items %}
|
||||
<article class="compact-row {{ 'compact-row-container' if item.is_container else 'compact-row-item' }}">
|
||||
<article
|
||||
class="compact-row clickable-card overview-card {{ 'compact-row-container' if item.is_container else 'compact-row-item' }}"
|
||||
data-href="/items/{{ item.id }}"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
aria-label="查看物品 {{ item.name }}"
|
||||
>
|
||||
{% if item.image_blob %}
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb">
|
||||
{% endif %}
|
||||
@@ -48,24 +59,13 @@
|
||||
<span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
|
||||
{{ "容器型 Item" if item.is_container else "Item" }}
|
||||
</span>
|
||||
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
|
||||
<h3>{{ item.name }}</h3>
|
||||
</div>
|
||||
<div class="row-meta-grid">
|
||||
<span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span>
|
||||
<span>是否容器:{{ "是" if item.is_container else "否" }}</span>
|
||||
</div>
|
||||
{% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/items/{{ item.id }}">查看详情</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑</a>
|
||||
{% if item.is_container %}
|
||||
<a href="/items/{{ item.id }}">查看内部内容</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/items/{{ item.id }}/delete">
|
||||
<button type="submit" class="link-button">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/boxes/{{ box.id }}">返回箱子</a>
|
||||
<a class="button button-secondary button-small" href="/boxes/{{ box.id }}">返回箱子</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
@@ -44,6 +44,24 @@
|
||||
名称
|
||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
图片
|
||||
<input type="file" name="image_file" accept="image/*">
|
||||
</label>
|
||||
{% if item and item.image_blob %}
|
||||
<section class="card">
|
||||
<p><strong>当前图片:</strong></p>
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
|
||||
<button
|
||||
type="submit"
|
||||
class="button button-danger button-small"
|
||||
formaction="/items/{{ item.id }}/image/delete"
|
||||
formmethod="post"
|
||||
>
|
||||
删除当前图片
|
||||
</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' }}">
|
||||
@@ -59,28 +77,10 @@
|
||||
备注
|
||||
<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/*">
|
||||
</label>
|
||||
{% if item and item.image_blob %}
|
||||
<section class="card">
|
||||
<p><strong>当前图片:</strong></p>
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
|
||||
<button
|
||||
type="submit"
|
||||
class="link-button"
|
||||
formaction="/items/{{ item.id }}/image/delete"
|
||||
formmethod="post"
|
||||
>
|
||||
删除当前图片
|
||||
</button>
|
||||
</section>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" name="submit_action" value="save">{{ submit_label }}</button>
|
||||
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
||||
{% if not item %}
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary">
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
|
||||
保存并添加下一个
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -17,22 +17,23 @@
|
||||
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a> 中</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
|
||||
<a href="/search">去搜索</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑物品</a>
|
||||
<a class="button button-secondary button-small" href="/boxes/{{ item.box.id }}">返回箱子</a>
|
||||
<a class="button button-secondary button-small" href="/search">去搜索</a>
|
||||
<a class="button button-secondary button-small" href="/items/{{ item.id }}/edit">编辑物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<section class="card detail-card detail-card-compact">
|
||||
{% if item.image_blob %}
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
|
||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image detail-image-compact">
|
||||
{% endif %}
|
||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
||||
<div class="detail-meta-grid">
|
||||
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ item.note or '-' }}</p>
|
||||
<p class="detail-note"><strong>备注:</strong> {{ item.note or '-' }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="/items/{{ item.id }}/delete">
|
||||
<button type="submit" class="link-button">删除物品</button>
|
||||
<button type="submit" class="button button-danger button-small">删除物品</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -40,13 +41,22 @@
|
||||
{% if item.is_container %}
|
||||
<section class="stack">
|
||||
<div class="page-header">
|
||||
<h2>子物品</h2>
|
||||
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
||||
<div class="section-heading">
|
||||
<h2>内部子物品</h2>
|
||||
<p class="muted">当前容器里装的内容,点击任意一行可进入对应编辑上下文。</p>
|
||||
</div>
|
||||
<a class="button button-primary" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
||||
</div>
|
||||
{% if item.subitems %}
|
||||
<div class="dense-list">
|
||||
<div class="overview-grid">
|
||||
{% for subitem in item.subitems %}
|
||||
<article class="compact-row compact-row-subitem">
|
||||
<article
|
||||
class="compact-row clickable-card compact-row-subitem overview-card"
|
||||
data-href="/subitems/{{ subitem.id }}/edit"
|
||||
tabindex="0"
|
||||
role="link"
|
||||
aria-label="查看子物品 {{ subitem.name }}"
|
||||
>
|
||||
{% if subitem.image_blob %}
|
||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb">
|
||||
{% endif %}
|
||||
@@ -60,13 +70,12 @@
|
||||
<span>上级容器:{{ item.name }}</span>
|
||||
</div>
|
||||
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
|
||||
<div class="actions">
|
||||
<form method="post" action="/subitems/{{ subitem.id }}/delete">
|
||||
<button type="submit" class="link-button">删除</button>
|
||||
<button type="submit" class="button button-danger button-small">删除子物品</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
value="{{ query }}"
|
||||
placeholder="例如:锅、电源线、冬衣、文件袋"
|
||||
>
|
||||
<button type="submit">搜索</button>
|
||||
<button type="submit" class="button button-primary">搜索</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/items/{{ item.id }}">返回物品</a>
|
||||
<a class="button button-secondary button-small" href="/items/{{ item.id }}">返回物品</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||
@@ -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/*">
|
||||
@@ -64,7 +56,7 @@
|
||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
|
||||
<button
|
||||
type="submit"
|
||||
class="link-button"
|
||||
class="button button-danger button-small"
|
||||
formaction="/subitems/{{ subitem.id }}/image/delete"
|
||||
formmethod="post"
|
||||
>
|
||||
@@ -72,10 +64,18 @@
|
||||
</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">{{ submit_label }}</button>
|
||||
<button type="submit" name="submit_action" value="save" class="button button-primary">{{ submit_label }}</button>
|
||||
{% if not subitem %}
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button-secondary">
|
||||
<button type="submit" name="submit_action" value="save_and_add_next" class="button button-secondary">
|
||||
保存并添加下一个
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
services:
|
||||
web:
|
||||
container_name: moving-helper
|
||||
image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest"
|
||||
build:
|
||||
context: .
|
||||
user: "1000:1000"
|
||||
ports:
|
||||
- "${PORT:-10000}:${PORT:-10000}"
|
||||
- "127.0.0.1:${APP_PORT:-10000}:10000"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: ${PORT:-10000}
|
||||
DATABASE_URL: sqlite:////app/data/app.db
|
||||
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ${DATA_DIR:-./data}:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
filterwarnings =
|
||||
ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing
|
||||
|
||||
@@ -4,5 +4,6 @@ jinja2==3.1.6
|
||||
sqlalchemy==2.0.43
|
||||
python-multipart==0.0.20
|
||||
pillow==11.2.1
|
||||
requests==2.32.3
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_DIR="__APP_DIR__"
|
||||
DEFAULT_BACKUP_DIR="__BACKUP_DIR__"
|
||||
ENV_FILE="$APP_DIR/.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
|
||||
|
||||
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 "Database file not found: $DB_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||
TMP_BACKUP="$BACKUP_DIR/.app-$TIMESTAMP.db.tmp"
|
||||
FINAL_BACKUP="$BACKUP_DIR/app-$TIMESTAMP.db"
|
||||
|
||||
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"
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if [ ! -f ".env" ] && [ -f ".env.example" ]; then
|
||||
echo ".env not found. Create it first from .env.example:"
|
||||
echo " cp .env.example .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
|
||||
DATA_DIR=${DATA_DIR:-./data}
|
||||
APP_PORT=${APP_PORT:-10000}
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
echo "[1/4] Pull latest code if this directory is a git repository"
|
||||
if [ -d ".git" ]; then
|
||||
git pull --ff-only
|
||||
else
|
||||
echo "Skipped: current directory is not a git repository"
|
||||
fi
|
||||
|
||||
echo "[2/4] Pull and update containers"
|
||||
docker compose pull web
|
||||
docker compose up -d
|
||||
|
||||
echo "[3/4] Current container status"
|
||||
docker compose ps
|
||||
|
||||
echo "[4/4] Recent logs"
|
||||
docker compose logs --tail=50 web
|
||||
|
||||
echo
|
||||
echo "Deployment complete. Default application URLs:"
|
||||
echo " https://${HOST_DOMAIN:-localhost}"
|
||||
echo " Backend port mapping: localhost:$APP_PORT -> container:10000"
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.db import SessionLocal, configure_database
|
||||
from app.notion_import import (
|
||||
apply_import,
|
||||
extract_page_id,
|
||||
fetch_page_blocks,
|
||||
parse_notion_blocks,
|
||||
print_summary,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="一次性导入 Notion 搬家记录到当前 SQLite 数据库")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只解析,不写数据库")
|
||||
parser.add_argument("--apply", action="store_true", help="真正写入数据库")
|
||||
args = parser.parse_args()
|
||||
|
||||
mode = _resolve_mode(args)
|
||||
|
||||
token = getpass.getpass("请输入 Notion API token: ").strip()
|
||||
if not token:
|
||||
print("未输入 token,已退出")
|
||||
return 1
|
||||
|
||||
page_url = input("请输入 Notion 页面完整 URL: ").strip()
|
||||
if not page_url:
|
||||
print("未输入页面 URL,已退出")
|
||||
return 1
|
||||
|
||||
try:
|
||||
page_id = extract_page_id(page_url)
|
||||
except ValueError as exc:
|
||||
print(f"页面 URL 无法识别: {exc}")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(f"正在读取 Notion page: {page_id}")
|
||||
try:
|
||||
blocks = fetch_page_blocks(token, page_id)
|
||||
except Exception as exc:
|
||||
print(f"读取 Notion page 失败: {exc}")
|
||||
return 1
|
||||
|
||||
print(f"已读取顶层及嵌套 blocks,总数约 {count_blocks(blocks)} 个")
|
||||
print("正在解析页面结构...")
|
||||
summary = parse_notion_blocks(blocks)
|
||||
print_summary(summary)
|
||||
|
||||
if mode == "dry-run":
|
||||
print()
|
||||
print("dry-run 完成,未写入数据库。")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print("这是一次性导入脚本,不建议在同一数据库上重复执行。")
|
||||
print("建议先备份当前 SQLite 数据库,再继续。")
|
||||
confirmed = input("确认执行导入?输入 yes 继续: ").strip().lower()
|
||||
if confirmed != "yes":
|
||||
print("已取消导入。")
|
||||
return 0
|
||||
|
||||
configure_database()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
counts = apply_import(summary, db)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
print(f"导入失败,已回滚: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print()
|
||||
print("导入完成")
|
||||
print(f"- 写入 Box: {counts['boxes']}")
|
||||
print(f"- 写入 Item: {counts['items']}")
|
||||
print(f"- 写入 SubItem: {counts['subitems']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_mode(args: argparse.Namespace) -> str:
|
||||
if args.apply and args.dry_run:
|
||||
raise SystemExit("请只选择一种模式:--dry-run 或 --apply")
|
||||
if args.apply:
|
||||
return "apply"
|
||||
return "dry-run"
|
||||
|
||||
|
||||
def count_blocks(blocks: list[dict]) -> int:
|
||||
total = 0
|
||||
for block in blocks:
|
||||
total += 1
|
||||
total += count_blocks(block.get("_children", []))
|
||||
return total
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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;
|
||||
}
|
||||
}
|
||||
+232
-1
@@ -2,7 +2,9 @@ from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db_module
|
||||
import app.images as images_module
|
||||
from PIL import Image
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.models import Box, Item, SubItem
|
||||
|
||||
@@ -61,6 +63,19 @@ def make_image_upload(
|
||||
return (filename, output.getvalue(), "image/png")
|
||||
|
||||
|
||||
def make_oriented_jpeg_upload(
|
||||
filename="portrait.jpg",
|
||||
size=(1600, 900),
|
||||
orientation=6,
|
||||
):
|
||||
image = Image.new("RGB", size, (20, 120, 220))
|
||||
exif = Image.Exif()
|
||||
exif[274] = orientation
|
||||
output = BytesIO()
|
||||
image.save(output, format="JPEG", exif=exif)
|
||||
return (filename, output.getvalue(), "image/jpeg")
|
||||
|
||||
|
||||
def read_jpeg_size(image_bytes):
|
||||
with Image.open(BytesIO(image_bytes)) as image:
|
||||
return image.format, image.size
|
||||
@@ -88,6 +103,128 @@ 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",
|
||||
note="易碎餐具和杯子",
|
||||
room="Kitchen",
|
||||
status="packed",
|
||||
)
|
||||
box.items.append(Item(name="Plate", is_container=False))
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Kitchen Box" in response.text
|
||||
assert "物品数:1" in response.text
|
||||
assert "易碎餐具和杯子" in response.text
|
||||
assert "房间:" not in response.text
|
||||
assert "状态:" not in response.text
|
||||
|
||||
|
||||
def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
|
||||
box = Box(name="No Note Box", note=None, room="Office", status="open")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No Note Box" in response.text
|
||||
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):
|
||||
response = create_box(client, name="Kitchen Box")
|
||||
|
||||
@@ -162,6 +299,21 @@ def test_box_detail_returns_200_when_box_exists(client, db_session):
|
||||
assert "Visible Box" in response.text
|
||||
|
||||
|
||||
def test_box_detail_item_cards_show_notes_without_note_placeholder_text(client, db_session):
|
||||
box = Box(name="Overview Box")
|
||||
box.items.append(Item(name="Accessory Pouch", note="充电器和转换头", is_container=True, quantity=2))
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/boxes/{box.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Accessory Pouch" in response.text
|
||||
assert "数量:2" in response.text
|
||||
assert "充电器和转换头" in response.text
|
||||
assert "有备注" not in response.text
|
||||
|
||||
|
||||
def test_can_create_regular_item_under_box(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
db_session.add(box)
|
||||
@@ -355,6 +507,21 @@ def test_can_upload_image_for_box_and_process_it(client, db_session):
|
||||
assert image_size == (1600, 800)
|
||||
|
||||
|
||||
def test_image_pipeline_applies_exif_orientation_before_saving(client, db_session):
|
||||
response = create_box(client, name="Portrait Box", image=make_oriented_jpeg_upload())
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
box = db_session.query(Box).filter_by(name="Portrait Box").one()
|
||||
assert box.image_blob is not None
|
||||
assert box.image_width == 900
|
||||
assert box.image_height == 1600
|
||||
|
||||
image_format, image_size = read_jpeg_size(box.image_blob)
|
||||
assert image_format == "JPEG"
|
||||
assert image_size == (900, 1600)
|
||||
|
||||
|
||||
def test_can_upload_image_for_item(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
db_session.add(box)
|
||||
@@ -572,6 +739,24 @@ def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client
|
||||
assert updated_box.image_height is None
|
||||
|
||||
|
||||
def test_heic_upload_returns_clear_error_if_heif_support_is_unavailable(monkeypatch):
|
||||
heic_file = UploadFile(filename="sample.heic", file=BytesIO(b"not-a-real-heic"))
|
||||
|
||||
def fake_open(*args, **kwargs):
|
||||
raise images_module.UnidentifiedImageError("cannot identify image file")
|
||||
|
||||
monkeypatch.setattr(images_module, "HEIF_SUPPORT_ENABLED", False)
|
||||
monkeypatch.setattr(images_module.Image, "open", fake_open)
|
||||
monkeypatch.setattr(images_module.shutil, "which", lambda command: None)
|
||||
|
||||
try:
|
||||
images_module.process_upload(heic_file)
|
||||
assert False, "Expected HEIC upload to raise HTTPException"
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
assert "HEIC/HEIF" in getattr(exc, "detail", "")
|
||||
|
||||
|
||||
def test_can_search_box_by_name(client, db_session):
|
||||
box = Box(name="冬季衣物箱")
|
||||
db_session.add(box)
|
||||
@@ -737,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
|
||||
|
||||
@@ -755,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
|
||||
|
||||
|
||||
@@ -769,7 +956,9 @@ def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client
|
||||
assert response.status_code == 200
|
||||
assert "Box" in response.text
|
||||
assert "厨房箱" in response.text
|
||||
assert "compact-row" in response.text
|
||||
assert "overview-grid" in response.text
|
||||
assert f'data-href="/items/{item.id}"' in response.text
|
||||
assert "是否容器" not in response.text
|
||||
|
||||
|
||||
def test_item_detail_page_renders_clear_hierarchy(client, db_session):
|
||||
@@ -785,6 +974,48 @@ def test_item_detail_page_renders_clear_hierarchy(client, db_session):
|
||||
assert "容器型 Item" in response.text
|
||||
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
|
||||
|
||||
|
||||
def test_box_detail_page_shows_primary_and_secondary_cta_buttons(client, db_session):
|
||||
box = Box(name="操作箱")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/boxes/{box.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "button button-primary" in response.text
|
||||
assert "button button-secondary" in response.text
|
||||
assert "删除箱子" in response.text
|
||||
|
||||
|
||||
def test_boxes_overview_uses_clickable_cards_without_detail_edit_buttons(client, db_session):
|
||||
box = Box(name="可点击箱子")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'data-href="/boxes/{box.id}"' in response.text
|
||||
assert "查看详情" not in response.text
|
||||
assert "编辑</a>" not in response.text
|
||||
|
||||
|
||||
def test_item_detail_page_shows_primary_action_for_adding_subitems(client, db_session):
|
||||
box = Box(name="容器箱")
|
||||
item = Item(name="收纳盒", box=box, is_container=True)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/items/{item.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "添加子物品" in response.text
|
||||
assert "button button-primary" in response.text
|
||||
|
||||
|
||||
def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
from app.models import Box, Item, SubItem
|
||||
from app.notion_import import (
|
||||
ImportSummary,
|
||||
ParsedBox,
|
||||
ParsedItem,
|
||||
ParsedSubItem,
|
||||
apply_import,
|
||||
extract_page_id,
|
||||
parse_notion_blocks,
|
||||
)
|
||||
|
||||
|
||||
def make_heading_2(text: str) -> dict:
|
||||
return {
|
||||
"type": "heading_2",
|
||||
"heading_2": {"rich_text": [{"plain_text": text}]},
|
||||
"_children": [],
|
||||
}
|
||||
|
||||
|
||||
def make_bullet(text: str, children: list[dict] | None = None) -> dict:
|
||||
return {
|
||||
"type": "bulleted_list_item",
|
||||
"bulleted_list_item": {"rich_text": [{"plain_text": text}]},
|
||||
"_children": children or [],
|
||||
}
|
||||
|
||||
|
||||
def make_image_block() -> dict:
|
||||
return {"type": "image", "image": {}, "_children": []}
|
||||
|
||||
|
||||
def test_extract_page_id_from_notion_url():
|
||||
url = "https://www.notion.so/workspace/My-Page-1234567890abcdef1234567890abcdef?pvs=4"
|
||||
|
||||
page_id = extract_page_id(url)
|
||||
|
||||
assert page_id == "12345678-90ab-cdef-1234-567890abcdef"
|
||||
|
||||
|
||||
def test_parse_heading_2_as_box():
|
||||
summary = parse_notion_blocks([make_heading_2("厨房箱")])
|
||||
|
||||
assert summary.box_count == 1
|
||||
assert summary.boxes[0].name == "厨房箱"
|
||||
|
||||
|
||||
def test_parse_first_level_bullet_as_item():
|
||||
blocks = [make_heading_2("客厅箱"), make_bullet("锅具")]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
assert summary.item_count == 1
|
||||
assert summary.boxes[0].items[0].name == "锅具"
|
||||
assert summary.boxes[0].items[0].is_container is False
|
||||
|
||||
|
||||
def test_parse_bullet_with_children_as_container_item_and_subitems():
|
||||
blocks = [
|
||||
make_heading_2("电子箱"),
|
||||
make_bullet("配件盒", children=[make_bullet("USB 线"), make_bullet("转接头")]),
|
||||
]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
item = summary.boxes[0].items[0]
|
||||
assert item.name == "配件盒"
|
||||
assert item.is_container is True
|
||||
assert [subitem.name for subitem in item.subitems] == ["USB 线", "转接头"]
|
||||
|
||||
|
||||
def test_parse_second_level_bullets_as_subitems():
|
||||
blocks = [
|
||||
make_heading_2("文件箱"),
|
||||
make_bullet("文件袋", children=[make_bullet("合同"), make_bullet("护照复印件")]),
|
||||
]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
assert summary.subitem_count == 2
|
||||
assert summary.boxes[0].items[0].subitems[1].name == "护照复印件"
|
||||
|
||||
|
||||
def test_parse_deeper_than_supported_levels_adds_warning():
|
||||
blocks = [
|
||||
make_heading_2("测试箱"),
|
||||
make_bullet(
|
||||
"外层袋",
|
||||
children=[make_bullet("内层物品", children=[make_bullet("更深一层")])],
|
||||
),
|
||||
]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
assert summary.container_item_count == 1
|
||||
assert any("超出支持层级" in warning for warning in summary.warnings)
|
||||
|
||||
|
||||
def test_parse_non_text_media_block_adds_skip_warning():
|
||||
blocks = [make_heading_2("照片箱"), make_image_block()]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
assert any("这版不导入图片或媒体" in warning for warning in summary.warnings)
|
||||
|
||||
|
||||
def test_dry_run_parse_does_not_write_database(db_session):
|
||||
blocks = [make_heading_2("厨房箱"), make_bullet("锅")]
|
||||
|
||||
summary = parse_notion_blocks(blocks)
|
||||
|
||||
assert summary.box_count == 1
|
||||
assert db_session.query(Box).count() == 0
|
||||
assert db_session.query(Item).count() == 0
|
||||
assert db_session.query(SubItem).count() == 0
|
||||
|
||||
|
||||
def test_apply_import_writes_expected_structure(db_session):
|
||||
summary = ImportSummary(
|
||||
boxes=[
|
||||
ParsedBox(
|
||||
name="主卧箱",
|
||||
items=[
|
||||
ParsedItem(name="衣服", is_container=False),
|
||||
ParsedItem(
|
||||
name="收纳袋",
|
||||
is_container=True,
|
||||
subitems=[ParsedSubItem(name="袜子"), ParsedSubItem(name="围巾")],
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
counts = apply_import(summary, db_session)
|
||||
|
||||
assert counts == {"boxes": 1, "items": 2, "subitems": 2}
|
||||
assert db_session.query(Box).count() == 1
|
||||
assert db_session.query(Item).count() == 2
|
||||
assert db_session.query(SubItem).count() == 2
|
||||
|
||||
container_item = db_session.query(Item).filter_by(name="收纳袋").one()
|
||||
assert container_item.is_container is True
|
||||
Reference in New Issue
Block a user