Compare commits
11 Commits
ea73b0c165
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d39c1933b4 | |||
| 5aa87f60ad | |||
| 8fa3dace79 | |||
| c3ba361724 | |||
| 8d89caea0c | |||
| f315614657 | |||
| ef058765de | |||
| bda23909bf | |||
| e7a2719fa1 | |||
| 314fc16b98 | |||
| 4c4ff61fab |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
.env
|
||||||
|
.pytest_cache
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
backups
|
||||||
|
data
|
||||||
|
tests
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Runtime
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=10000
|
||||||
|
|
||||||
|
# In Docker, keep the database inside the mounted /app/data directory.
|
||||||
|
DATABASE_URL=sqlite:////app/data/app.db
|
||||||
|
|
||||||
|
# Host-side persistent data directory
|
||||||
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
# Container user mapping
|
||||||
|
UID=1000
|
||||||
|
GID=1000
|
||||||
|
|
||||||
@@ -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
|
||||||
+10
@@ -3,3 +3,13 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.pyc
|
*.pyc
|
||||||
data/*.db
|
data/*.db
|
||||||
|
|
||||||
|
# macOS generated files
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
._*
|
||||||
|
**/._*
|
||||||
|
.Spotlight-V100
|
||||||
|
**/.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
**/.Trashes
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY tests ./tests
|
|
||||||
|
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,17 @@
|
|||||||
- pytest / FastAPI TestClient
|
- pytest / FastAPI TestClient
|
||||||
- Docker / Docker Compose
|
- Docker / Docker Compose
|
||||||
|
|
||||||
项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD、单图上传能力和全局搜索,但仍然没有加入 OCR、AI 识别或其他扩展功能。
|
项目目标是小而稳、容易继续扩展。它不是企业平台,也不是复杂运维系统,重点是本地开发简单、容器部署稳定、数据持久化清楚、后续几个月后自己回来看也能快速接上。
|
||||||
|
|
||||||
|
## 当前已支持
|
||||||
|
|
||||||
|
- 固定 3 级结构:`Box -> Item -> SubItem`
|
||||||
|
- Box / Item / SubItem 基础 CRUD
|
||||||
|
- Box / Item / SubItem 单图上传、替换、删除、展示
|
||||||
|
- Box / Item / SubItem 全局搜索
|
||||||
|
- Docker / Compose 长期运行
|
||||||
|
- SQLite 数据持久化
|
||||||
|
- 基础自动化测试
|
||||||
|
|
||||||
## 当前数据模型
|
## 当前数据模型
|
||||||
|
|
||||||
@@ -36,30 +46,9 @@ Box
|
|||||||
└── SubItem
|
└── SubItem
|
||||||
```
|
```
|
||||||
|
|
||||||
## 当前已支持
|
|
||||||
|
|
||||||
目前已支持的基础能力:
|
|
||||||
|
|
||||||
- Box 列表、详情、新建、编辑、删除
|
|
||||||
- Item 新建、详情、编辑、删除
|
|
||||||
- SubItem 新建、编辑、删除
|
|
||||||
- Box / Item / SubItem 单张图片上传、替换、删除、展示
|
|
||||||
- Box / Item / SubItem 全局搜索
|
|
||||||
- `/` 重定向到 `/boxes`
|
|
||||||
- Jinja2 模板渲染
|
|
||||||
- 静态文件挂载
|
|
||||||
- SQLite 持久化
|
|
||||||
- Docker 长期运行
|
|
||||||
- 基础自动化测试
|
|
||||||
|
|
||||||
删除规则:
|
|
||||||
|
|
||||||
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
|
|
||||||
- 删除容器型 `Item` 时,会级联删除其下 `SubItem`
|
|
||||||
|
|
||||||
## 图片能力说明
|
## 图片能力说明
|
||||||
|
|
||||||
这一阶段的图片系统保持简单直接:
|
图片系统保持简单直接:
|
||||||
|
|
||||||
- `Box` 最多支持 1 张图片
|
- `Box` 最多支持 1 张图片
|
||||||
- `Item` 最多支持 1 张图片
|
- `Item` 最多支持 1 张图片
|
||||||
@@ -67,10 +56,7 @@ Box
|
|||||||
- 支持上传、替换、删除
|
- 支持上传、替换、删除
|
||||||
- 不支持多图
|
- 不支持多图
|
||||||
|
|
||||||
图片的主要用途是帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。
|
图片主要用于帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。它不是原图归档系统。
|
||||||
它不是一个原图归档系统。
|
|
||||||
|
|
||||||
### 图片处理方式
|
|
||||||
|
|
||||||
上传图片后,系统会使用 Pillow 做统一处理:
|
上传图片后,系统会使用 Pillow 做统一处理:
|
||||||
|
|
||||||
@@ -87,7 +73,7 @@ Box
|
|||||||
- `image_width`
|
- `image_width`
|
||||||
- `image_height`
|
- `image_height`
|
||||||
|
|
||||||
图片访问通过普通 HTTP 路由返回 JPEG 数据,例如:
|
图片访问路由例如:
|
||||||
|
|
||||||
- `/boxes/{id}/image`
|
- `/boxes/{id}/image`
|
||||||
- `/items/{id}/image`
|
- `/items/{id}/image`
|
||||||
@@ -95,10 +81,10 @@ Box
|
|||||||
|
|
||||||
## 全局搜索
|
## 全局搜索
|
||||||
|
|
||||||
当前已经支持一个轻量的全局搜索页:
|
当前支持一个轻量的全局搜索页:
|
||||||
|
|
||||||
- 路由:`/search`
|
- 路由:`/search`
|
||||||
- 使用 query parameter,例如:`/search?q=电源线`
|
- 方式:`GET /search?q=关键词`
|
||||||
|
|
||||||
搜索范围包括:
|
搜索范围包括:
|
||||||
|
|
||||||
@@ -109,13 +95,13 @@ Box
|
|||||||
- `SubItem.name`
|
- `SubItem.name`
|
||||||
- `SubItem.note`
|
- `SubItem.note`
|
||||||
|
|
||||||
当前使用 SQLite 上的简单模糊匹配完成搜索,不引入外部搜索引擎或复杂全文系统。
|
当前使用 SQLite 上的简单模糊匹配,不引入外部搜索引擎或复杂全文系统。
|
||||||
|
|
||||||
搜索结果会尽量帮助你快速定位:
|
搜索结果会显示:
|
||||||
|
|
||||||
- 显示对象类型:`Box / Item / SubItem`
|
- 对象类型:`Box / Item / SubItem`
|
||||||
- 显示名称和备注
|
- 名称和备注
|
||||||
- 显示归属路径
|
- 归属路径
|
||||||
- 对 `Item` 展示所属 `Box`
|
- 对 `Item` 展示所属 `Box`
|
||||||
- 对 `SubItem` 展示所属 `Item` 和 `Box`
|
- 对 `SubItem` 展示所属 `Item` 和 `Box`
|
||||||
- 如果对象已有图片,会显示一个小缩略图
|
- 如果对象已有图片,会显示一个小缩略图
|
||||||
@@ -124,7 +110,6 @@ Box
|
|||||||
|
|
||||||
这一阶段仍然没有实现以下内容:
|
这一阶段仍然没有实现以下内容:
|
||||||
|
|
||||||
- 搜索
|
|
||||||
- 多图上传
|
- 多图上传
|
||||||
- OCR
|
- OCR
|
||||||
- AI 识别物品
|
- AI 识别物品
|
||||||
@@ -149,14 +134,13 @@ Box
|
|||||||
│ ├── static
|
│ ├── static
|
||||||
│ │ └── style.css
|
│ │ └── style.css
|
||||||
│ └── templates
|
│ └── templates
|
||||||
│ ├── base.html
|
|
||||||
│ ├── boxes
|
|
||||||
│ ├── items
|
|
||||||
│ └── subitems
|
|
||||||
├── data
|
├── data
|
||||||
|
├── scripts
|
||||||
|
│ ├── backup_db.sh
|
||||||
|
│ └── deploy.sh
|
||||||
├── tests
|
├── tests
|
||||||
│ ├── conftest.py
|
├── .dockerignore
|
||||||
│ └── test_app.py
|
├── .env.example
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── pytest.ini
|
├── pytest.ini
|
||||||
@@ -166,17 +150,38 @@ Box
|
|||||||
|
|
||||||
## 轻量配置
|
## 轻量配置
|
||||||
|
|
||||||
项目通过环境变量支持以下配置项:
|
项目通过环境变量支持以下部署时真正需要关心的配置:
|
||||||
|
|
||||||
- `DATABASE_URL`
|
|
||||||
- `HOST`
|
- `HOST`
|
||||||
- `PORT`
|
- `PORT`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `DATA_DIR`
|
||||||
|
- `UID`
|
||||||
|
- `GID`
|
||||||
|
|
||||||
默认值:
|
推荐从示例文件开始:
|
||||||
|
|
||||||
- `DATABASE_URL=sqlite:///./data/app.db`
|
```bash
|
||||||
- `HOST=0.0.0.0`
|
cp .env.example .env
|
||||||
- `PORT=10000`
|
```
|
||||||
|
|
||||||
|
默认值如下:
|
||||||
|
|
||||||
|
```env
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=10000
|
||||||
|
DATABASE_URL=sqlite:////app/data/app.db
|
||||||
|
DATA_DIR=./data
|
||||||
|
UID=1000
|
||||||
|
GID=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本地开发默认数据库仍然是 `./data/app.db`
|
||||||
|
- Docker 内建议继续使用 `sqlite:////app/data/app.db`
|
||||||
|
- `DATA_DIR` 控制宿主机上的持久化目录
|
||||||
|
- `UID/GID` 用来让容器内文件权限更贴近宿主机用户
|
||||||
|
|
||||||
## 本地开发模式
|
## 本地开发模式
|
||||||
|
|
||||||
@@ -213,14 +218,33 @@ http://localhost:10000
|
|||||||
./data/app.db
|
./data/app.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker 部署模式
|
## Docker 运行方式
|
||||||
|
|
||||||
Docker / Compose 是这个项目面向长期运行环境的方式。
|
Docker / Compose 是这个项目面向长期运行环境的方式。
|
||||||
|
|
||||||
启动:
|
### 首次准备
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
cp .env.example .env
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动 / 更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f web
|
||||||
```
|
```
|
||||||
|
|
||||||
访问:
|
访问:
|
||||||
@@ -229,20 +253,129 @@ docker compose up --build
|
|||||||
http://localhost:10000
|
http://localhost:10000
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
### Compose 配置说明
|
||||||
|
|
||||||
|
当前 `docker-compose.yml` 保持尽量简单:
|
||||||
|
|
||||||
- 默认暴露 `10000` 端口
|
- 默认暴露 `10000` 端口
|
||||||
- `restart: unless-stopped`
|
- `restart: unless-stopped`
|
||||||
- 容器使用 `1000:1000` 运行
|
- 容器用户来自 `UID:GID`
|
||||||
- SQLite 文件持久化到宿主机 `./data/app.db`
|
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
|
||||||
- 容器重建不会丢失数据
|
- SQLite 默认写入 `/app/data/app.db`
|
||||||
|
|
||||||
备份时直接复制 SQLite 文件即可:
|
## 自动化部署
|
||||||
|
|
||||||
|
这个项目没有复杂 CI/CD,只提供一个适合家用项目的轻量部署脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
它会按顺序执行:
|
||||||
|
|
||||||
|
1. 检查 `.env`
|
||||||
|
2. 准备数据目录
|
||||||
|
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
|
||||||
|
4. 执行 `docker compose up -d --build`
|
||||||
|
5. 输出容器状态
|
||||||
|
6. 输出最近日志
|
||||||
|
|
||||||
|
这个脚本的目标不是做平台化发布,而是让“更新代码并刷新容器”变成一个稳定、可重复执行的动作。
|
||||||
|
|
||||||
|
如果你不想自动拉代码,也可以直接手动运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据持久化
|
||||||
|
|
||||||
|
当前 SQLite 文件默认会保存在:
|
||||||
|
|
||||||
|
- 宿主机:`./data/app.db`
|
||||||
|
- 容器内:`/app/data/app.db`
|
||||||
|
|
||||||
|
这是因为 `docker-compose.yml` 把:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
./data/app.db
|
${DATA_DIR:-./data}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
挂载到了容器内:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- 容器重建不会删除宿主机上的数据库文件
|
||||||
|
- 更新镜像不会导致 SQLite 数据丢失
|
||||||
|
- 只要保留 `DATA_DIR` 目录,数据就还在
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
### 最简单的备份方式
|
||||||
|
|
||||||
|
直接复制 SQLite 文件即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp data/app.db backups/app.db
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用附带脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
它会在 `backups/` 目录下生成一个带时间戳的副本。
|
||||||
|
|
||||||
|
### 最简单的恢复方式
|
||||||
|
|
||||||
|
停止容器后,把备份文件覆盖回去:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop
|
||||||
|
cp backups/app-YYYYMMDD-HHMMSS.db data/app.db
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见排查
|
||||||
|
|
||||||
|
### 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 +389,164 @@ python -m pytest
|
|||||||
当前测试覆盖包括:
|
当前测试覆盖包括:
|
||||||
|
|
||||||
- Box / Item / SubItem 基础 CRUD
|
- Box / Item / SubItem 基础 CRUD
|
||||||
- 404 返回
|
- 图片上传、替换、删除与错误路径
|
||||||
- 非容器 Item 不能创建 SubItem
|
- 全局搜索 name / note
|
||||||
- Box / Item 删除后的级联删除
|
- 创建后的重定向行为
|
||||||
- 图片上传、转换为 JPEG、缩放、读取、替换、删除
|
- 关键页面结构和 UX 文案
|
||||||
- 全局搜索 name / note,并展示对象类型与归属路径
|
|
||||||
- 无图片访问和非法图片上传等错误路径
|
## CI / CD
|
||||||
- 关键 POST 请求后的重定向行为
|
|
||||||
|
仓库现在包含两条基础自动化流程,文件位于:
|
||||||
|
|
||||||
|
- `.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 dataclasses import dataclass
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from fastapi import HTTPException, UploadFile
|
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
|
MAX_IMAGE_SIDE = 1600
|
||||||
@@ -22,6 +34,8 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
|
|||||||
if file is None or not file.filename:
|
if file is None or not file.filename:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_bytes = file.file.read()
|
raw_bytes = file.file.read()
|
||||||
if not raw_bytes:
|
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:
|
with Image.open(BytesIO(raw_bytes)) as source_image:
|
||||||
processed_image = _prepare_image(source_image)
|
processed_image = _prepare_image(source_image)
|
||||||
except UnidentifiedImageError as exc:
|
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
|
raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -42,7 +59,9 @@ def process_upload(file: UploadFile | None) -> ProcessedImage | None:
|
|||||||
|
|
||||||
|
|
||||||
def _prepare_image(source_image: Image.Image) -> ProcessedImage:
|
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))
|
prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
|
||||||
|
|
||||||
output = BytesIO()
|
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.convert("RGB")
|
||||||
|
|
||||||
return source_image.copy()
|
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
|
||||||
|
|||||||
+18
-2
@@ -82,6 +82,10 @@ def _image_response_or_404(target) -> Response:
|
|||||||
return Response(content=target.image_blob, media_type=target.image_mime_type)
|
return Response(content=target.image_blob, media_type=target.image_mime_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _wants_add_next(submit_action: str | None) -> bool:
|
||||||
|
return submit_action == "save_and_add_next"
|
||||||
|
|
||||||
|
|
||||||
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] = []
|
||||||
@@ -333,6 +337,7 @@ def create_app() -> FastAPI:
|
|||||||
note: str | None = Form(default=None),
|
note: str | None = Form(default=None),
|
||||||
quantity: str | None = Form(default=None),
|
quantity: str | None = Form(default=None),
|
||||||
is_container: str | None = Form(default=None),
|
is_container: str | None = Form(default=None),
|
||||||
|
submit_action: str | None = Form(default=None),
|
||||||
image_file: UploadFile | None = File(default=None),
|
image_file: UploadFile | None = File(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
@@ -348,7 +353,13 @@ def create_app() -> FastAPI:
|
|||||||
db.add(item)
|
db.add(item)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
if _wants_add_next(submit_action):
|
||||||
|
redirect_url = f"/boxes/{box.id}/items/new"
|
||||||
|
elif item.is_container:
|
||||||
|
redirect_url = f"/items/{item.id}"
|
||||||
|
else:
|
||||||
|
redirect_url = f"/boxes/{box.id}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
@app.get("/items/{item_id}")
|
@app.get("/items/{item_id}")
|
||||||
def show_item(item_id: int, request: Request, db: Session = Depends(get_db)):
|
def show_item(item_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
@@ -436,6 +447,7 @@ def create_app() -> FastAPI:
|
|||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
note: str | None = Form(default=None),
|
note: str | None = Form(default=None),
|
||||||
quantity: str | None = Form(default=None),
|
quantity: str | None = Form(default=None),
|
||||||
|
submit_action: str | None = Form(default=None),
|
||||||
image_file: UploadFile | None = File(default=None),
|
image_file: UploadFile | None = File(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
@@ -451,7 +463,11 @@ def create_app() -> FastAPI:
|
|||||||
db.add(subitem)
|
db.add(subitem)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(subitem)
|
db.refresh(subitem)
|
||||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
if _wants_add_next(submit_action):
|
||||||
|
redirect_url = f"/items/{item.id}/subitems/new"
|
||||||
|
else:
|
||||||
|
redirect_url = f"/items/{item.id}"
|
||||||
|
return RedirectResponse(url=redirect_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
@app.get("/subitems/{subitem_id}/image")
|
@app.get("/subitems/{subitem_id}/image")
|
||||||
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
|
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
+301
-18
@@ -7,9 +7,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 840px;
|
max-width: 1100px;
|
||||||
margin: 48px auto;
|
margin: 28px auto;
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
@@ -17,6 +17,8 @@ body {
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 1.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2,
|
h2,
|
||||||
@@ -59,18 +61,45 @@ button,
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background: #0b57d0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background: #eef3f8;
|
||||||
|
color: #1f2937;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background: #b42318;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover,
|
.button:hover,
|
||||||
button:hover {
|
button:hover,
|
||||||
opacity: 0.92;
|
.button:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
opacity: 0.94;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-nav {
|
.top-nav {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header,
|
.page-header,
|
||||||
@@ -82,18 +111,63 @@ button:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 16px;
|
padding: 14px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-box {
|
||||||
|
background: #e6f0ff;
|
||||||
|
color: #0b57d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-item {
|
||||||
|
background: #eef7e8;
|
||||||
|
color: #2f6b1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-container {
|
||||||
|
background: #fff1da;
|
||||||
|
color: #9a4d00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-subitem {
|
||||||
|
background: #f2ebff;
|
||||||
|
color: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -127,16 +201,147 @@ button:hover {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-image-compact {
|
||||||
|
max-width: 180px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.thumb-image {
|
.thumb-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 120px;
|
width: 64px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 0;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-thumb {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dense-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-item {
|
||||||
|
border-left: 4px solid #3d7a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row-subitem {
|
||||||
|
border-left: 4px solid #7b57c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 2px 10px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-card .row-meta-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-note {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
.meta,
|
||||||
.muted {
|
.muted {
|
||||||
color: #666;
|
color: #666;
|
||||||
@@ -153,18 +358,96 @@ button:hover {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-help {
|
||||||
|
margin-top: -4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-panel {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel {
|
||||||
|
border: 1px solid #d9e2f2;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f5f8fd;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-body {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-body:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.actions form {
|
.actions form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button {
|
@media (max-width: 720px) {
|
||||||
background: none;
|
.container {
|
||||||
border: none;
|
margin: 0;
|
||||||
color: #b42318;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 16px;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-button:hover {
|
.compact-row,
|
||||||
text-decoration: underline;
|
.compact-row-box {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-image,
|
||||||
|
.compact-thumb {
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,46 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
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;
|
||||||
|
if (event.target.type === "submit") return;
|
||||||
|
if (!event.target.closest("form")) return;
|
||||||
|
|
||||||
|
const focusable = Array.from(
|
||||||
|
event.target.form.querySelectorAll(
|
||||||
|
'input:not([type="hidden"]):not([type="submit"]):not([type="checkbox"]), textarea, select'
|
||||||
|
)
|
||||||
|
).filter((element) => !element.disabled);
|
||||||
|
|
||||||
|
const index = focusable.indexOf(event.target);
|
||||||
|
if (index === -1 || index === focusable.length - 1) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
focusable[index + 1].focus();
|
||||||
|
if (focusable[index + 1].select) {
|
||||||
|
focusable[index + 1].select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,29 +1,51 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/boxes">箱子</a>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>{{ "新建 Box" if not box else "编辑 Box" }}</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<div class="type-tag type-box">Box</div>
|
||||||
<h1>{{ page_title }}</h1>
|
<h1>{{ page_title }}</h1>
|
||||||
<a href="/boxes">返回箱子列表</a>
|
<p class="muted">
|
||||||
|
{% if box %}
|
||||||
|
你当前正在编辑一个顶层箱子。
|
||||||
|
{% else %}
|
||||||
|
你当前正在创建一个新的顶层箱子。
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||||
<label>
|
<section class="context-panel">
|
||||||
|
<div class="context-title">当前操作</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag type-box">Box</span>
|
||||||
|
<span>{{ "创建顶层箱子" if not box else "编辑顶层箱子" }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<label class="form-field">
|
||||||
名称
|
名称
|
||||||
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
|
<input type="text" name="name" value="{{ box.name if box else '' }}" required autofocus>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
房间
|
房间
|
||||||
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
|
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
状态
|
状态
|
||||||
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
|
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
备注
|
备注
|
||||||
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
|
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
图片
|
图片
|
||||||
<input type="file" name="image_file" accept="image/*">
|
<input type="file" name="image_file" accept="image/*">
|
||||||
</label>
|
</label>
|
||||||
@@ -33,7 +55,7 @@
|
|||||||
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
|
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="link-button"
|
class="button button-danger button-small"
|
||||||
formaction="/boxes/{{ box.id }}/image/delete"
|
formaction="/boxes/{{ box.id }}/image/delete"
|
||||||
formmethod="post"
|
formmethod="post"
|
||||||
>
|
>
|
||||||
@@ -41,6 +63,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit">{{ submit_label }}</button>
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="button button-primary">{{ submit_label }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>首页</span>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>箱子</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>箱子</h1>
|
<div class="type-tag type-box">Box</div>
|
||||||
|
<h1>箱子总览</h1>
|
||||||
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
|
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="button" href="/boxes/new">新建箱子</a>
|
<a class="button button-primary" href="/boxes/new">新建箱子</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if boxes %}
|
{% if boxes %}
|
||||||
<div class="stack">
|
<div class="overview-grid">
|
||||||
{% for box in boxes %}
|
{% for box in boxes %}
|
||||||
<section class="card">
|
<section
|
||||||
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
|
class="compact-row compact-row-box clickable-card overview-card overview-card-box"
|
||||||
<p class="meta">物品数:{{ box.items|length }}</p>
|
data-href="/boxes/{{ box.id }}"
|
||||||
{% if box.room %}<p>房间:{{ box.room }}</p>{% endif %}
|
tabindex="0"
|
||||||
{% if box.status %}<p>状态:{{ box.status }}</p>{% endif %}
|
role="link"
|
||||||
{% if box.note %}<p>{{ box.note }}</p>{% endif %}
|
aria-label="查看箱子 {{ box.name }}"
|
||||||
<div class="actions">
|
>
|
||||||
<a href="/boxes/{{ box.id }}">查看详情</a>
|
<div class="compact-main">
|
||||||
<a href="/boxes/{{ box.id }}/edit">编辑</a>
|
<div class="row-title-line">
|
||||||
|
<span class="type-tag type-box">Box</span>
|
||||||
|
<h2>{{ box.name }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="row-meta-grid">
|
||||||
|
<span>物品数:{{ box.items|length }}</span>
|
||||||
|
</div>
|
||||||
|
{% if box.note %}<p class="row-note">{{ box.note }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -28,7 +41,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p>还没有箱子。</p>
|
<p>还没有箱子。</p>
|
||||||
<a href="/boxes/new">创建第一个箱子</a>
|
<a class="button button-primary" href="/boxes/new">创建第一个箱子</a>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,57 +1,74 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/boxes">箱子</a>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>{{ box.name }}</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="type-tag type-box">Box</div>
|
||||||
<h1>{{ box.name }}</h1>
|
<h1>{{ box.name }}</h1>
|
||||||
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
|
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="/boxes">返回箱子列表</a>
|
<a class="button button-secondary button-small" href="/boxes">返回箱子列表</a>
|
||||||
<a href="/search">去搜索</a>
|
<a class="button button-secondary button-small" href="/search">去搜索</a>
|
||||||
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
<a class="button button-primary" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card detail-card detail-card-compact">
|
||||||
{% if box.image_blob %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
<div class="detail-meta-grid">
|
||||||
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
|
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
|
||||||
<p><strong>状态:</strong> {{ box.status 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">
|
<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">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stack">
|
<section class="stack">
|
||||||
<h2>物品</h2>
|
<div class="section-heading">
|
||||||
|
<h2>内部物品</h2>
|
||||||
|
<p class="muted">重点浏览区域,点击任意一行可进入物品详情。</p>
|
||||||
|
</div>
|
||||||
{% if box.items %}
|
{% if box.items %}
|
||||||
|
<div class="overview-grid">
|
||||||
{% for item in box.items %}
|
{% for item in box.items %}
|
||||||
<article class="card">
|
<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 %}
|
{% if item.image_blob %}
|
||||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image">
|
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image compact-thumb">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
|
<div class="compact-main">
|
||||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
<div class="row-title-line">
|
||||||
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
|
<span class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
|
||||||
{% if item.note %}<p><strong>备注:</strong> {{ item.note }}</p>{% endif %}
|
{{ "容器型 Item" if item.is_container else "Item" }}
|
||||||
<div class="actions">
|
</span>
|
||||||
<a href="/items/{{ item.id }}">查看详情</a>
|
<h3>{{ item.name }}</h3>
|
||||||
<a href="/items/{{ item.id }}/edit">编辑</a>
|
</div>
|
||||||
{% if item.is_container %}
|
<div class="row-meta-grid">
|
||||||
<a href="/items/{{ item.id }}">查看内部内容</a>
|
<span>数量:{{ item.quantity if item.quantity is not none else 1 }}</span>
|
||||||
{% endif %}
|
</div>
|
||||||
<form method="post" action="/items/{{ item.id }}/delete">
|
{% if item.note %}<p class="row-note">{{ item.note }}</p>{% endif %}
|
||||||
<button type="submit" class="link-button">删除</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p>这个箱子里还没有物品。</p>
|
<p>这个箱子里还没有物品。</p>
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/boxes">箱子</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/boxes/{{ box.id }}">{{ box.name }}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>{{ "新建 Item" if not item else "编辑 Item" }}</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
|
||||||
|
{{ "容器型 Item" if item and item.is_container else "Item" }}
|
||||||
|
</div>
|
||||||
<h1>{{ page_title }}</h1>
|
<h1>{{ page_title }}</h1>
|
||||||
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
|
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
|
||||||
|
<p class="muted">
|
||||||
|
{% if item %}
|
||||||
|
你当前正在编辑这个物品,并可决定它是否是一个小容器。
|
||||||
|
{% else %}
|
||||||
|
你当前正在往这个箱子里添加一个 Item,可选择它是普通物品还是小容器。
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/boxes/{{ box.id }}">返回箱子</a>
|
<a class="button button-secondary button-small" href="/boxes/{{ box.id }}">返回箱子</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||||
<label>
|
<section class="context-panel">
|
||||||
|
<div class="context-title">当前上下文</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag type-box">Box</span>
|
||||||
|
<span>{{ box.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag {{ 'type-container' if item and item.is_container else 'type-item' }}">
|
||||||
|
{{ "容器型 Item" if item and item.is_container else "Item" }}
|
||||||
|
</span>
|
||||||
|
<span>{{ "创建新的二级物品" if not item else "编辑当前二级物品" }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<label class="form-field">
|
||||||
名称
|
名称
|
||||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
|
<input type="text" name="name" value="{{ item.name if item else '' }}" required autofocus>
|
||||||
</label>
|
</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 '' }}">
|
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '1' }}">
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
||||||
这个物品本身是一个小容器
|
这个物品本身是一个小容器
|
||||||
</label>
|
</label>
|
||||||
<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>
|
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
图片
|
图片
|
||||||
<input type="file" name="image_file" accept="image/*">
|
<input type="file" name="image_file" accept="image/*">
|
||||||
</label>
|
</label>
|
||||||
@@ -36,7 +69,7 @@
|
|||||||
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
|
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="link-button"
|
class="button button-danger button-small"
|
||||||
formaction="/items/{{ item.id }}/image/delete"
|
formaction="/items/{{ item.id }}/image/delete"
|
||||||
formmethod="post"
|
formmethod="post"
|
||||||
>
|
>
|
||||||
@@ -44,6 +77,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit">{{ submit_label }}</button>
|
<div class="form-actions">
|
||||||
|
<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 button-secondary">
|
||||||
|
保存并添加下一个
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/boxes">箱子</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="type-tag {{ 'type-container' if item.is_container else 'type-item' }}">
|
||||||
|
{{ "容器型 Item" if item.is_container else "Item" }}
|
||||||
|
</div>
|
||||||
<h1>{{ item.name }}</h1>
|
<h1>{{ item.name }}</h1>
|
||||||
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a> 中</p>
|
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a> 中</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
|
<a class="button button-secondary button-small" href="/boxes/{{ item.box.id }}">返回箱子</a>
|
||||||
<a href="/search">去搜索</a>
|
<a class="button button-secondary button-small" href="/search">去搜索</a>
|
||||||
<a href="/items/{{ item.id }}/edit">编辑物品</a>
|
<a class="button button-secondary button-small" href="/items/{{ item.id }}/edit">编辑物品</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card detail-card detail-card-compact">
|
||||||
{% if item.image_blob %}
|
{% 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 %}
|
{% 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.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">
|
<div class="actions">
|
||||||
<form method="post" action="/items/{{ item.id }}/delete">
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -30,26 +41,39 @@
|
|||||||
{% if item.is_container %}
|
{% if item.is_container %}
|
||||||
<section class="stack">
|
<section class="stack">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>子物品</h2>
|
<div class="section-heading">
|
||||||
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
<h2>内部子物品</h2>
|
||||||
|
<p class="muted">当前容器里装的内容,点击任意一行可进入对应编辑上下文。</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
||||||
</div>
|
</div>
|
||||||
{% if item.subitems %}
|
{% if item.subitems %}
|
||||||
|
<div class="overview-grid">
|
||||||
{% for subitem in item.subitems %}
|
{% for subitem in item.subitems %}
|
||||||
<article class="card">
|
<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 %}
|
{% if subitem.image_blob %}
|
||||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
|
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image compact-thumb">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="compact-main">
|
||||||
|
<div class="row-title-line">
|
||||||
|
<span class="type-tag type-subitem">SubItem</span>
|
||||||
<h3>{{ subitem.name }}</h3>
|
<h3>{{ subitem.name }}</h3>
|
||||||
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
|
</div>
|
||||||
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
|
<div class="row-meta-grid">
|
||||||
<div class="actions">
|
<span>数量:{{ subitem.quantity if subitem.quantity is not none else 1 }}</span>
|
||||||
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
|
<span>上级容器:{{ item.name }}</span>
|
||||||
<form method="post" action="/subitems/{{ subitem.id }}/delete">
|
</div>
|
||||||
<button type="submit" class="link-button">删除</button>
|
{% if subitem.note %}<p class="row-note">备注:{{ subitem.note }}</p>{% endif %}
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<p>还没有子物品。</p>
|
<p>还没有子物品。</p>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
value="{{ query }}"
|
value="{{ query }}"
|
||||||
placeholder="例如:锅、电源线、冬衣、文件袋"
|
placeholder="例如:锅、电源线、冬衣、文件袋"
|
||||||
>
|
>
|
||||||
<button type="submit">搜索</button>
|
<button type="submit" class="button button-primary">搜索</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,60 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/boxes">箱子</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/items/{{ item.id }}">{{ item.name }}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<strong>{{ "新建 SubItem" if not subitem else "编辑 SubItem" }}</strong>
|
||||||
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="type-tag type-subitem">SubItem</div>
|
||||||
<h1>{{ page_title }}</h1>
|
<h1>{{ page_title }}</h1>
|
||||||
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
|
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
|
||||||
|
<p class="muted">
|
||||||
|
{% if subitem %}
|
||||||
|
你当前正在编辑一个最后一级内容。
|
||||||
|
{% else %}
|
||||||
|
你当前正在这个容器型 Item 下面添加最后一级内容。
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/items/{{ item.id }}">返回物品</a>
|
<a class="button button-secondary button-small" href="/items/{{ item.id }}">返回物品</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
<form method="post" action="{{ form_action }}" class="stack form-panel" enctype="multipart/form-data">
|
||||||
<label>
|
<section class="context-panel">
|
||||||
|
<div class="context-title">当前上下文</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag type-box">Box</span>
|
||||||
|
<span>{{ item.box.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag type-container">容器型 Item</span>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-body">
|
||||||
|
<span class="type-tag type-subitem">SubItem</span>
|
||||||
|
<span>{{ "创建最后一级内容" if not subitem else "编辑最后一级内容" }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<label class="form-field">
|
||||||
名称
|
名称
|
||||||
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
|
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required autofocus>
|
||||||
</label>
|
</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 '' }}">
|
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '1' }}">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
备注
|
备注
|
||||||
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label class="form-field">
|
||||||
图片
|
图片
|
||||||
<input type="file" name="image_file" accept="image/*">
|
<input type="file" name="image_file" accept="image/*">
|
||||||
</label>
|
</label>
|
||||||
@@ -32,7 +64,7 @@
|
|||||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
|
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="link-button"
|
class="button button-danger button-small"
|
||||||
formaction="/subitems/{{ subitem.id }}/image/delete"
|
formaction="/subitems/{{ subitem.id }}/image/delete"
|
||||||
formmethod="post"
|
formmethod="post"
|
||||||
>
|
>
|
||||||
@@ -40,6 +72,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit">{{ submit_label }}</button>
|
<div class="form-actions">
|
||||||
|
<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 button-secondary">
|
||||||
|
保存并添加下一个
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+5
-4
@@ -1,14 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
|
container_name: moving-helper
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
user: "1000:1000"
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-10000}:${PORT:-10000}"
|
- "${PORT:-10000}:${PORT:-10000}"
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: ${HOST:-0.0.0.0}
|
||||||
PORT: ${PORT:-10000}
|
PORT: ${PORT:-10000}
|
||||||
DATABASE_URL: sqlite:////app/data/app.db
|
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ${DATA_DIR:-./data}:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
ignore:'asyncio\.iscoroutinefunction' is deprecated and slated for removal in Python 3\.16; use inspect\.iscoroutinefunction\(\) instead:DeprecationWarning:fastapi\.routing
|
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
|
sqlalchemy==2.0.43
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pillow==11.2.1
|
pillow==11.2.1
|
||||||
|
requests==2.32.3
|
||||||
pytest==8.4.1
|
pytest==8.4.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
|||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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,先从 .env.example 复制一份:"
|
||||||
|
echo " cp .env.example .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
|
||||||
|
DATA_DIR=${DATA_DIR_VALUE:-./data}
|
||||||
|
DB_PATH="$DATA_DIR/app.db"
|
||||||
|
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "未找到数据库文件:$DB_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p backups
|
||||||
|
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||||
|
DESTINATION="backups/app-$TIMESTAMP.db"
|
||||||
|
|
||||||
|
cp "$DB_PATH" "$DESTINATION"
|
||||||
|
echo "备份已创建:$DESTINATION"
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/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,先从 .env.example 复制一份:"
|
||||||
|
echo " cp .env.example .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
|
||||||
|
DATA_DIR=${DATA_DIR_VALUE:-./data}
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
echo "[1/4] 拉取最新代码(如果当前目录是 git 仓库)"
|
||||||
|
if [ -d ".git" ]; then
|
||||||
|
git pull --ff-only
|
||||||
|
else
|
||||||
|
echo "跳过:当前目录不是 git 仓库"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[2/4] 构建并更新容器"
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "[3/4] 当前容器状态"
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
echo "[4/4] 最近日志"
|
||||||
|
docker compose logs --tail=50 web
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "部署完成。应用默认地址:"
|
||||||
|
echo " http://localhost:$(grep '^PORT=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || echo 10000)"
|
||||||
|
|
||||||
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())
|
||||||
+301
-6
@@ -2,7 +2,9 @@ from io import BytesIO
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import app.db as db_module
|
import app.db as db_module
|
||||||
|
import app.images as images_module
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
from app.models import Box, Item, SubItem
|
from app.models import Box, Item, SubItem
|
||||||
|
|
||||||
@@ -21,19 +23,28 @@ def create_item(
|
|||||||
quantity="2",
|
quantity="2",
|
||||||
is_container=False,
|
is_container=False,
|
||||||
image=None,
|
image=None,
|
||||||
|
submit_action="save",
|
||||||
):
|
):
|
||||||
data = {"name": name, "note": note, "quantity": quantity}
|
data = {"name": name, "note": note, "quantity": quantity, "submit_action": submit_action}
|
||||||
if is_container:
|
if is_container:
|
||||||
data["is_container"] = "on"
|
data["is_container"] = "on"
|
||||||
files = {"image_file": image} if image is not None else None
|
files = {"image_file": image} if image is not None else None
|
||||||
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
|
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3", image=None):
|
def create_subitem(
|
||||||
|
client,
|
||||||
|
item_id,
|
||||||
|
name="SubItem A",
|
||||||
|
note="Small",
|
||||||
|
quantity="3",
|
||||||
|
image=None,
|
||||||
|
submit_action="save",
|
||||||
|
):
|
||||||
files = {"image_file": image} if image is not None else None
|
files = {"image_file": image} if image is not None else None
|
||||||
return client.post(
|
return client.post(
|
||||||
f"/items/{item_id}/subitems",
|
f"/items/{item_id}/subitems",
|
||||||
data={"name": name, "note": note, "quantity": quantity},
|
data={"name": name, "note": note, "quantity": quantity, "submit_action": submit_action},
|
||||||
files=files,
|
files=files,
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
)
|
)
|
||||||
@@ -52,6 +63,19 @@ def make_image_upload(
|
|||||||
return (filename, output.getvalue(), "image/png")
|
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):
|
def read_jpeg_size(image_bytes):
|
||||||
with Image.open(BytesIO(image_bytes)) as image:
|
with Image.open(BytesIO(image_bytes)) as image:
|
||||||
return image.format, image.size
|
return image.format, image.size
|
||||||
@@ -79,6 +103,40 @@ def test_boxes_page_returns_200(client):
|
|||||||
assert "箱子" in response.text
|
assert "箱子" 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_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")
|
||||||
|
|
||||||
@@ -153,6 +211,21 @@ def test_box_detail_returns_200_when_box_exists(client, db_session):
|
|||||||
assert "Visible Box" in response.text
|
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):
|
def test_can_create_regular_item_under_box(client, db_session):
|
||||||
box = Box(name="Main Box")
|
box = Box(name="Main Box")
|
||||||
db_session.add(box)
|
db_session.add(box)
|
||||||
@@ -320,14 +393,13 @@ def test_post_redirects_are_reasonable(client, db_session):
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
item_response = create_item(client, box.id, name="Lamp")
|
item_response = create_item(client, box.id, name="Lamp")
|
||||||
item_id = int(item_response.headers["location"].split("/")[-1])
|
item = db_session.query(Item).one()
|
||||||
item = db_session.get(Item, item_id)
|
|
||||||
item.is_container = True
|
item.is_container = True
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
subitem_response = create_subitem(client, item.id, name="Bulb")
|
subitem_response = create_subitem(client, item.id, name="Bulb")
|
||||||
|
|
||||||
assert item_response.headers["location"] == f"/items/{item.id}"
|
assert item_response.headers["location"] == f"/boxes/{box.id}"
|
||||||
assert subitem_response.headers["location"] == f"/items/{item.id}"
|
assert subitem_response.headers["location"] == f"/items/{item.id}"
|
||||||
|
|
||||||
|
|
||||||
@@ -347,6 +419,21 @@ def test_can_upload_image_for_box_and_process_it(client, db_session):
|
|||||||
assert image_size == (1600, 800)
|
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):
|
def test_can_upload_image_for_item(client, db_session):
|
||||||
box = Box(name="Main Box")
|
box = Box(name="Main Box")
|
||||||
db_session.add(box)
|
db_session.add(box)
|
||||||
@@ -564,6 +651,24 @@ def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client
|
|||||||
assert updated_box.image_height is None
|
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):
|
def test_can_search_box_by_name(client, db_session):
|
||||||
box = Box(name="冬季衣物箱")
|
box = Box(name="冬季衣物箱")
|
||||||
db_session.add(box)
|
db_session.add(box)
|
||||||
@@ -707,3 +812,193 @@ def test_search_result_without_image_does_not_break_template(client, db_session)
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "无图物品" in response.text
|
assert "无图物品" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_box_page_shows_clear_context(client):
|
||||||
|
response = client.get("/boxes/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "新建 Box" in response.text
|
||||||
|
assert "创建顶层箱子" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_item_page_shows_clear_context_and_default_quantity(client, db_session):
|
||||||
|
box = Box(name="主卧箱")
|
||||||
|
db_session.add(box)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/boxes/{box.id}/items/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "新建 Item" in response.text
|
||||||
|
assert "主卧箱" in response.text
|
||||||
|
assert 'name="quantity"' in response.text
|
||||||
|
assert 'value="1"' in response.text
|
||||||
|
assert "这个物品本身是一个小容器" in response.text
|
||||||
|
assert "保存并添加下一个" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_subitem_page_shows_clear_context_and_default_quantity(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}/subitems/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "新建 SubItem" in response.text
|
||||||
|
assert "客厅箱" in response.text
|
||||||
|
assert "文件袋" in response.text
|
||||||
|
assert 'name="quantity"' in response.text
|
||||||
|
assert 'value="1"' in response.text
|
||||||
|
assert "保存并添加下一个" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client, db_session):
|
||||||
|
box = Box(name="厨房箱")
|
||||||
|
item = Item(name="锅", box=box, is_container=False)
|
||||||
|
db_session.add_all([box, item])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/boxes/{box.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Box" in response.text
|
||||||
|
assert "厨房箱" 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):
|
||||||
|
box = Box(name="书房箱")
|
||||||
|
item = Item(name="配件盒", box=box, is_container=True)
|
||||||
|
subitem = SubItem(name="转接头", parent_item=item)
|
||||||
|
db_session.add_all([box, item, subitem])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.get(f"/items/{item.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
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 "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):
|
||||||
|
box = Box(name="连续录入箱")
|
||||||
|
db_session.add(box)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = create_item(client, box.id, name="剪刀", is_container=False)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == f"/boxes/{box.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_regular_subitem_redirects_back_to_parent_item(client, db_session):
|
||||||
|
box = Box(name="配件箱")
|
||||||
|
item = Item(name="线材袋", box=box, is_container=True)
|
||||||
|
db_session.add_all([box, item])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = create_subitem(client, item.id, name="USB头")
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == f"/items/{item.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_box_redirects_to_new_box_detail(client):
|
||||||
|
response = create_box(client, name="新箱子")
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"].startswith("/boxes/")
|
||||||
|
assert not response.headers["location"].endswith("/items/new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_container_item_redirects_to_item_detail(client, db_session):
|
||||||
|
box = Box(name="子容器箱")
|
||||||
|
db_session.add(box)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = create_item(client, box.id, name="小收纳盒", is_container=True)
|
||||||
|
|
||||||
|
created_item = db_session.query(Item).one()
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == f"/items/{created_item.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_item_with_save_and_add_next_returns_to_same_new_item_context(client, db_session):
|
||||||
|
box = Box(name="快速录入箱")
|
||||||
|
db_session.add(box)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = create_item(
|
||||||
|
client,
|
||||||
|
box.id,
|
||||||
|
name="袜子",
|
||||||
|
is_container=False,
|
||||||
|
submit_action="save_and_add_next",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == f"/boxes/{box.id}/items/new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_subitem_with_save_and_add_next_returns_to_same_new_subitem_context(client, db_session):
|
||||||
|
box = Box(name="电子箱")
|
||||||
|
item = Item(name="配件袋", box=box, is_container=True)
|
||||||
|
db_session.add_all([box, item])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = create_subitem(
|
||||||
|
client,
|
||||||
|
item.id,
|
||||||
|
name="转接头",
|
||||||
|
submit_action="save_and_add_next",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == f"/items/{item.id}/subitems/new"
|
||||||
|
|||||||
@@ -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