feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -2,3 +2,15 @@ DODO_DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/
|
||||
DODO_COOKIE_SECURE=true
|
||||
DODO_SESSION_DAYS=30
|
||||
DODO_TRUSTED_PROXIES=127.0.0.1
|
||||
|
||||
# Task attachment storage and per-file upload limit.
|
||||
DODO_ATTACHMENT_DIR=./data/attachments
|
||||
DODO_ATTACHMENT_MAX_MB=20
|
||||
|
||||
# Complete ZIP v2 backup limits and preflight staging.
|
||||
# Keep the staging directory and attachment directory on storage with enough free space.
|
||||
DODO_BACKUP_MAX_ARCHIVE_MB=256
|
||||
DODO_BACKUP_MAX_PENDING_PER_USER=3
|
||||
DODO_BACKUP_MAX_STAGED_MB_PER_USER=768
|
||||
DODO_BACKUP_PREFLIGHT_TTL_SECONDS=900
|
||||
DODO_BACKUP_STAGING_DIR=./data/backup-staging
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
frontend/playwright-runtime/
|
||||
.DS_Store
|
||||
*.db
|
||||
uploads/
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
# dodo
|
||||
|
||||
一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用。
|
||||
一个自托管、移动端友好的任务与生活管理 PWA,数据由自己掌控。
|
||||
|
||||
## 第一阶段能力
|
||||
## 当前主要能力
|
||||
|
||||
- 首次初始化管理员
|
||||
- 用户名密码登录,Cookie Session
|
||||
- 文件夹、清单、任务基础 CRUD
|
||||
- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年和自定义重复(间隔、星期、月日期、次数或截止日期)
|
||||
- 收集箱系统清单
|
||||
- 习惯打卡与倒数纪念日
|
||||
- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复
|
||||
- Vue 3 + PWA 应用外壳
|
||||
- 手账生活感浅色 UI
|
||||
- 文件夹、收集箱与自定义清单;清单归档后保留任务归属,恢复后原样可见
|
||||
- 任务与一层子任务、优先级、Markdown 备注、日期/时间、回收站、拖拽排序
|
||||
- RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖
|
||||
- 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息
|
||||
- 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档
|
||||
- 倒数日、纪念日、生日及公历/农历重复
|
||||
- Markdown 备忘录及软删除/恢复
|
||||
- 任务附件、登录设备管理与审计日志
|
||||
- 完整 ZIP v2 备份(含附件字节、manifest 与 SHA-256)及预检后合并/替换恢复
|
||||
- 兼容旧版 CSV / JSON v1 恢复
|
||||
|
||||
## 界面约定
|
||||
|
||||
- 弹层统一使用 `AppSheet` / `AppDialog`,共享遮罩、焦点陷阱、Escape、背景 inert 和嵌套栈行为
|
||||
- 设置页按“数据、账户与安全、登录设备、活动、危险操作”连续分组
|
||||
- 手机底栏直接进入今天、习惯、倒数日、设置,不再使用“更多”中转
|
||||
- 新增入口是普通的圆形 Plus FAB,共用于任务、习惯、倒数日和备忘录
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
|
||||
- Backend: FastAPI + SQLAlchemy 2 Async
|
||||
- DB: PostgreSQL(测试环境使用 SQLite)
|
||||
- Backend: FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
|
||||
- DB: PostgreSQL(测试使用 SQLite)
|
||||
- Package: uv + pnpm
|
||||
|
||||
## 本地开发
|
||||
@@ -33,18 +41,16 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
```bash
|
||||
DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo
|
||||
DODO_COOKIE_SECURE=false
|
||||
DODO_SESSION_DAYS=30
|
||||
```
|
||||
配置项见 [`.env.example`](.env.example),API 与数据合同见 [`docs/api.md`](docs/api.md) 和 [`docs/data-model.md`](docs/data-model.md)。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
uv run pytest -q
|
||||
uv run ruff check backend tests
|
||||
cd frontend && pnpm run build
|
||||
uv run pytest -q
|
||||
cd frontend
|
||||
pnpm test
|
||||
pnpm build
|
||||
cd ..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
|
||||
MAX_ENTRIES = 10_000
|
||||
MAX_COMPRESSION_RATIO = 200
|
||||
MAX_METADATA_ENTRY_BYTES = 16 * 1024 * 1024
|
||||
_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StagedBlob:
|
||||
archive_path: str
|
||||
staging_path: Path
|
||||
size: int
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedArchive:
|
||||
backup_id: UUID
|
||||
archive_sha256: str
|
||||
entities: dict[str, list[dict]]
|
||||
files: dict[str, StagedBlob]
|
||||
staging_dir: Path
|
||||
|
||||
|
||||
|
||||
def backup_error(code: str, message: str, status_code: int = 422) -> HTTPException:
|
||||
return HTTPException(status_code, {"code": code, "message": message})
|
||||
|
||||
|
||||
|
||||
def canonical_json(value) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
|
||||
def build_archive_to_path(
|
||||
output_path: Path, backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, Path]
|
||||
) -> None:
|
||||
entity_entries = {f"data/{name}.json": canonical_json(rows) for name, rows in entities.items()}
|
||||
checksums = {name: hashlib.sha256(data).hexdigest() for name, data in entity_entries.items()}
|
||||
for name, path in files.items():
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
checksums[name] = digest.hexdigest()
|
||||
manifest = {
|
||||
"format": "dodo-backup", "version": 2, "backup_id": str(backup_id),
|
||||
"entities": {name: len(rows) for name, rows in entities.items()}, "checksums": checksums,
|
||||
}
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
archive.writestr("manifest.json", canonical_json(manifest))
|
||||
for name, data in entity_entries.items():
|
||||
archive.writestr(name, data)
|
||||
for name, path in files.items():
|
||||
archive.write(path, name)
|
||||
|
||||
|
||||
|
||||
def build_archive(backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, bytes]) -> bytes:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
file_paths = {}
|
||||
for index, (name, data) in enumerate(files.items()):
|
||||
path = root / str(index)
|
||||
path.write_bytes(data)
|
||||
file_paths[name] = path
|
||||
output = root / "backup.zip"
|
||||
build_archive_to_path(output, backup_id, entities, file_paths)
|
||||
return output.read_bytes()
|
||||
|
||||
|
||||
|
||||
def _safe_name(name: str) -> bool:
|
||||
path = PurePosixPath(name)
|
||||
return bool(name) and not name.startswith("/") and "\\" not in name and ".." not in path.parts
|
||||
|
||||
|
||||
|
||||
def _read_metadata(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
||||
if info.file_size > MAX_METADATA_ENTRY_BYTES:
|
||||
raise backup_error("backup_size_invalid", "备份元数据过大")
|
||||
with archive.open(info) as source:
|
||||
data = source.read(MAX_METADATA_ENTRY_BYTES + 1)
|
||||
if len(data) > MAX_METADATA_ENTRY_BYTES:
|
||||
raise backup_error("backup_size_invalid", "备份元数据过大")
|
||||
return data
|
||||
|
||||
|
||||
|
||||
def _stream_blob(archive: zipfile.ZipFile, info: zipfile.ZipInfo, destination: Path) -> tuple[int, str]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with archive.open(info) as source, destination.open("xb") as output:
|
||||
while chunk := source.read(_CHUNK_SIZE):
|
||||
size += len(chunk)
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
return size, digest.hexdigest()
|
||||
|
||||
|
||||
|
||||
def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES) -> ParsedArchive:
|
||||
size = path.stat().st_size
|
||||
if size <= 0 or size > max_archive_bytes:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
staging_dir = Path(tempfile.mkdtemp(prefix="dodo-backup-blobs-"))
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
infos = archive.infolist()
|
||||
names = [item.filename for item in infos]
|
||||
if len(infos) > MAX_ENTRIES:
|
||||
raise backup_error("backup_too_many_entries", "备份条目过多")
|
||||
if len(names) != len(set(names)):
|
||||
raise backup_error("backup_duplicate_entry", "备份包含重复条目")
|
||||
if any(not _safe_name(item.filename) or item.is_dir() or item.flag_bits & 1 for item in infos):
|
||||
raise backup_error("backup_unsafe_path", "备份包含不安全路径")
|
||||
if "manifest.json" not in names:
|
||||
raise backup_error("backup_manifest_missing", "备份缺少 manifest")
|
||||
if sum(item.file_size for item in infos) > max_archive_bytes:
|
||||
raise backup_error("backup_size_invalid", "备份解压后过大")
|
||||
if any(item.file_size and item.compress_size == 0 for item in infos):
|
||||
raise backup_error("backup_compression_invalid", "备份压缩比异常")
|
||||
if any(item.compress_size and item.file_size / item.compress_size > MAX_COMPRESSION_RATIO for item in infos):
|
||||
raise backup_error("backup_compression_invalid", "备份压缩比异常")
|
||||
by_name = {item.filename: item for item in infos}
|
||||
manifest = json.loads(_read_metadata(archive, by_name["manifest.json"]))
|
||||
if manifest.get("format") != "dodo-backup" or manifest.get("version") != 2:
|
||||
raise backup_error("backup_version_unsupported", "不支持的备份版本")
|
||||
backup_id = UUID(manifest["backup_id"])
|
||||
checksums = manifest["checksums"]
|
||||
declared_entities = manifest["entities"]
|
||||
if not isinstance(checksums, dict) or not isinstance(declared_entities, dict):
|
||||
raise TypeError
|
||||
content_names = set(names) - {"manifest.json"}
|
||||
if set(checksums) != content_names:
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
|
||||
entities: dict[str, list[dict]] = {}
|
||||
files: dict[str, StagedBlob] = {}
|
||||
for index, name in enumerate(sorted(content_names)):
|
||||
info = by_name[name]
|
||||
if name.startswith("data/") and name.endswith(".json"):
|
||||
data = _read_metadata(archive, info)
|
||||
actual_digest = hashlib.sha256(data).hexdigest()
|
||||
value = json.loads(data)
|
||||
if not isinstance(value, list) or any(not isinstance(row, dict) for row in value):
|
||||
raise ValueError
|
||||
entities[name[5:-5]] = value
|
||||
else:
|
||||
blob_path = staging_dir / str(index)
|
||||
blob_size, actual_digest = _stream_blob(archive, info, blob_path)
|
||||
files[name] = StagedBlob(name, blob_path, blob_size, actual_digest)
|
||||
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
|
||||
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||
if set(declared_entities) != set(entities):
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
|
||||
except HTTPException:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
raise
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
raise backup_error("backup_invalid_zip", "无效的 ZIP 备份") from exc
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||
raise backup_error("backup_manifest_invalid", "无效的备份 manifest 或数据") from exc
|
||||
return ParsedArchive(backup_id, digest.hexdigest(), entities, files, staging_dir)
|
||||
|
||||
|
||||
|
||||
def parse_archive(content: bytes) -> ParsedArchive:
|
||||
if not content or len(content) > MAX_ARCHIVE_BYTES:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||
with tempfile.NamedTemporaryFile() as staged:
|
||||
staged.write(content)
|
||||
staged.flush()
|
||||
return parse_archive_path(Path(staged.name))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Legacy JSON/CSV backup compatibility remains in backend.mvp.
|
||||
|
||||
The v2 ZIP implementation is isolated in this package. This marker module documents
|
||||
that the old endpoints intentionally remain available during the gradual migration.
|
||||
"""
|
||||
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import shutil
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from backend.auth import current_user, hash_token
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_db
|
||||
from backend.models import BackupPreflight, Countdown, User, new_id, utcnow
|
||||
|
||||
from .archive import MAX_ARCHIVE_BYTES as ARCHIVE_DEFAULT_LIMIT
|
||||
from .archive import backup_error, parse_archive_path
|
||||
from .schemas import RestoreRequest
|
||||
from .service import BackupRepairPending, export_v2, restore_v2, validate_archive
|
||||
|
||||
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
|
||||
MAX_ARCHIVE_BYTES = ARCHIVE_DEFAULT_LIMIT
|
||||
MAX_PENDING_PREFLIGHTS_PER_USER = 3
|
||||
_READ_CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def _staging_root() -> Path:
|
||||
root = Path(get_settings().backup_staging_dir).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _safe_staging_path(value: str) -> Path:
|
||||
root = _staging_root()
|
||||
path = Path(value).resolve()
|
||||
if path.parent != root:
|
||||
raise backup_error("backup_preflight_invalid", "预检暂存无效", 409)
|
||||
return path
|
||||
|
||||
|
||||
def _path_size(path: str | None) -> int:
|
||||
if not path:
|
||||
return 0
|
||||
root = Path(path)
|
||||
if not root.exists():
|
||||
return 0
|
||||
if root.is_file():
|
||||
return root.stat().st_size
|
||||
return sum(item.stat().st_size for item in root.rglob("*") if item.is_file())
|
||||
|
||||
|
||||
async def _prune(db: AsyncSession) -> None:
|
||||
now = utcnow()
|
||||
rows = list((await db.scalars(select(BackupPreflight).where(
|
||||
BackupPreflight.expires_at <= now,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
).with_for_update())).all())
|
||||
from .storage import remove_quarantine, restore_quarantine_dir
|
||||
|
||||
attachment_root = Path(get_settings().attachment_dir).resolve()
|
||||
for row in rows:
|
||||
try:
|
||||
if row.status == "repair_pending":
|
||||
if row.cleanup_path:
|
||||
restore_quarantine_dir(attachment_root, Path(row.cleanup_path))
|
||||
row.cleanup_path = None
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
row.status = "failed"
|
||||
continue
|
||||
if row.cleanup_path:
|
||||
remove_quarantine(Path(row.cleanup_path))
|
||||
row.cleanup_path = None
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if row.status == "cleanup_pending":
|
||||
row.status = "consumed"
|
||||
row.consumed_at = row.consumed_at or now
|
||||
else:
|
||||
await db.delete(row)
|
||||
if rows:
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/export.zip")
|
||||
async def export_zip(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
path = await export_v2(db, user)
|
||||
await db.commit()
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/zip",
|
||||
filename="dodo-backup-v2.zip",
|
||||
background=BackgroundTask(path.unlink, missing_ok=True),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/preflight")
|
||||
async def preflight(
|
||||
mode: str = Query(pattern="^(merge|replace)$"),
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _prune(db)
|
||||
settings = get_settings()
|
||||
configured_limit = settings.backup_max_archive_mb * 1024 * 1024
|
||||
limit = min(MAX_ARCHIVE_BYTES, configured_limit)
|
||||
max_pending = min(MAX_PENDING_PREFLIGHTS_PER_USER, settings.backup_max_pending_per_user)
|
||||
max_staged = settings.backup_max_staged_mb_per_user * 1024 * 1024
|
||||
token = secrets.token_urlsafe(32)
|
||||
staging = _staging_root() / f"{new_id()}.zip"
|
||||
reservation = BackupPreflight(
|
||||
token_hash=hash_token(token), user_id=user.id, backup_id=new_id(),
|
||||
archive_sha256="0" * 64, archive_size=limit, staging_path=str(staging), mode=mode,
|
||||
status="reserved", expires_at=utcnow() + timedelta(seconds=settings.backup_preflight_ttl_seconds),
|
||||
)
|
||||
# A user-row write serializes quota decisions across workers on PostgreSQL;
|
||||
# SQLite serializes writers at the database level.
|
||||
await db.execute(update(User).where(User.id == user.id).values(username=User.username))
|
||||
pending_count, pending_bytes = (await db.execute(select(
|
||||
func.count(BackupPreflight.id), func.coalesce(func.sum(BackupPreflight.archive_size), 0)
|
||||
).where(
|
||||
BackupPreflight.user_id == user.id,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
))).one()
|
||||
pending_rows = list((await db.scalars(select(BackupPreflight).where(
|
||||
BackupPreflight.user_id == user.id,
|
||||
BackupPreflight.status.in_((
|
||||
"reserved", "pending", "failed", "consuming", "cleanup_pending",
|
||||
"repair_pending",
|
||||
)),
|
||||
))).all())
|
||||
pending_bytes += sum(_path_size(item.cleanup_path) for item in pending_rows)
|
||||
if pending_count >= max_pending or pending_bytes + limit > max_staged:
|
||||
await db.rollback()
|
||||
raise backup_error("backup_preflight_quota", "待处理预检配额已达上限", 429)
|
||||
db.add(reservation)
|
||||
await db.flush()
|
||||
reservation_id = reservation.id
|
||||
await db.commit()
|
||||
|
||||
size = 0
|
||||
digest = hashlib.sha256()
|
||||
archive = None
|
||||
try:
|
||||
with staging.open("xb") as output:
|
||||
while chunk := await file.read(_READ_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > limit:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小无效")
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
archive = parse_archive_path(staging, max_archive_bytes=limit)
|
||||
validate_archive(archive)
|
||||
if mode == "merge":
|
||||
incoming_pin_ids = {
|
||||
str(item["id"])
|
||||
for item in archive.entities["countdowns"]
|
||||
if item.get("pinned") is True and item.get("archived_at") is None
|
||||
}
|
||||
existing_pin_ids = set((await db.scalars(select(Countdown.id).where(
|
||||
Countdown.user_id == user.id,
|
||||
Countdown.pinned.is_(True),
|
||||
Countdown.archived_at.is_(None),
|
||||
))).all())
|
||||
if (
|
||||
incoming_pin_ids
|
||||
and existing_pin_ids
|
||||
and incoming_pin_ids != {str(item) for item in existing_pin_ids}
|
||||
):
|
||||
raise backup_error("backup_constraint_invalid", "合并恢复会产生多个置顶倒数日")
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
if archive.archive_sha256 != digest.hexdigest():
|
||||
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||
reservation.backup_id = archive.backup_id
|
||||
reservation.archive_sha256 = archive.archive_sha256
|
||||
reservation.archive_size = size
|
||||
reservation.status = "pending"
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
if archive is not None:
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
failed = await db.get(BackupPreflight, reservation_id)
|
||||
if failed is not None:
|
||||
await db.delete(failed)
|
||||
await db.commit()
|
||||
staging.unlink(missing_ok=True)
|
||||
raise
|
||||
return {"valid": True, "preflight_token": token, "backup_id": archive.backup_id,
|
||||
"archive_sha256": archive.archive_sha256,
|
||||
"entities": {name: len(rows) for name, rows in archive.entities.items()}}
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore(
|
||||
payload: RestoreRequest,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _prune(db)
|
||||
token_hash = hash_token(payload.preflight_token)
|
||||
row = await db.scalar(select(BackupPreflight).where(
|
||||
BackupPreflight.token_hash == token_hash, BackupPreflight.user_id == user.id
|
||||
).with_for_update())
|
||||
if row is None or row.mode != payload.mode or row.expires_at <= utcnow():
|
||||
raise backup_error("backup_preflight_invalid", "预检令牌无效或已过期", 409)
|
||||
if row.status == "repair_pending" and row.cleanup_path:
|
||||
from .storage import restore_quarantine_dir
|
||||
root = Path(get_settings().attachment_dir).resolve()
|
||||
try:
|
||||
restore_quarantine_dir(root, Path(row.cleanup_path))
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_repair_pending", "原附件复原尚未完成,请稍后重试", 503) from exc
|
||||
row.status = "failed"
|
||||
row.cleanup_path = None
|
||||
await db.commit()
|
||||
raise backup_error("backup_restore_retry", "附件已复原,请重新预检后重试", 409)
|
||||
if row.status == "cleanup_pending":
|
||||
from .storage import remove_quarantine
|
||||
try:
|
||||
if row.cleanup_path:
|
||||
remove_quarantine(Path(row.cleanup_path))
|
||||
_safe_staging_path(row.staging_path).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_cleanup_pending", "清理尚未完成,请稍后重试", 503) from exc
|
||||
row.status = "consumed"
|
||||
row.cleanup_path = None
|
||||
await db.commit()
|
||||
return {"restored": 0, "mode": payload.mode, "already_imported": False, "cleanup_retried": True}
|
||||
claimed = await db.execute(update(BackupPreflight).where(
|
||||
BackupPreflight.id == row.id, BackupPreflight.status.in_(("pending", "failed"))
|
||||
).values(status="consuming", consumed_at=utcnow()))
|
||||
if claimed.rowcount != 1:
|
||||
await db.rollback()
|
||||
raise backup_error("backup_preflight_invalid", "预检令牌已使用", 409)
|
||||
await db.commit()
|
||||
staging = _safe_staging_path(row.staging_path)
|
||||
archive = parse_archive_path(staging)
|
||||
try:
|
||||
if archive.archive_sha256 != row.archive_sha256 or archive.backup_id != row.backup_id:
|
||||
row.status = "failed"
|
||||
await db.commit()
|
||||
raise backup_error("backup_preflight_invalid", "预检暂存已改变", 409)
|
||||
result = await restore_v2(db, user, archive, payload.mode, operation=row)
|
||||
except BackupRepairPending:
|
||||
raise
|
||||
except Exception:
|
||||
await db.refresh(row)
|
||||
if row.status not in {"cleanup_pending", "repair_pending"}:
|
||||
row.status = "failed"
|
||||
await db.commit()
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(archive.staging_dir, ignore_errors=True)
|
||||
row.status = "cleanup_pending"
|
||||
await db.commit()
|
||||
try:
|
||||
staging.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise backup_error("backup_cleanup_pending", "数据已恢复,但暂存清理未完成;请使用同一令牌重试", 500) from exc
|
||||
row.status = "consumed"
|
||||
await db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
preflight_token: str = Field(min_length=32, max_length=128)
|
||||
mode: str = Field(pattern="^(merge|replace)$")
|
||||
@@ -0,0 +1,771 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import tempfile
|
||||
from datetime import date, datetime
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import Boolean, Date, DateTime, Float, Integer, String, Text, Uuid, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.config import get_settings
|
||||
from backend.models import (
|
||||
Attachment,
|
||||
BackupImport,
|
||||
BackupImportEntity,
|
||||
BackupPreflight,
|
||||
Countdown,
|
||||
Folder,
|
||||
Habit,
|
||||
HabitLog,
|
||||
HabitPause,
|
||||
Memo,
|
||||
RecurrenceException,
|
||||
RecurrenceTemplate,
|
||||
Task,
|
||||
TaskList,
|
||||
User,
|
||||
new_id,
|
||||
)
|
||||
from backend.mvp import (
|
||||
CountdownInput,
|
||||
HabitCreate,
|
||||
MemoCreate,
|
||||
PauseInput,
|
||||
RecurrenceCreate,
|
||||
is_occurrence,
|
||||
parse_rrule,
|
||||
)
|
||||
from backend.schemas import FolderCreate, ListCreate, TaskCreate
|
||||
|
||||
from .archive import ParsedArchive, backup_error, build_archive_to_path, canonical_json
|
||||
from .storage import contained_file, quarantine_files, remove_quarantine, restore_quarantine
|
||||
|
||||
|
||||
class BackupRepairPending(HTTPException):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(500, {
|
||||
"code": "backup_repair_pending",
|
||||
"message": "恢复失败且原附件尚未完全复原;请使用同一令牌重试",
|
||||
})
|
||||
|
||||
ENTITY_MODELS = {
|
||||
"folders": Folder,
|
||||
"lists": TaskList,
|
||||
"tasks": Task,
|
||||
"recurrences": RecurrenceTemplate,
|
||||
"recurrence_exceptions": RecurrenceException,
|
||||
"habits": Habit,
|
||||
"habit_logs": HabitLog,
|
||||
"habit_pauses": HabitPause,
|
||||
"countdowns": Countdown,
|
||||
"memos": Memo,
|
||||
"attachments": Attachment,
|
||||
}
|
||||
RELATIONS = {
|
||||
"lists": {"folder_id": "folders"},
|
||||
"tasks": {"list_id": "lists", "parent_id": "tasks"},
|
||||
"recurrences": {"task_id": "tasks"},
|
||||
"recurrence_exceptions": {"template_id": "recurrences"},
|
||||
"habit_logs": {"habit_id": "habits"},
|
||||
"habit_pauses": {"habit_id": "habits"},
|
||||
"attachments": {"task_id": "tasks"},
|
||||
}
|
||||
ORDER = tuple(ENTITY_MODELS)
|
||||
|
||||
|
||||
def _value(value):
|
||||
if isinstance(value, (UUID, date, datetime)):
|
||||
return value.isoformat() if not isinstance(value, UUID) else str(value)
|
||||
return value
|
||||
|
||||
|
||||
def _row(row, *, attachment_path: str | None = None) -> dict:
|
||||
result = {
|
||||
column.name: _value(getattr(row, column.name))
|
||||
for column in row.__table__.columns
|
||||
if column.name != "user_id" and column.name != "storage_name"
|
||||
}
|
||||
if attachment_path is not None:
|
||||
result["archive_path"] = attachment_path
|
||||
return result
|
||||
|
||||
|
||||
async def export_v2(db: AsyncSession, user: User) -> Path:
|
||||
# Serialize exports with restores for this user. PostgreSQL keeps the row
|
||||
# lock until this function's transaction is committed by the route.
|
||||
await db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||
entities: dict[str, list[dict]] = {}
|
||||
files: dict[str, Path] = {}
|
||||
root = Path(get_settings().attachment_dir).resolve()
|
||||
total_size = 0
|
||||
limit = get_settings().backup_max_archive_mb * 1024 * 1024
|
||||
for name, model in ENTITY_MODELS.items():
|
||||
if name in {"recurrence_exceptions", "habit_logs", "habit_pauses"}:
|
||||
if name == "recurrence_exceptions":
|
||||
ids = select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id)
|
||||
rows = list((await db.scalars(select(model).where(model.template_id.in_(ids)))).all())
|
||||
else:
|
||||
ids = select(Habit.id).where(Habit.user_id == user.id)
|
||||
rows = list((await db.scalars(select(model).where(model.habit_id.in_(ids)))).all())
|
||||
else:
|
||||
rows = list((await db.scalars(select(model).where(model.user_id == user.id))).all())
|
||||
output = []
|
||||
for item in rows:
|
||||
if isinstance(item, Attachment):
|
||||
archive_path = f"attachments/{item.id}/content"
|
||||
try:
|
||||
path = contained_file(root, item.storage_name)
|
||||
except ValueError as exc:
|
||||
raise backup_error("backup_attachment_path_invalid", "附件存储路径无效") from exc
|
||||
if not path.is_file():
|
||||
raise backup_error("backup_attachment_missing", "附件文件缺失")
|
||||
actual_size = path.stat().st_size
|
||||
if actual_size != item.size:
|
||||
raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配")
|
||||
total_size += actual_size
|
||||
if total_size > limit:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小超出导入合同")
|
||||
files[archive_path] = path
|
||||
output.append(_row(item, attachment_path=archive_path))
|
||||
else:
|
||||
output.append(_row(item))
|
||||
entities[name] = output
|
||||
handle, output_name = tempfile.mkstemp(prefix="dodo-backup-", suffix=".zip")
|
||||
import os
|
||||
os.close(handle)
|
||||
output_path = Path(output_name)
|
||||
try:
|
||||
build_archive_to_path(output_path, new_id(), entities, files)
|
||||
if output_path.stat().st_size > limit:
|
||||
raise backup_error("backup_size_invalid", "备份文件大小超出导入合同")
|
||||
return output_path
|
||||
except Exception:
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _validate_scalar(entity: str, column, row: dict) -> None:
|
||||
name = column.name
|
||||
if name in {"user_id", "storage_name"}:
|
||||
return
|
||||
if name not in row:
|
||||
if not column.nullable and column.default is None and not column.primary_key:
|
||||
raise backup_error("backup_entity_invalid", f"{entity} 缺少必填字段")
|
||||
return
|
||||
value = row[name]
|
||||
if value is None:
|
||||
if not column.nullable and not column.primary_key:
|
||||
raise backup_error("backup_entity_invalid", f"{entity}.{name} 不可为空")
|
||||
return
|
||||
try:
|
||||
effective_type = getattr(column.type, "impl", column.type)
|
||||
if isinstance(effective_type, Uuid):
|
||||
UUID(str(value))
|
||||
elif isinstance(effective_type, DateTime):
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
datetime.fromisoformat(value)
|
||||
elif isinstance(effective_type, Date):
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
date.fromisoformat(value)
|
||||
elif isinstance(effective_type, Boolean):
|
||||
if type(value) is not bool:
|
||||
raise TypeError
|
||||
elif isinstance(effective_type, Integer):
|
||||
if type(value) is not int:
|
||||
raise TypeError
|
||||
elif isinstance(effective_type, Float):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise TypeError
|
||||
elif isinstance(effective_type, (String, Text)):
|
||||
if not isinstance(value, str):
|
||||
raise TypeError
|
||||
if isinstance(effective_type, String) and effective_type.length is not None and len(value) > effective_type.length:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError, OverflowError) as exc:
|
||||
raise backup_error("backup_entity_invalid", f"{entity}.{name} 类型无效") from exc
|
||||
|
||||
|
||||
def _parse_habit_days(value: object, *, minimum: int, maximum: int) -> list[int] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value or any(part.strip() != part for part in value.split(",")):
|
||||
raise ValueError
|
||||
parts = value.split(",")
|
||||
if any(not part.isascii() or not part.isdecimal() for part in parts):
|
||||
raise ValueError
|
||||
days = [int(part) for part in parts]
|
||||
if value != ",".join(map(str, days)):
|
||||
raise ValueError
|
||||
if len(days) != len(set(days)) or any(day < minimum or day > maximum for day in days):
|
||||
raise ValueError
|
||||
return days
|
||||
|
||||
|
||||
def _validate_habit_graph(parsed: ParsedArchive) -> None:
|
||||
habits: dict[str, HabitCreate] = {}
|
||||
try:
|
||||
for row in parsed.entities["habits"]:
|
||||
weekdays = _parse_habit_days(row.get("weekdays"), minimum=0, maximum=6)
|
||||
month_days = _parse_habit_days(row.get("month_days"), minimum=1, maximum=31)
|
||||
payload = {
|
||||
field: row.get(field)
|
||||
for field in HabitCreate.model_fields
|
||||
if field in row
|
||||
}
|
||||
payload["weekdays"] = weekdays
|
||||
payload["month_days"] = month_days
|
||||
habit = HabitCreate.model_validate(payload)
|
||||
if habit.kind == "boolean" and (row.get("target") != 1 or row.get("max_value") != 1):
|
||||
raise ValueError
|
||||
if habit.weekdays != weekdays or habit.month_days != month_days:
|
||||
raise ValueError
|
||||
if habit.interval_days != row.get("interval_days"):
|
||||
raise ValueError
|
||||
habits[str(row["id"])] = habit
|
||||
|
||||
for row in parsed.entities["habit_logs"]:
|
||||
habit = habits[str(row["habit_id"])]
|
||||
value = row["value"]
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ValueError
|
||||
if habit.kind == "boolean":
|
||||
if value not in (0, 1):
|
||||
raise ValueError
|
||||
elif habit.max_value is not None and value > habit.max_value:
|
||||
raise ValueError
|
||||
|
||||
pauses_by_habit: dict[str, list[tuple[date, date]]] = {}
|
||||
for row in parsed.entities["habit_pauses"]:
|
||||
pause = PauseInput.model_validate({
|
||||
"start_date": row.get("start_date"),
|
||||
"end_date": row.get("end_date"),
|
||||
})
|
||||
pauses_by_habit.setdefault(str(row["habit_id"]), []).append(
|
||||
(pause.start_date, pause.end_date)
|
||||
)
|
||||
for pauses in pauses_by_habit.values():
|
||||
pauses.sort()
|
||||
if any(current[0] <= previous[1] for previous, current in pairwise(pauses)):
|
||||
raise ValueError
|
||||
except (KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||
raise backup_error("backup_habit_invalid", "习惯数据不符合业务规则") from exc
|
||||
|
||||
|
||||
def _required_trimmed(value: object, *, maximum: int | None = None) -> str:
|
||||
if not isinstance(value, str) or value != value.strip() or not value:
|
||||
raise ValueError
|
||||
if maximum is not None and len(value) > maximum:
|
||||
raise ValueError
|
||||
return value
|
||||
|
||||
|
||||
def _ordered_datetimes(row: dict, earlier: str, later: str) -> None:
|
||||
if row.get(earlier) and row.get(later) and datetime.fromisoformat(row[later]) < datetime.fromisoformat(row[earlier]):
|
||||
raise ValueError
|
||||
|
||||
|
||||
def _validate_entity_contracts(parsed: ParsedArchive) -> None:
|
||||
try:
|
||||
for row in parsed.entities["folders"]:
|
||||
FolderCreate.model_validate({"name": row.get("name")})
|
||||
_required_trimmed(row.get("name"), maximum=120)
|
||||
if row.get("position", 0) < 0:
|
||||
raise ValueError
|
||||
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||
for row in parsed.entities["lists"]:
|
||||
ListCreate.model_validate({"name": row.get("name"), "folder_id": row.get("folder_id")})
|
||||
_required_trimmed(row.get("name"), maximum=120)
|
||||
if row.get("position", 0) < 0:
|
||||
raise ValueError
|
||||
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||
for row in parsed.entities["tasks"]:
|
||||
task = TaskCreate.model_validate({
|
||||
"title": row.get("title"),
|
||||
"list_id": row.get("list_id"),
|
||||
"description": row.get("description", ""),
|
||||
"priority": row.get("priority", 0),
|
||||
"due_at": row.get("due_at"),
|
||||
"due_has_time": row.get("due_has_time", False),
|
||||
"parent_id": row.get("parent_id"),
|
||||
})
|
||||
_required_trimmed(row.get("title"), maximum=500)
|
||||
if row.get("version", 1) < 1 or row.get("position", 0) < 0:
|
||||
raise ValueError
|
||||
if task.due_at is None and task.due_has_time:
|
||||
raise ValueError
|
||||
completed_at = row.get("completed_at")
|
||||
if row.get("completed") is True and completed_at is None:
|
||||
raise ValueError
|
||||
if row.get("completed") is False and completed_at is not None:
|
||||
raise ValueError
|
||||
_ordered_datetimes(row, "created_at", "updated_at")
|
||||
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||
for row in parsed.entities["countdowns"]:
|
||||
CountdownInput.model_validate({
|
||||
field: row[field] for field in CountdownInput.model_fields if field in row
|
||||
})
|
||||
_required_trimmed(row.get("title"), maximum=200)
|
||||
_required_trimmed(row.get("icon"), maximum=32)
|
||||
_ordered_datetimes(row, "created_at", "updated_at")
|
||||
_ordered_datetimes(row, "created_at", "archived_at")
|
||||
for row in parsed.entities["memos"]:
|
||||
MemoCreate.model_validate({"title": row.get("title"), "content": row.get("content", "")})
|
||||
_required_trimmed(row.get("title"), maximum=200)
|
||||
if row.get("version", 1) < 1:
|
||||
raise ValueError
|
||||
_ordered_datetimes(row, "created_at", "updated_at")
|
||||
_ordered_datetimes(row, "created_at", "deleted_at")
|
||||
allowed_mime = {
|
||||
"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png",
|
||||
"image/gif", "application/json", "application/zip",
|
||||
}
|
||||
for row in parsed.entities["attachments"]:
|
||||
filename = row.get("filename")
|
||||
mime_type = row.get("mime_type")
|
||||
if (
|
||||
not isinstance(filename, str)
|
||||
or not filename.strip()
|
||||
or Path(filename).name != filename
|
||||
or mime_type not in allowed_mime
|
||||
or row.get("size", -1) < 0
|
||||
):
|
||||
raise ValueError
|
||||
except (KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||
raise backup_error("backup_entity_invalid", "实体数据不符合业务规则") from exc
|
||||
|
||||
|
||||
def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
||||
tasks = {str(row["id"]): row for row in parsed.entities["tasks"]}
|
||||
try:
|
||||
for row in parsed.entities["recurrences"]:
|
||||
task = tasks[str(row["task_id"])]
|
||||
recurrence = RecurrenceCreate.model_validate({
|
||||
"task_id": row["task_id"],
|
||||
"rrule": row.get("rrule"),
|
||||
"trigger_mode": row.get("trigger_mode", "scheduled"),
|
||||
"after_completion_days": row.get("after_completion_days"),
|
||||
})
|
||||
starts_at = datetime.fromisoformat(row["starts_at"])
|
||||
ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None
|
||||
last_completed_at = (
|
||||
datetime.fromisoformat(row["last_completed_at"])
|
||||
if row.get("last_completed_at")
|
||||
else None
|
||||
)
|
||||
if task.get("parent_id") is not None or task.get("due_at") is None:
|
||||
raise ValueError
|
||||
due_at = datetime.fromisoformat(task["due_at"])
|
||||
if recurrence.trigger_mode == "scheduled":
|
||||
if recurrence.rrule is None:
|
||||
raise ValueError
|
||||
parse_rrule(recurrence.rrule)
|
||||
if starts_at != due_at:
|
||||
raise ValueError
|
||||
if ends_at is not None and ends_at < starts_at:
|
||||
raise ValueError
|
||||
if last_completed_at is not None and last_completed_at > starts_at:
|
||||
raise ValueError
|
||||
|
||||
recurrences = {str(row["id"]): row for row in parsed.entities["recurrences"]}
|
||||
for row in parsed.entities["recurrence_exceptions"]:
|
||||
recurrence = recurrences[str(row["template_id"])]
|
||||
if recurrence.get("trigger_mode", "scheduled") != "scheduled":
|
||||
raise ValueError
|
||||
occurrence_at = datetime.fromisoformat(row["occurrence_at"])
|
||||
if not is_occurrence(
|
||||
recurrence["rrule"], datetime.fromisoformat(recurrence["starts_at"]), occurrence_at
|
||||
):
|
||||
raise ValueError
|
||||
if recurrence.get("ends_at") and occurrence_at > datetime.fromisoformat(recurrence["ends_at"]):
|
||||
raise ValueError
|
||||
except (HTTPException, KeyError, TypeError, ValueError, ValidationError) as exc:
|
||||
raise backup_error("backup_recurrence_invalid", "重复规则不符合业务规则") from exc
|
||||
|
||||
|
||||
def validate_archive(parsed: ParsedArchive) -> None:
|
||||
unknown = set(parsed.entities) - set(ENTITY_MODELS)
|
||||
missing = set(ENTITY_MODELS) - set(parsed.entities)
|
||||
if unknown:
|
||||
raise backup_error("backup_entity_unknown", "备份包含未知实体")
|
||||
if missing:
|
||||
raise backup_error("backup_entity_missing", "备份缺少必需实体")
|
||||
ids: dict[str, set[str]] = {}
|
||||
for entity in ORDER:
|
||||
rows = parsed.entities.get(entity, [])
|
||||
model = ENTITY_MODELS[entity]
|
||||
allowed = {column.name for column in model.__table__.columns} | ({"archive_path"} if entity == "attachments" else set())
|
||||
entity_ids = []
|
||||
for row in rows:
|
||||
if set(row) - allowed or "user_id" in row or "storage_name" in row:
|
||||
raise backup_error("backup_entity_invalid", f"{entity} 包含未知或受保护字段")
|
||||
for column in model.__table__.columns:
|
||||
_validate_scalar(entity, column, row)
|
||||
try:
|
||||
entity_ids.append(str(UUID(str(row["id"]))))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise backup_error("backup_entity_invalid", f"{entity} 包含无效 ID") from exc
|
||||
if len(entity_ids) != len(set(entity_ids)):
|
||||
raise backup_error("backup_duplicate_id", f"{entity} 包含重复 ID")
|
||||
ids[entity] = set(entity_ids)
|
||||
for entity, fields in RELATIONS.items():
|
||||
for row in parsed.entities.get(entity, []):
|
||||
for field, target in fields.items():
|
||||
value = row.get(field)
|
||||
if value is not None and str(value) not in ids[target]:
|
||||
raise backup_error("backup_reference_invalid", f"{entity}.{field} 引用不存在")
|
||||
attachment_paths = set()
|
||||
for row in parsed.entities.get("attachments", []):
|
||||
path = row.get("archive_path")
|
||||
if not isinstance(path, str) or path not in parsed.files or not path.startswith("attachments/"):
|
||||
raise backup_error("backup_attachment_missing", "附件内容缺失")
|
||||
if path in attachment_paths:
|
||||
raise backup_error("backup_attachment_duplicate", "附件内容被重复引用")
|
||||
attachment_paths.add(path)
|
||||
blob = parsed.files[path]
|
||||
if row.get("size") != blob.size:
|
||||
raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配")
|
||||
if set(parsed.files) != attachment_paths:
|
||||
raise backup_error("backup_unreferenced_file", "备份包含未引用文件")
|
||||
|
||||
def unique(entity: str, fields: tuple[str, ...], *, ignore_null: bool = False) -> None:
|
||||
seen = set()
|
||||
for row in parsed.entities[entity]:
|
||||
key = tuple(row.get(field) for field in fields)
|
||||
if ignore_null and any(value is None for value in key):
|
||||
continue
|
||||
if key in seen:
|
||||
raise backup_error("backup_constraint_invalid", f"{entity} 唯一约束冲突")
|
||||
seen.add(key)
|
||||
|
||||
unique("recurrences", ("task_id",))
|
||||
unique("recurrence_exceptions", ("template_id", "occurrence_at"))
|
||||
unique("habit_logs", ("habit_id", "day"))
|
||||
unique("tasks", ("external_id",), ignore_null=True)
|
||||
active_pinned = sum(
|
||||
row.get("pinned") is True and row.get("archived_at") is None
|
||||
for row in parsed.entities["countdowns"]
|
||||
)
|
||||
if active_pinned > 1:
|
||||
raise backup_error("backup_constraint_invalid", "最多只能有一个置顶倒数日")
|
||||
_validate_entity_contracts(parsed)
|
||||
_validate_habit_graph(parsed)
|
||||
_validate_recurrence_graph(parsed)
|
||||
inboxes = [row for row in parsed.entities["lists"] if row.get("is_inbox") is True]
|
||||
if len(inboxes) != 1:
|
||||
raise backup_error("backup_constraint_invalid", "备份必须包含且仅包含一个收集箱")
|
||||
tasks = {str(row["id"]): row for row in parsed.entities["tasks"]}
|
||||
for task_id, row in tasks.items():
|
||||
parent_id = row.get("parent_id")
|
||||
if parent_id is None:
|
||||
continue
|
||||
parent_id = str(parent_id)
|
||||
if parent_id == task_id:
|
||||
raise backup_error("backup_constraint_invalid", "任务不能以自身为父任务")
|
||||
parent = tasks[parent_id]
|
||||
if parent.get("parent_id") is not None:
|
||||
raise backup_error("backup_constraint_invalid", "任务父子关系仅支持一层")
|
||||
if parent.get("list_id") != row.get("list_id"):
|
||||
raise backup_error("backup_constraint_invalid", "父子任务必须属于同一清单")
|
||||
|
||||
|
||||
def _ordered_rows(entity: str, rows: list[dict]) -> list[dict]:
|
||||
if entity != "tasks":
|
||||
return rows
|
||||
pending = {str(row["id"]): row for row in rows}
|
||||
ordered: list[dict] = []
|
||||
while pending:
|
||||
ready = [row for row in pending.values() if row.get("parent_id") is None or str(row["parent_id"]) not in pending]
|
||||
if not ready:
|
||||
raise backup_error("backup_constraint_invalid", "任务父子关系存在环")
|
||||
for row in ready:
|
||||
ordered.append(row)
|
||||
pending.pop(str(row["id"]))
|
||||
return ordered
|
||||
|
||||
|
||||
def _normalized_values(values: dict) -> dict:
|
||||
return {key: _value(value) for key, value in values.items() if key not in {"created_at", "updated_at"}}
|
||||
|
||||
|
||||
def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID) -> dict:
|
||||
values = {}
|
||||
relations = RELATIONS.get(next(name for name, item in ENTITY_MODELS.items() if item is model), {})
|
||||
for column in model.__table__.columns:
|
||||
name = column.name
|
||||
if name == "user_id":
|
||||
values[name] = user_id
|
||||
elif name == "storage_name":
|
||||
continue
|
||||
elif name in raw:
|
||||
value = raw[name]
|
||||
if name in relations and value is not None:
|
||||
value = mapping[relations[name]][str(value)]
|
||||
elif value is not None:
|
||||
column_type = column.type
|
||||
effective_type = getattr(column_type, "impl", column_type)
|
||||
if isinstance(effective_type, Uuid):
|
||||
value = UUID(str(value))
|
||||
elif isinstance(effective_type, DateTime) and isinstance(value, str):
|
||||
value = datetime.fromisoformat(value)
|
||||
elif isinstance(effective_type, Date) and isinstance(value, str):
|
||||
value = date.fromisoformat(value)
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
|
||||
def _content_digest(entity: str, raw: dict) -> str:
|
||||
# Source-form relations are deliberate: digest identity remains stable even
|
||||
# when another user needs different target UUIDs.
|
||||
payload = {key: value for key, value in raw.items() if key not in {"created_at", "updated_at", "storage_name"}}
|
||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
async def _all_uuid_primary_keys(db: AsyncSession) -> set[UUID]:
|
||||
occupied: set[UUID] = set()
|
||||
for table in User.metadata.sorted_tables:
|
||||
primary_keys = list(table.primary_key.columns)
|
||||
if len(primary_keys) != 1:
|
||||
continue
|
||||
column = primary_keys[0]
|
||||
effective_type = getattr(column.type, "impl", column.type)
|
||||
if isinstance(effective_type, Uuid):
|
||||
occupied.update((await db.scalars(select(column))).all())
|
||||
return occupied
|
||||
|
||||
|
||||
async def _mapped_id(
|
||||
db: AsyncSession, model, source: UUID, user_id: UUID, backup_id: UUID, entity: str,
|
||||
reserved: set[UUID], incoming: set[UUID],
|
||||
) -> UUID:
|
||||
ledger = await db.scalar(select(BackupImportEntity).where(
|
||||
BackupImportEntity.user_id == user_id,
|
||||
BackupImportEntity.backup_id == backup_id,
|
||||
BackupImportEntity.entity_type == entity,
|
||||
BackupImportEntity.source_id == source,
|
||||
))
|
||||
if ledger is not None:
|
||||
reserved.add(ledger.target_id)
|
||||
return ledger.target_id
|
||||
existing = await db.get(model, source)
|
||||
owner_id = getattr(existing, "user_id", None)
|
||||
if owner_id is None:
|
||||
if isinstance(existing, RecurrenceException):
|
||||
owner_id = await db.scalar(select(RecurrenceTemplate.user_id).where(RecurrenceTemplate.id == existing.template_id))
|
||||
elif isinstance(existing, (HabitLog, HabitPause)):
|
||||
owner_id = await db.scalar(select(Habit.user_id).where(Habit.id == existing.habit_id))
|
||||
if existing is not None and owner_id == user_id:
|
||||
reserved.add(source)
|
||||
return source
|
||||
if source not in reserved:
|
||||
reserved.add(source)
|
||||
return source
|
||||
candidate = uuid5(user_id, str(source))
|
||||
while candidate in reserved or candidate in incoming:
|
||||
candidate = uuid5(user_id, str(candidate))
|
||||
reserved.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
async def restore_v2(
|
||||
db: AsyncSession, user: User, parsed: ParsedArchive, mode: str,
|
||||
*, operation: BackupPreflight | None = None,
|
||||
) -> dict:
|
||||
validate_archive(parsed)
|
||||
# The user row is the cross-worker restore/export mutex on PostgreSQL.
|
||||
await db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||
existing_import = await db.scalar(select(BackupImport).where(
|
||||
BackupImport.user_id == user.id, BackupImport.backup_id == parsed.backup_id
|
||||
))
|
||||
already_imported = existing_import is not None
|
||||
if mode == "merge" and existing_import and existing_import.archive_sha256 != parsed.archive_sha256:
|
||||
raise HTTPException(409, {"code": "backup_id_conflict", "message": "备份标识与内容不一致"})
|
||||
|
||||
root = Path(get_settings().attachment_dir).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
quarantine = root.parent / f".backup-quarantine-{new_id()}"
|
||||
moved = []
|
||||
written: list[Path] = []
|
||||
try:
|
||||
if mode == "replace":
|
||||
old_names = list((await db.scalars(
|
||||
select(Attachment.storage_name).where(Attachment.user_id == user.id)
|
||||
)).all())
|
||||
moved = quarantine_files(root, old_names, quarantine)
|
||||
await db.execute(delete(RecurrenceException).where(
|
||||
RecurrenceException.template_id.in_(
|
||||
select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id)
|
||||
)
|
||||
))
|
||||
await db.execute(delete(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))
|
||||
await db.execute(delete(HabitLog).where(
|
||||
HabitLog.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||
))
|
||||
await db.execute(delete(HabitPause).where(
|
||||
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||
))
|
||||
for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
|
||||
await db.execute(delete(model).where(model.user_id == user.id))
|
||||
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
|
||||
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
|
||||
|
||||
mapping: dict[str, dict[str, UUID]] = {name: {} for name in ORDER}
|
||||
incoming_ids: set[UUID] = {
|
||||
UUID(str(raw["id"]))
|
||||
for rows in parsed.entities.values()
|
||||
for raw in rows
|
||||
}
|
||||
reserved_ids = await _all_uuid_primary_keys(db)
|
||||
for entity in ORDER:
|
||||
model = ENTITY_MODELS[entity]
|
||||
for raw in _ordered_rows(entity, parsed.entities[entity]):
|
||||
source = UUID(str(raw["id"]))
|
||||
mapping[entity][str(source)] = await _mapped_id(
|
||||
db, model, source, user.id, parsed.backup_id, entity,
|
||||
reserved_ids, incoming_ids - {source},
|
||||
)
|
||||
|
||||
restored = 0
|
||||
for entity in ORDER:
|
||||
model = ENTITY_MODELS[entity]
|
||||
for raw in _ordered_rows(entity, parsed.entities[entity]):
|
||||
source = UUID(str(raw["id"]))
|
||||
target = mapping[entity][str(source)]
|
||||
digest_payload = dict(raw)
|
||||
if model is Attachment:
|
||||
digest_payload["archive_sha256"] = parsed.files[raw["archive_path"]].sha256
|
||||
content_digest = _content_digest(entity, digest_payload)
|
||||
ledger = await db.scalar(select(BackupImportEntity).where(
|
||||
BackupImportEntity.user_id == user.id,
|
||||
BackupImportEntity.backup_id == parsed.backup_id,
|
||||
BackupImportEntity.entity_type == entity,
|
||||
BackupImportEntity.source_id == source,
|
||||
))
|
||||
existing = await db.get(model, target)
|
||||
values = _coerce(model, raw, mapping, user.id)
|
||||
values["id"] = target
|
||||
if ledger is not None:
|
||||
if ledger.target_id != target or ledger.content_digest != content_digest:
|
||||
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射账本冲突"})
|
||||
if existing is None:
|
||||
raise HTTPException(409, {"code": "backup_entity_missing", "message": f"{entity} 映射目标不存在"})
|
||||
current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None)
|
||||
expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}}
|
||||
if model is Attachment:
|
||||
expected["archive_path"] = raw["archive_path"]
|
||||
stored = contained_file(root, existing.storage_name)
|
||||
file_digest = hashlib.sha256()
|
||||
actual_size = 0
|
||||
try:
|
||||
with stored.open("rb") as source_file:
|
||||
while chunk := source_file.read(1024 * 1024):
|
||||
actual_size += len(chunk)
|
||||
file_digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise HTTPException(409, {"code": "backup_entity_missing", "message": "attachments 映射目标文件不存在"}) from exc
|
||||
blob = parsed.files[raw["archive_path"]]
|
||||
if actual_size != blob.size or file_digest.hexdigest() != blob.sha256:
|
||||
raise HTTPException(409, {"code": "backup_entity_conflict", "message": "attachments 映射目标内容冲突"})
|
||||
if _normalized_values(current) != _normalized_values(expected):
|
||||
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射目标内容冲突"})
|
||||
continue
|
||||
if existing is not None and mode == "merge":
|
||||
current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None)
|
||||
expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}}
|
||||
if model is Attachment:
|
||||
expected["archive_path"] = raw["archive_path"]
|
||||
stored = contained_file(root, existing.storage_name)
|
||||
blob = parsed.files[raw["archive_path"]]
|
||||
file_digest = hashlib.sha256()
|
||||
actual_size = 0
|
||||
try:
|
||||
with stored.open("rb") as source_file:
|
||||
while chunk := source_file.read(1024 * 1024):
|
||||
actual_size += len(chunk)
|
||||
file_digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise HTTPException(409, {
|
||||
"code": "backup_entity_missing",
|
||||
"message": "attachments 已存在但文件不存在",
|
||||
}) from exc
|
||||
if actual_size != blob.size or file_digest.hexdigest() != blob.sha256:
|
||||
raise HTTPException(409, {
|
||||
"code": "backup_entity_conflict",
|
||||
"message": "attachments 已存在不同内容",
|
||||
})
|
||||
if _normalized_values(current) != _normalized_values(expected):
|
||||
raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 已存在不同内容"})
|
||||
else:
|
||||
if model is Attachment:
|
||||
archive_path = raw["archive_path"]
|
||||
storage_name = str(new_id())
|
||||
destination = contained_file(root, storage_name)
|
||||
digest = hashlib.sha256()
|
||||
copied_size = 0
|
||||
blob = parsed.files[archive_path]
|
||||
with blob.staging_path.open("rb") as source_file, destination.open("xb") as output:
|
||||
while chunk := source_file.read(1024 * 1024):
|
||||
copied_size += len(chunk)
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
if copied_size != blob.size or digest.hexdigest() != blob.sha256:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise backup_error("backup_checksum_mismatch", "附件暂存校验失败", 409)
|
||||
written.append(destination)
|
||||
values["storage_name"] = storage_name
|
||||
db.add(model(**values))
|
||||
await db.flush()
|
||||
restored += 1
|
||||
db.add(BackupImportEntity(
|
||||
user_id=user.id, backup_id=parsed.backup_id, entity_type=entity,
|
||||
source_id=source, target_id=target, content_digest=content_digest,
|
||||
))
|
||||
await db.flush()
|
||||
if mode == "replace" or existing_import is None:
|
||||
db.add(BackupImport(
|
||||
user_id=user.id, backup_id=parsed.backup_id,
|
||||
archive_sha256=parsed.archive_sha256, mode=mode,
|
||||
))
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
for path in written:
|
||||
path.unlink(missing_ok=True)
|
||||
try:
|
||||
restore_quarantine(moved)
|
||||
except (OSError, ValueError):
|
||||
if operation is not None:
|
||||
operation.status = "repair_pending"
|
||||
operation.cleanup_path = str(quarantine)
|
||||
await db.commit()
|
||||
raise BackupRepairPending()
|
||||
raise
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
for path in written:
|
||||
path.unlink(missing_ok=True)
|
||||
try:
|
||||
restore_quarantine(moved)
|
||||
except (OSError, ValueError):
|
||||
if operation is not None:
|
||||
operation.status = "repair_pending"
|
||||
operation.cleanup_path = str(quarantine)
|
||||
await db.commit()
|
||||
raise BackupRepairPending()
|
||||
raise
|
||||
try:
|
||||
remove_quarantine(quarantine)
|
||||
except OSError as exc:
|
||||
if operation is not None:
|
||||
operation.status = "cleanup_pending"
|
||||
operation.cleanup_path = str(quarantine)
|
||||
await db.commit()
|
||||
raise HTTPException(500, {"code": "backup_cleanup_pending", "message": "数据已恢复,但旧附件清理未完成;请使用同一令牌重试"}) from exc
|
||||
return {"restored": restored, "mode": mode, "already_imported": already_imported, "cleanup_retried": False}
|
||||
@@ -0,0 +1,52 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def contained_file(root: Path, storage_name: str) -> Path:
|
||||
root = root.resolve()
|
||||
candidate = (root / storage_name).resolve()
|
||||
if candidate.parent != root:
|
||||
raise ValueError("attachment path escapes storage root")
|
||||
return candidate
|
||||
|
||||
|
||||
def quarantine_files(root: Path, storage_names: list[str], quarantine: Path) -> list[tuple[Path, Path]]:
|
||||
moved = []
|
||||
quarantine.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
for name in storage_names:
|
||||
source = contained_file(root, name)
|
||||
if source.exists():
|
||||
target = quarantine / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.replace(target)
|
||||
moved.append((source, target))
|
||||
return moved
|
||||
except Exception:
|
||||
restore_quarantine(moved)
|
||||
raise
|
||||
|
||||
|
||||
def restore_quarantine(moved: list[tuple[Path, Path]]) -> None:
|
||||
for original, quarantined in reversed(moved):
|
||||
if quarantined.exists():
|
||||
original.parent.mkdir(parents=True, exist_ok=True)
|
||||
quarantined.replace(original)
|
||||
|
||||
|
||||
def restore_quarantine_dir(root: Path, quarantine: Path) -> None:
|
||||
if not quarantine.exists():
|
||||
return
|
||||
for quarantined in sorted(quarantine.rglob("*")):
|
||||
if not quarantined.is_file():
|
||||
continue
|
||||
relative = quarantined.relative_to(quarantine)
|
||||
original = contained_file(root, relative.as_posix())
|
||||
original.parent.mkdir(parents=True, exist_ok=True)
|
||||
quarantined.replace(original)
|
||||
shutil.rmtree(quarantine)
|
||||
|
||||
|
||||
def remove_quarantine(path: Path) -> None:
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
@@ -12,6 +12,11 @@ class Settings(BaseSettings):
|
||||
auto_create_schema: bool = False
|
||||
attachment_dir: str = "./data/attachments"
|
||||
attachment_max_mb: int = 20
|
||||
backup_max_archive_mb: int = 256
|
||||
backup_max_pending_per_user: int = 3
|
||||
backup_max_staged_mb_per_user: int = 768
|
||||
backup_preflight_ttl_seconds: int = 900
|
||||
backup_staging_dir: str = "./data/backup-staging"
|
||||
login_attempts: int = 5
|
||||
login_window_seconds: int = 300
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from .auth import (
|
||||
session_token,
|
||||
verify_password,
|
||||
)
|
||||
from .backup import router as backup_router
|
||||
from .db import create_schema, get_db
|
||||
from .models import (
|
||||
AppState,
|
||||
@@ -118,6 +119,7 @@ async def openapi(_: User = Depends(current_user)):
|
||||
|
||||
|
||||
app.include_router(mvp_router)
|
||||
app.include_router(backup_router)
|
||||
logger = logging.getLogger(__name__)
|
||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
@@ -255,6 +255,52 @@ class Memo(Base):
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
|
||||
|
||||
class BackupPreflight(Base):
|
||||
__tablename__ = "backup_preflights"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||
archive_sha256: Mapped[str] = mapped_column(String(64))
|
||||
archive_size: Mapped[int] = mapped_column(Integer)
|
||||
staging_path: Mapped[str] = mapped_column(String(1024), unique=True)
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), index=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
cleanup_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
class BackupImport(Base):
|
||||
__tablename__ = "backup_imports"
|
||||
__table_args__ = (UniqueConstraint("user_id", "backup_id", name="uq_backup_import_user_backup"),)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||
archive_sha256: Mapped[str] = mapped_column(String(64))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
class BackupImportEntity(Base):
|
||||
__tablename__ = "backup_import_entities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "backup_id", "entity_type", "source_id",
|
||||
name="uq_backup_import_entity_source",
|
||||
),
|
||||
)
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
backup_id: Mapped[UUID] = mapped_column(index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
source_id: Mapped[UUID] = mapped_column(index=True)
|
||||
target_id: Mapped[UUID] = mapped_column(index=True)
|
||||
content_digest: Mapped[str] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
|
||||
+65
-12
@@ -11,7 +11,7 @@ from zoneinfo import ZoneInfo
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
|
||||
from sqlalchemy import case, delete, func, select, update
|
||||
from sqlalchemy import case, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import current_user
|
||||
@@ -44,6 +44,42 @@ from .models import (
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
LEGACY_BACKUP_MAX_BYTES = 16 * 1024 * 1024
|
||||
LEGACY_BACKUP_MAX_RECORDS = 10_000
|
||||
LEGACY_BACKUP_MAX_FIELD_BYTES = 1024 * 1024
|
||||
_LEGACY_READ_CHUNK = 64 * 1024
|
||||
_LEGACY_ENTITIES = ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos")
|
||||
|
||||
|
||||
def _legacy_error(code: str, message: str) -> HTTPException:
|
||||
return HTTPException(422, {"code": code, "message": message})
|
||||
|
||||
|
||||
def _validate_legacy_payload_limits(payload: dict) -> None:
|
||||
total = 0
|
||||
for entity in _LEGACY_ENTITIES:
|
||||
rows = payload.get(entity, [])
|
||||
if not isinstance(rows, list):
|
||||
raise _legacy_error("legacy_backup_invalid", "旧版备份实体格式无效")
|
||||
total += len(rows)
|
||||
if total > LEGACY_BACKUP_MAX_RECORDS:
|
||||
raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多")
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise _legacy_error("legacy_backup_invalid", "旧版备份记录格式无效")
|
||||
for value in row.values():
|
||||
if isinstance(value, str) and len(value.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES:
|
||||
raise _legacy_error("legacy_backup_field_too_large", "旧版备份字段过大")
|
||||
|
||||
|
||||
async def _read_legacy_upload(file: UploadFile) -> bytes:
|
||||
content = bytearray()
|
||||
while chunk := await file.read(_LEGACY_READ_CHUNK):
|
||||
content.extend(chunk)
|
||||
if len(content) > LEGACY_BACKUP_MAX_BYTES:
|
||||
raise _legacy_error("legacy_backup_too_large", "旧版备份文件过大")
|
||||
return bytes(content)
|
||||
|
||||
|
||||
def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details):
|
||||
db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details))
|
||||
@@ -223,8 +259,10 @@ class RecurrenceCreate(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode(self):
|
||||
if self.trigger_mode == "scheduled" and self.rrule is None:
|
||||
raise ValueError("scheduled recurrence requires rrule")
|
||||
if self.trigger_mode == "scheduled" and (
|
||||
self.rrule is None or self.after_completion_days is not None
|
||||
):
|
||||
raise ValueError("scheduled recurrence requires rrule and no completion interval")
|
||||
if self.trigger_mode == "after_completion" and (
|
||||
self.after_completion_days is None or self.rrule is not None
|
||||
):
|
||||
@@ -1209,6 +1247,16 @@ async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: As
|
||||
_ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/attachments")
|
||||
async def list_attachments(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
await owned_task(db, user.id, task_id)
|
||||
rows = (await db.scalars(select(Attachment).where(
|
||||
Attachment.task_id == task_id, Attachment.user_id == user.id
|
||||
).order_by(Attachment.created_at, Attachment.id))).all()
|
||||
return [{"id": row.id, "task_id": row.task_id, "filename": row.filename,
|
||||
"mime_type": row.mime_type, "size": row.size} for row in rows]
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments", status_code=201)
|
||||
async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
await owned_task(db, user.id, task_id)
|
||||
@@ -1349,7 +1397,11 @@ async def restore_csv(
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
text = (await file.read()).decode("utf-8-sig")
|
||||
try:
|
||||
raw_content = await _read_legacy_upload(file)
|
||||
text = raw_content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _legacy_error("legacy_backup_invalid", "无效的 Dodo CSV 备份") from exc
|
||||
payload = {
|
||||
"version": 1,
|
||||
"folders": [],
|
||||
@@ -1363,9 +1415,14 @@ async def restore_csv(
|
||||
try:
|
||||
for row in csv.DictReader(io.StringIO(text)):
|
||||
entity = row.get("entity", "")
|
||||
data = row.get("data")
|
||||
if entity not in payload or entity == "version":
|
||||
raise ValueError("unknown entity")
|
||||
payload[entity].append(json.loads(row["data"]))
|
||||
if not isinstance(data, str) or len(data.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES:
|
||||
raise ValueError("field too large")
|
||||
payload[entity].append(json.loads(data))
|
||||
if sum(len(payload[name]) for name in _LEGACY_ENTITIES) > LEGACY_BACKUP_MAX_RECORDS:
|
||||
raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多")
|
||||
except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(422, "无效的 Dodo CSV 备份") from exc
|
||||
return await restore_json(payload, mode, user, db)
|
||||
@@ -1436,16 +1493,12 @@ def _validate_countdown_backups(payload: dict) -> list[dict]:
|
||||
|
||||
@router.post("/restore")
|
||||
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
if mode == "replace":
|
||||
raise _legacy_error("legacy_replace_unsupported", "旧版备份仅支持合并恢复")
|
||||
if payload.get("version") != 1:
|
||||
raise HTTPException(422, "不支持的备份版本")
|
||||
_validate_legacy_payload_limits(payload)
|
||||
parsed_countdowns = _validate_countdown_backups(payload)
|
||||
if mode == "replace":
|
||||
await db.execute(delete(Memo).where(Memo.user_id == user.id))
|
||||
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
|
||||
await db.execute(delete(Task).where(Task.user_id == user.id))
|
||||
await db.execute(delete(Habit).where(Habit.user_id == user.id))
|
||||
await db.execute(delete(TaskList).where(TaskList.user_id == user.id))
|
||||
await db.execute(delete(Folder).where(Folder.user_id == user.id))
|
||||
id_map = {}
|
||||
task_id_map = {}
|
||||
for raw in payload.get("folders", []):
|
||||
|
||||
+55
-39
@@ -1,59 +1,75 @@
|
||||
# API
|
||||
|
||||
Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_session` Cookie。
|
||||
Base path: `/api/v1`。除初始化、登录和健康检查外,接口均要求 `dodo_session` Cookie;同源写请求使用双提交 CSRF 校验。登录后可访问 `/api/docs` 和 `/api/openapi.json`。
|
||||
|
||||
## Setup / Auth
|
||||
## 账户与会话
|
||||
|
||||
- `GET /setup/status`
|
||||
- `POST /setup/initialize`
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/logout`
|
||||
- `GET /me`
|
||||
- `GET /setup/status`、`POST /setup/initialize`
|
||||
- `POST /auth/login`、`POST /auth/logout`、`POST /auth/change-password`
|
||||
- `GET/PATCH /me`
|
||||
- `GET /sessions`、`DELETE /sessions/{session_id}`、`DELETE /sessions/others`
|
||||
- `GET /audit-logs`
|
||||
|
||||
## Folders
|
||||
## 文件夹、清单与任务
|
||||
|
||||
- `GET /folders` — 仅返回未删除文件夹
|
||||
- `POST /folders` — 创建文件夹
|
||||
- `PATCH /folders/{folder_id}` — 重命名
|
||||
- `DELETE /folders/{folder_id}` — 软删除;其清单移到根层级
|
||||
- 文件夹:`GET/POST /folders`,`PATCH/DELETE /folders/{id}`,以及排序接口
|
||||
- 清单:`GET/POST /lists`,`PATCH/DELETE /lists/{id}`,归档恢复、永久删除、移动与排序接口
|
||||
- 归档清单只标记 `deleted_at`,不会把任务移到收集箱;清单归档期间,其任务在普通列表和详情中不可见,恢复清单后重新出现
|
||||
- 任务:`GET/POST /tasks`、`GET/PATCH/DELETE /tasks/{id}`、`POST /tasks/{id}/restore`、`DELETE /trash/{id}`
|
||||
- `GET /tasks` 和 `GET /trash` 使用不透明游标;`limit` 为 1–100
|
||||
- `POST /tasks/batch` 支持批量完成、移动、截止时间与软删除;任务写入使用 `version` 乐观锁
|
||||
- 一层子任务必须与父任务同清单;任务可带日期型或具体时间型截止时间
|
||||
|
||||
## Lists
|
||||
## 重复任务
|
||||
|
||||
- `GET /lists` — 仅返回未删除清单,系统收集箱排首位
|
||||
- `POST /lists` — 创建清单,可指定 `folder_id`
|
||||
- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409
|
||||
- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409
|
||||
- `GET /tasks/{task_id}/recurrence`
|
||||
- `POST /recurrences`、`PATCH/DELETE /recurrences/{id}`
|
||||
- `POST /recurrences/{id}/complete`
|
||||
- 支持 RRULE 计划重复与按用户本地完成日期计算的“完成后重复”
|
||||
|
||||
## Tasks
|
||||
## 习惯、倒数日与备忘录
|
||||
|
||||
- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述和清单名
|
||||
- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务
|
||||
- `GET /tasks/{task_id}` — 返回任务和一层子任务
|
||||
- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,原子比较更新;版本冲突返回 409
|
||||
- `DELETE /tasks/{task_id}` — 软删除任务及其直接子任务
|
||||
- `POST /tasks/{task_id}/restore` — 恢复任务及其直接子任务
|
||||
- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间或软删除
|
||||
- 习惯:创建、列表/周网格、部分更新、排序、归档/恢复/永久删除、日志、暂停和统计
|
||||
- 倒数日:创建、列表、编辑、置顶、归档/恢复/永久删除;支持公历/农历及周/月/年重复
|
||||
- 备忘录:游标列表、创建、读取、乐观锁更新、软删除、恢复和归档后永久删除
|
||||
|
||||
批量请求字段:`task_ids`、`completed`、`list_id`、`due_at`、`soft_delete`。所有任务和目标清单在写入前完成归属校验;任一不存在则整批不修改。
|
||||
## 附件
|
||||
|
||||
## Countdowns
|
||||
- `GET/POST /tasks/{task_id}/attachments`
|
||||
- `GET/DELETE /attachments/{attachment_id}`
|
||||
- 文件保存在服务端附件目录;上传受大小与类型限制,访问始终校验当前用户归属
|
||||
|
||||
- `GET /countdowns?archived=false` — 查询倒数日;置顶项优先,其余按下一次发生日期排序
|
||||
- `POST /countdowns` — 创建倒数日、纪念日或生日;支持 `none/weekly/monthly/yearly` 重复
|
||||
- `PATCH /countdowns/{countdown_id}` — 编辑名称、日期、类型、重复与图标
|
||||
- `POST /countdowns/{countdown_id}/pin` — 单一置顶,自动取消其他置顶项
|
||||
- `DELETE /countdowns/{countdown_id}` — 归档
|
||||
- `POST /countdowns/{countdown_id}/restore` — 恢复归档项
|
||||
- `DELETE /countdowns/{countdown_id}/purge` — 永久删除已归档项
|
||||
## 完整备份 ZIP v2
|
||||
|
||||
## Recycle bin
|
||||
### `GET /backup/export.zip`
|
||||
|
||||
- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页
|
||||
- `DELETE /trash/{task_id}` — 永久删除任务及其子任务
|
||||
导出 `dodo-backup` version 2 ZIP。归档包含 `manifest.json`、每类实体的 `data/*.json`、附件元数据和附件原始字节。manifest 声明实体数量及每个条目的 SHA-256。
|
||||
|
||||
游标是不透明字符串。无效游标返回 422;`limit` 范围为 1–100。
|
||||
实体范围:`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。会话、密码散列、审计日志及备份内部账本不导出。
|
||||
|
||||
## Health
|
||||
### `POST /backup/preflight?mode=merge|replace`
|
||||
|
||||
以 multipart 字段 `file` 上传 ZIP。服务端流式暂存,并在返回令牌前验证:ZIP 路径与条目、压缩比/容量、manifest 版本与计数、全部校验和、字段与业务约束、关系拓扑、一层任务树、用户隔离以及附件元数据/字节一致性。
|
||||
|
||||
成功返回 `valid`、短期 `preflight_token`、`backup_id`、归档摘要和各实体数量。预检有每用户待处理数量/容量配额和过期时间;令牌绑定用户、文件摘要和恢复模式。
|
||||
|
||||
### `POST /backup/restore`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{"preflight_token":"...","mode":"merge"}
|
||||
```
|
||||
|
||||
`merge` 使用持久化 source→target ID/内容摘要账本实现可重试合并;跨用户或同 ID 不同内容冲突会拒绝。`replace` 在事务内替换当前用户业务实体,并通过同文件系统隔离区协调附件删除和失败补偿。令牌单次消费;若数据库已提交但隔离区清理失败,同一令牌仅重试清理,不重复导入。
|
||||
|
||||
## 旧格式兼容
|
||||
|
||||
- `GET /export`、`GET /export.csv`:旧版 JSON/UTF-8-BOM CSV v1 轻量导出,不是完整备份
|
||||
- `POST /restore?mode=merge|replace`、`POST /restore.csv?mode=merge|replace`:兼容旧 JSON/CSV v1
|
||||
- 旧格式只覆盖文件夹、清单、任务、重复模板、习惯、倒数日和备忘录;不包含日志、暂停、重复例外和附件字节
|
||||
|
||||
## 健康检查
|
||||
|
||||
- `GET /health/live`
|
||||
- `GET /health/ready`
|
||||
|
||||
+29
-80
@@ -1,97 +1,46 @@
|
||||
# dodo 数据模型
|
||||
|
||||
所有业务实体使用 UUIDv7 主键并按 `user_id` 隔离。生产使用 PostgreSQL,测试使用 SQLite;模型保持两者兼容。
|
||||
业务主键使用 UUIDv7;带 `user_id` 的实体按用户隔离。时间点按 UTC 保存,用户时区用于日期语义与展示。生产使用 PostgreSQL,测试使用 SQLite。
|
||||
|
||||
## app_state
|
||||
## 账户
|
||||
|
||||
- `key` 主键
|
||||
- `created_at`
|
||||
- `app_state`:初始化状态
|
||||
- `users`:用户名、密码散列、时区
|
||||
- `sessions`:会话令牌散列、过期/最近访问时间、IP 与 User-Agent
|
||||
- `audit_logs`:用户、动作、实体、非敏感摘要和时间
|
||||
|
||||
## users
|
||||
## 任务域
|
||||
|
||||
- `id`
|
||||
- `username` 唯一
|
||||
- `password_hash`
|
||||
- `timezone`
|
||||
- `created_at`
|
||||
- `folders`:名称、位置、软删除时间;删除文件夹只解除清单分组
|
||||
- `task_lists`:文件夹、名称、收集箱标记、位置、软删除时间
|
||||
- `tasks`:清单、可空父任务、标题、Markdown 描述、优先级、完成/完成时间、截止时间、`due_has_time`、位置、版本、软删除与外部 ID
|
||||
- `recurrence_templates`:任务的一对一重复规则、开始/结束、计划或完成后触发模式、完成后间隔与最近完成时间
|
||||
- `recurrence_exceptions`:模板发生时间及标题/截止/完成/删除覆盖
|
||||
- `attachments`:任务、原始文件名、服务端存储名、MIME、大小和创建时间
|
||||
- `purge_operations`:清单永久删除时附件隔离区清理的补偿状态
|
||||
|
||||
## sessions
|
||||
任务树只允许一层,父子任务属于同一清单。清单归档仅设置清单 `deleted_at`,保留所有任务的 `list_id`;查询隐藏归档清单内任务,恢复清单后原任务和完成状态重新可见。任务与子任务软删除/恢复按生命周期规则处理,永久删除会清理关联重复数据和附件。
|
||||
|
||||
- `id`
|
||||
- `token_hash` 唯一
|
||||
- `user_id` → users,级联删除
|
||||
- `expires_at`
|
||||
- `created_at`
|
||||
## 习惯、倒数日与备忘录
|
||||
|
||||
## folders
|
||||
- `habits`:完成型/数值型、目标/上限、日/周/月/间隔计划、开始日、排序和归档
|
||||
- `habit_logs`:习惯与日期唯一的数值记录
|
||||
- `habit_pauses`:习惯暂停区间
|
||||
- `countdowns`:标题、日期、公历/农历字段、类型、重复、兼容保留的图标字段、置顶与归档
|
||||
- `memos`:标题、Markdown 内容、乐观锁版本、创建/更新时间和软删除时间
|
||||
|
||||
- `id`
|
||||
- `user_id` → users
|
||||
- `name`
|
||||
- `position`
|
||||
- `created_at`
|
||||
- `deleted_at`,非空表示软删除
|
||||
## 完整备份 v2 内部状态
|
||||
|
||||
删除文件夹不会删除清单;清单的 `folder_id` 被置空。
|
||||
- `backup_preflights`:令牌散列、用户、备份 ID/摘要/大小、暂存路径、模式、状态、过期/消费时间和待清理路径
|
||||
- `backup_imports`:每用户已导入备份 ID、归档摘要、模式和时间;用于幂等判断
|
||||
- `backup_import_entities`:源实体 ID 到目标 ID 的映射及内容摘要;用于 merge 冲突检测与可重试导入
|
||||
|
||||
## task_lists
|
||||
这些表和账户/会话/审计信息不属于用户可迁移业务实体。ZIP v2 只导出:
|
||||
|
||||
- `id`
|
||||
- `user_id` → users
|
||||
- `folder_id` → folders,可空
|
||||
- `name`
|
||||
- `is_inbox`,每个用户初始化时创建一个受保护的系统收集箱
|
||||
- `position`
|
||||
- `created_at`
|
||||
- `deleted_at`,非空表示软删除
|
||||
`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。
|
||||
|
||||
删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除。
|
||||
|
||||
## tasks
|
||||
|
||||
- `id`
|
||||
- `user_id` → users
|
||||
- `list_id` → task_lists
|
||||
- `parent_id` → tasks,可空;仅允许一层子任务且必须与父任务同清单
|
||||
- `title`
|
||||
- `description`
|
||||
- `priority`(0–3)
|
||||
- `completed`
|
||||
- `due_at`,可空
|
||||
- `version`,乐观锁版本;单任务更新用 `id + user_id + version` 原子比较更新
|
||||
- `position`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `deleted_at`,非空表示进入回收站
|
||||
|
||||
顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。
|
||||
|
||||
## countdowns
|
||||
|
||||
- `id`
|
||||
- `user_id` → users,级联删除
|
||||
- `title`
|
||||
- `event_date`,仅日期
|
||||
- `kind`:`countdown` / `anniversary` / `birthday`
|
||||
- `repeat_rule`:`none` / `weekly` / `monthly` / `yearly`
|
||||
- `icon`
|
||||
- `pinned`,每个用户仅保留一个置顶项
|
||||
- `archived_at`,非空表示归档
|
||||
- `created_at` / `updated_at`
|
||||
附件导出时去掉内部 `storage_name`,改用归档内安全路径并携带真实字节;恢复时生成目标存储名。每个实体文件和附件字节均由 manifest SHA-256 覆盖。
|
||||
|
||||
## 迁移
|
||||
|
||||
- `0001_initial.py`:已部署的初始模式,不修改
|
||||
- `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引
|
||||
- `0008_remove_calendar_subscriptions.py`:移除日历订阅表
|
||||
- `0009_remove_tags.py`:移除历史标签表及任务标签关联表
|
||||
- `0010_countdowns.py`:新增倒数日、纪念日与生日表
|
||||
|
||||
## 后续阶段预留
|
||||
|
||||
- task_reminders
|
||||
- task_recurrence_templates
|
||||
- task_recurrence_exceptions
|
||||
- habits / habit_logs / habit_reminders
|
||||
- attachments
|
||||
- audit_logs
|
||||
迁移按 `0001` 至 `0019` 顺序应用;当前最新 `0019_backup_imports.py` 增加完整备份预检、导入及实体映射账本。历史迁移还覆盖任务管理、会话元数据、查询索引、习惯排序、倒数日/农历、日期型截止语义、重复触发模式、备忘录与 `completed_at` 等演进。
|
||||
|
||||
+31
-102
@@ -1,117 +1,46 @@
|
||||
# dodo 产品与技术决策记录
|
||||
|
||||
## 定位
|
||||
## 定位与当前范围
|
||||
|
||||
dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标不是一比一复刻 TickTick,而是做一个数据归自己、界面温暖紧凑、适合个人长期使用的任务系统。
|
||||
dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道。
|
||||
|
||||
## 当前边界
|
||||
## 技术与数据
|
||||
|
||||
- 首版只做本地开发验证,不部署。
|
||||
- 首版不做通知渠道:Web Push、Telegram、SMTP 暂不实现。
|
||||
- 首版附件只做本地存储,不实现 S3。
|
||||
- 首版不做番茄钟。
|
||||
- 首版不做自然语言创建任务。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Monorepo:`frontend/`、`backend/`
|
||||
- 前端:Vue 3 + TypeScript + Vite + Tailwind CSS + Shadcn-vue/Reka UI
|
||||
- 后端:FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
|
||||
- 数据库:PostgreSQL,主键 UUIDv7,时间统一 UTC,用户配置时区
|
||||
- 包管理:uv + pnpm
|
||||
- 交付:单 Docker 镜像,外部 PostgreSQL
|
||||
- 许可证:AGPL-3.0
|
||||
|
||||
## 数据库
|
||||
|
||||
本地开发数据库:
|
||||
|
||||
```text
|
||||
postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo
|
||||
```
|
||||
|
||||
已从默认 `postgres` 库迁移到独立 `dodo` 库。误建在 `postgres.public` 的 dodo 表已清理。
|
||||
- Monorepo:Vue 3 + TypeScript + Vite 前端,FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic 后端
|
||||
- PostgreSQL 生产、SQLite 测试;UUIDv7 主键,时间点使用 UTC,日历语义使用用户时区
|
||||
- 单 Docker 镜像,外部 PostgreSQL;AGPL-3.0
|
||||
- 用户业务读写必须按归属过滤;更新任务/备忘录使用乐观锁
|
||||
|
||||
## 产品模型
|
||||
|
||||
### 任务
|
||||
- 文件夹 → 清单 → 一层任务树;系统收集箱受保护
|
||||
- 清单删除定义为归档:保留任务成员关系,归档期间隐藏,恢复后原样出现;永久删除仅允许作用于已归档清单
|
||||
- 截止日期区分全天日期和具体时间;重复任务支持 RFC 5545 计划重复及“完成后重复”
|
||||
- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档
|
||||
- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档
|
||||
- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除
|
||||
|
||||
- 文件夹 → 清单 → 任务
|
||||
- 系统内置收集箱,不允许删除
|
||||
- 任务支持一层子任务
|
||||
- 状态:未完成 / 已完成
|
||||
- 优先级:无 / 低 / 中 / 高
|
||||
- Markdown 描述
|
||||
- 截止日期 + 具体时间
|
||||
- 多提醒设计预留
|
||||
- 删除为软删除,回收站手动清空
|
||||
- 支持创建、修改、完成、恢复、删除操作历史
|
||||
- 并发编辑使用原子乐观锁
|
||||
## UI 决策
|
||||
|
||||
### 重复任务
|
||||
- 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航
|
||||
- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转
|
||||
- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物
|
||||
- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作
|
||||
- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复
|
||||
- 桌面任务/备忘录详情可保持非模态,移动端由同一组件切为底部模态弹层
|
||||
|
||||
- RFC 5545 RRULE
|
||||
- 模板 + 实例
|
||||
- 修改范围:仅本次 / 本次及以后 / 全部
|
||||
- 删除单次保存为例外
|
||||
- 每月 31 日在无 31 日月份跳过
|
||||
- 逾期完成不影响下次计划日期
|
||||
## 备份决策
|
||||
|
||||
### 习惯
|
||||
|
||||
- 完成型 + 数值型
|
||||
- 每天 / 每周 / 每月 / 间隔天数
|
||||
- 数值型当日累计,达标后封顶
|
||||
- 允许补打和修改历史
|
||||
- 支持暂停区间,暂停期不破坏连续记录
|
||||
- 归档后保留历史统计
|
||||
|
||||
### 倒数纪念日
|
||||
|
||||
- 支持倒数日、纪念日、生日
|
||||
- 支持不重复、每周、每月、每年重复
|
||||
- 未来显示“还有 N 天”,当天显示“就是今天”,过去显示“已经 N 天”
|
||||
- 支持单一置顶、归档恢复、编辑和删除
|
||||
|
||||
## UI 方向
|
||||
|
||||
- 手账生活感
|
||||
- 中高信息密度
|
||||
- 10–12px 中等圆角
|
||||
- 细分割线为主,少量浅底色
|
||||
- 强调色:`#F15A29`
|
||||
- 只做浅色模式
|
||||
- 系统字体栈
|
||||
- 不使用猫猫元素
|
||||
- 轻微动效
|
||||
|
||||
## 页面结构
|
||||
|
||||
- 桌面三栏:左导航 / 中任务列表 / 右任务详情
|
||||
- 手机底部导航
|
||||
- 顶部快速输入,手机悬浮新增按钮
|
||||
- 桌面右侧详情栏,手机底部弹层
|
||||
- 习惯首页:今日习惯列表 + 一周打卡格
|
||||
- 搜索:顶部搜索框 + 全局搜索快捷键
|
||||
- “完整备份”专指 `dodo-backup` ZIP version 2,而不是旧 JSON/CSV
|
||||
- v2 覆盖全部用户业务实体、历史/例外、附件元数据与附件字节;manifest 记录实体数量和每个条目的 SHA-256
|
||||
- 恢复必须先预检,再用绑定用户、文件摘要和模式的短期单次令牌执行
|
||||
- 预检拒绝未知/缺失实体、不安全 ZIP 路径、重复条目、异常压缩比/容量、校验和错误、非法字段、破坏关系拓扑或一层任务树的数据
|
||||
- `merge` 通过持久化 ID/摘要账本保证幂等与冲突可见;`replace` 仅替换当前用户业务数据
|
||||
- 附件恢复采用同文件系统暂存/隔离与补偿;数据库提交后的清理失败可用同一令牌重试清理,不会再次导入
|
||||
- 保留 JSON/CSV v1 恢复兼容,但明确其不包含日志、暂停、重复例外和附件字节,仅用于旧数据迁移
|
||||
|
||||
## 工程质量
|
||||
|
||||
- `/api/v1` API 路径
|
||||
- 统一错误码、可读提示和字段详情
|
||||
- 页面内诊断信息 + Toast
|
||||
- readiness 检查数据库
|
||||
- 首版不提供 Prometheus metrics
|
||||
- 审计日志记录操作人、实体、动作、时间和变更摘要
|
||||
- 手动 JSON 全量导出
|
||||
- 附件默认 20MB,可用环境变量调整
|
||||
- 默认允许常用文档与图片,拒绝危险文件类型
|
||||
|
||||
## 第一阶段验收
|
||||
|
||||
- 可初始化管理员
|
||||
- 可登录
|
||||
- 可创建清单和任务
|
||||
- 后端测试通过
|
||||
- 前端生产构建通过
|
||||
- PostgreSQL 迁移成功
|
||||
- 本地应用可以启动并访问
|
||||
- API 基路径 `/api/v1`;Cookie Session、同源 CSRF、安全响应头、登录限流
|
||||
- 附件与备份均有容量限制、路径包含检查和用户归属校验
|
||||
- 后端使用 pytest + ruff,前端使用 Vitest + vue-tsc/Vite;变更结束运行全量测试、构建和 `git diff --check`
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
async function csrf(request: APIRequestContext, baseURL: string) {
|
||||
const state = await request.storageState()
|
||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
|
||||
?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value
|
||||
?? ''
|
||||
}
|
||||
|
||||
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
|
||||
const token = await csrf(request, baseURL)
|
||||
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } })
|
||||
}
|
||||
|
||||
test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = testInfo.project.name
|
||||
const taskTitle = `E2E 备份任务 ${suffix}`
|
||||
const habitName = `E2E 备份习惯 ${suffix}`
|
||||
const countdownTitle = `E2E 备份倒数日 ${suffix}`
|
||||
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||
expect(bootstrap.ok()).toBeTruthy()
|
||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } })
|
||||
expect(task.ok()).toBeTruthy()
|
||||
const taskData = await task.json() as { id: string; version: number }
|
||||
const attachmentName = `原始附件-${suffix}.txt`
|
||||
const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255])
|
||||
const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, {
|
||||
method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } },
|
||||
})
|
||||
expect(attachmentResponse.ok()).toBeTruthy()
|
||||
const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string }
|
||||
expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' })
|
||||
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } })
|
||||
expect(habitResponse.ok()).toBeTruthy()
|
||||
const habit = await habitResponse.json()
|
||||
const day = new Date().toLocaleDateString('sv-SE')
|
||||
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy()
|
||||
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } })
|
||||
expect(countdownResponse.ok()).toBeTruthy()
|
||||
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
||||
const download = await downloadPromise
|
||||
const zipPath = await download.path()
|
||||
expect(zipPath).not.toBeNull()
|
||||
const zipBytes = new Uint8Array(await readFile(zipPath!))
|
||||
const files = unzipSync(zipBytes)
|
||||
const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record<string, number>; checksums: Record<string, string> }
|
||||
expect(manifest.format).toBe('dodo-backup')
|
||||
expect(manifest.version).toBe(2)
|
||||
expect(manifest.entities.tasks).toBeGreaterThan(0)
|
||||
expect(manifest.entities.habit_logs).toBeGreaterThan(0)
|
||||
expect(manifest.entities.attachments).toBeGreaterThan(0)
|
||||
const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }>
|
||||
const archivedAttachment = attachmentRows.find(item => item.id === attachment.id)
|
||||
expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||
expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes)
|
||||
expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex'))
|
||||
for (const [entry, digest] of Object.entries(manifest.checksums)) {
|
||||
expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy()
|
||||
expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest)
|
||||
}
|
||||
|
||||
expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([])
|
||||
|
||||
const chooser = page.locator('input[type=file]')
|
||||
await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) })
|
||||
await page.getByLabel('恢复方式').selectOption('replace')
|
||||
await page.getByRole('button', { name: '开始预检' }).click()
|
||||
const preflight = page.locator('.backup-preflight')
|
||||
await expect(preflight).toContainText('预检通过')
|
||||
await expect(preflight).toContainText('附件')
|
||||
await page.getByRole('button', { name: '替换并恢复' }).click()
|
||||
const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' })
|
||||
await confirm.getByRole('button', { name: '确认', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('数据已恢复')
|
||||
|
||||
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
|
||||
expect(restoredTasksResponse.ok()).toBeTruthy()
|
||||
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
|
||||
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
|
||||
const restoredTask = restoredTasks.find(item => item.title === taskTitle)!
|
||||
const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`)
|
||||
expect(restoredAttachmentsResponse.ok()).toBeTruthy()
|
||||
const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }>
|
||||
expect(restoredAttachments).toHaveLength(1)
|
||||
expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||
const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`)
|
||||
expect(restoredBlob.ok()).toBeTruthy()
|
||||
expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes)
|
||||
await bottomTab(page, '习惯').click()
|
||||
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
|
||||
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
|
||||
await expect(page.locator('.habit-history__row')).toContainText('2 / 2')
|
||||
await page.getByRole('button', { name: '关闭习惯详情' }).click()
|
||||
await bottomTab(page, '倒数日').click()
|
||||
await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test as base } from '@playwright/test'
|
||||
|
||||
export const expectedErrors = new WeakMap<object, Set<string>>()
|
||||
export function allowExpectedError(page: object, fragment: string) {
|
||||
expectedErrors.get(page)?.add(fragment)
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const failures: string[] = []
|
||||
const allowed = new Set<string>()
|
||||
expectedErrors.set(page, allowed)
|
||||
const record = (message: string) => {
|
||||
if (![...allowed].some(pattern => message.includes(pattern))) failures.push(message)
|
||||
}
|
||||
page.on('pageerror', error => record(`pageerror: ${error.message}`))
|
||||
page.on('console', message => { if (message.type() === 'error') record(`console.error: ${message.text()}`) })
|
||||
page.on('requestfailed', request => record(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`))
|
||||
page.on('response', response => {
|
||||
const url = new URL(response.url())
|
||||
const baseURL = new URL(page.url() || 'http://127.0.0.1:5173')
|
||||
if (url.origin === baseURL.origin && response.status() >= 400) record(`http ${response.status()}: ${response.request().method()} ${url.pathname}`)
|
||||
})
|
||||
await use(page)
|
||||
expect(failures, 'unexpected browser/runtime errors').toEqual([])
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { chromium, type FullConfig } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const projectName = process.env.DODO_E2E_PROJECT
|
||||
if (!projectName) throw new Error('DODO_E2E_PROJECT is required')
|
||||
const baseURL = config.projects[0]?.use.baseURL as string
|
||||
const storageState = path.resolve('playwright-runtime', projectName, 'auth.json')
|
||||
mkdirSync(path.dirname(storageState), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const context = await browser.newContext({ baseURL })
|
||||
const page = await context.newPage()
|
||||
const readyDeadline = Date.now() + 90_000
|
||||
let ready = false
|
||||
while (Date.now() < readyDeadline) {
|
||||
try {
|
||||
const [health, frontend, proxy] = await Promise.all([
|
||||
page.request.get('/health/ready'), page.request.get('/'), page.request.get('/api/v1/setup/status'),
|
||||
])
|
||||
if (health.ok() && frontend.ok() && proxy.ok()) { ready = true; break }
|
||||
} catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
if (!ready) throw new Error('isolated frontend/backend/proxy did not become ready')
|
||||
const initialized = await page.request.get('/api/v1/setup/status')
|
||||
if (!initialized.ok()) throw new Error(`setup status failed: ${initialized.status()}`)
|
||||
if ((await initialized.json()).initialized) throw new Error(`isolated ${projectName} runtime was already initialized`)
|
||||
const response = await page.request.post('/api/v1/setup/initialize', { data: { username: `e2e-owner-${projectName}`, password: 'e2e-password-1234' } })
|
||||
if (!response.ok()) throw new Error(`initialize failed: ${response.status()} ${await response.text()}`)
|
||||
await context.storageState({ path: storageState })
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { allowExpectedError, expect, test } from './fixtures'
|
||||
|
||||
async function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
function taskRow(page: Page, title: string) {
|
||||
return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||
}
|
||||
|
||||
async function assertInsideViewport(locator: Locator, page: Page) {
|
||||
const box = await locator.boundingBox()
|
||||
const viewport = page.viewportSize()
|
||||
expect(box).not.toBeNull()
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
|
||||
}
|
||||
|
||||
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
|
||||
const title = `E2E 今日任务 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
await page.getByLabel('任务名称').fill(title)
|
||||
await page.getByRole('button', { name: '添加任务', exact: true }).click()
|
||||
|
||||
const row = taskRow(page, title)
|
||||
await expect(row).toHaveCount(1)
|
||||
const rowMain = row.locator('.task-main')
|
||||
await rowMain.click()
|
||||
const detail = page.getByRole('dialog', { name: '任务详情' })
|
||||
await expect(detail).toBeVisible()
|
||||
await assertInsideViewport(detail, page)
|
||||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||
await expect(rowMain).toBeFocused()
|
||||
|
||||
await row.getByRole('button', { name: `完成${title}` }).click()
|
||||
await expect(row).toHaveClass(/done/)
|
||||
await page.reload()
|
||||
const persisted = taskRow(page, title)
|
||||
await expect(persisted).toHaveCount(1)
|
||||
await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible()
|
||||
await persisted.getByRole('button', { name: `重新打开${title}` }).click()
|
||||
await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible()
|
||||
})
|
||||
|
||||
test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => {
|
||||
const name = `E2E 数量习惯 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await (await bottomTab(page, '习惯')).click()
|
||||
await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page')
|
||||
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||
await page.getByLabel('新习惯名称').fill(name)
|
||||
await page.getByLabel('习惯类型').selectOption('numeric')
|
||||
await page.getByLabel('目标值').fill('2')
|
||||
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
|
||||
|
||||
const row = page.locator('.habit-row').filter({ hasText: name })
|
||||
await expect(row).toHaveCount(1)
|
||||
const check = row.getByRole('button', { name: `完成${name}一次` })
|
||||
await check.click()
|
||||
await expect(row).toContainText('1 / 2')
|
||||
await check.click()
|
||||
await expect(row).toContainText('2 / 2')
|
||||
await row.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||
const detail = page.getByRole('dialog', { name: name })
|
||||
await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible()
|
||||
await expect(detail.locator('.habit-history__row')).toContainText('2 / 2')
|
||||
await detail.getByRole('button', { name: '编辑习惯' }).click()
|
||||
await page.getByLabel('目标值').fill('3')
|
||||
await page.getByRole('button', { name: '保存修改' }).click()
|
||||
await expect(row).toContainText('2 / 3')
|
||||
await row.getByRole('button', { name: `完成${name}一次` }).click()
|
||||
await expect(row).toContainText('3 / 3')
|
||||
await page.reload()
|
||||
const persistedRow = page.locator('.habit-row').filter({ hasText: name })
|
||||
await expect(persistedRow).toContainText('3 / 3')
|
||||
await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||
const persistedDetail = page.getByRole('dialog', { name })
|
||||
await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3')
|
||||
})
|
||||
|
||||
test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => {
|
||||
const title = `E2E 倒数日 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await (await bottomTab(page, '倒数日')).click()
|
||||
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||
await page.getByLabel('倒数日名称').fill(title)
|
||||
await page.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
const item = page.getByRole('button').filter({ hasText: title })
|
||||
await expect(item).toHaveCount(1)
|
||||
await item.click()
|
||||
const detail = page.getByRole('dialog', { name: title })
|
||||
await expect(detail).toBeVisible()
|
||||
// The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet.
|
||||
allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/')
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204),
|
||||
detail.getByRole('button', { name: '归档' }).click(),
|
||||
])
|
||||
await page.getByRole('button', { name: /已归档(1)/ }).click()
|
||||
const archived = page.locator('.archived-countdowns article').filter({ hasText: title })
|
||||
await expect(archived).toHaveCount(1)
|
||||
await archived.getByRole('button', { name: '恢复' }).click()
|
||||
await page.reload()
|
||||
await expect(page.getByText(title, { exact: true })).toHaveCount(1)
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
|
||||
function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
async function openTaskComposer(page: Page) {
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
|
||||
}
|
||||
|
||||
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
||||
const groups = page.locator('.settings-group')
|
||||
await expect(groups).toHaveCount(5)
|
||||
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
|
||||
return {
|
||||
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom),
|
||||
}
|
||||
})
|
||||
expect(layout.bodyOverflow).toBe(0)
|
||||
expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy()
|
||||
const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ })
|
||||
for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) {
|
||||
const box = await target.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
for (const target of await sessionButtons.all()) {
|
||||
const box = await target.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
})
|
||||
|
||||
test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => {
|
||||
await page.goto('/')
|
||||
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.locator('#app')).toHaveAttribute('inert', '')
|
||||
const metrics = await dialog.evaluate(element => {
|
||||
const panel = element as HTMLElement
|
||||
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
|
||||
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer, footer')
|
||||
return {
|
||||
horizontalOverflow: panel.scrollWidth - panel.clientWidth,
|
||||
headerVisible: !header || header.getBoundingClientRect().top >= 0,
|
||||
footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1,
|
||||
}
|
||||
})
|
||||
expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true })
|
||||
}
|
||||
|
||||
await openTaskComposer(page)
|
||||
await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ }))
|
||||
await page.keyboard.press('Escape')
|
||||
await bottomTab(page, '习惯').click()
|
||||
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '添加习惯' }))
|
||||
await page.keyboard.press('Escape')
|
||||
await bottomTab(page, '倒数日').click()
|
||||
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '新建倒数日' }))
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||
await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click()
|
||||
await page.getByRole('button', { name: '添加备忘录' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '备忘录详情' }))
|
||||
await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`)
|
||||
await page.getByRole('button', { name: '关闭备忘录' }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' })
|
||||
await assertModal(confirmation)
|
||||
await confirmation.getByRole('button', { name: '取消' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible()
|
||||
await expect(page.getByLabel('备忘录标题')).toBeFocused()
|
||||
})
|
||||
|
||||
test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const opener = page.getByRole('button', { name: '添加任务' })
|
||||
await opener.focus()
|
||||
const dialog = await openTaskComposer(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.getByLabel('任务名称')).toBeFocused()
|
||||
const background = page.locator('#app')
|
||||
await expect(background).toHaveAttribute('aria-hidden', 'true')
|
||||
await expect(background).toHaveAttribute('inert', '')
|
||||
const geometry = await page.locator('.app-overlay').evaluate(element => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(2, 2)
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element }
|
||||
})
|
||||
expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true })
|
||||
|
||||
const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true })
|
||||
const first = focusable.first()
|
||||
const last = focusable.last()
|
||||
await first.focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(last).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(first).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(opener).toBeFocused()
|
||||
|
||||
await openTaskComposer(page)
|
||||
await page.mouse.click(2, 2)
|
||||
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden()
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run --exclude 'e2e/**'","test:e2e:mobile":"node scripts/playwright-mobile.mjs"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@playwright/test":"^1.63.0","@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","fflate":"^0.8.3","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectName = process.env.DODO_E2E_PROJECT
|
||||
if (!projectName) throw new Error('DODO_E2E_PROJECT is required; use pnpm test:e2e:mobile')
|
||||
const project = projectName === 'mobile-390'
|
||||
? { name: 'mobile-390', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } }
|
||||
: projectName === 'mobile-375'
|
||||
? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }
|
||||
: null
|
||||
if (!project) throw new Error(`unknown DODO_E2E_PROJECT: ${projectName}`)
|
||||
|
||||
const runtimeRoot = path.resolve('playwright-runtime', projectName)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
outputDir: path.join(runtimeRoot, 'test-results'),
|
||||
reporter: [['line'], ['html', { outputFolder: path.join(runtimeRoot, 'report'), open: 'never' }]],
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
storageState: path.join(runtimeRoot, 'auth.json'),
|
||||
reducedMotion: 'reduce',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [project],
|
||||
webServer: {
|
||||
command: 'node scripts/playwright-mobile-server.mjs',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
})
|
||||
Generated
+36
@@ -39,6 +39,9 @@ importers:
|
||||
specifier: latest
|
||||
version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: latest
|
||||
version: 4.3.3([email protected](@types/[email protected])([email protected]))
|
||||
@@ -48,6 +51,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: latest
|
||||
version: 26.4.1
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
jsdom:
|
||||
specifier: ^30.0.1
|
||||
version: 30.0.1
|
||||
@@ -180,6 +186,11 @@ packages:
|
||||
'@oxc-project/[email protected]':
|
||||
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -614,6 +625,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -878,6 +892,16 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1303,6 +1327,10 @@ snapshots:
|
||||
|
||||
'@oxc-project/[email protected]': {}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -1674,6 +1702,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -1901,6 +1931,12 @@ snapshots:
|
||||
exsolve: 1.1.1
|
||||
pathe: 2.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const frontend = process.cwd()
|
||||
const root = path.resolve(frontend, '..')
|
||||
const project = process.env.DODO_E2E_PROJECT
|
||||
if (!project) throw new Error('DODO_E2E_PROJECT is required')
|
||||
const runtimeBase = path.join(frontend, 'playwright-runtime', project)
|
||||
const runtime = path.join(runtimeBase, `run-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`)
|
||||
const attachments = path.join(runtime, 'attachments')
|
||||
const staging = path.join(runtime, 'backup-staging')
|
||||
mkdirSync(attachments, { recursive: true })
|
||||
mkdirSync(staging, { recursive: true })
|
||||
writeFileSync(path.join(runtimeBase, 'latest.json'), JSON.stringify({ runtime, database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||
writeFileSync(path.join(runtime, 'runtime.json'), JSON.stringify({ database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||
|
||||
const logs = {
|
||||
backend: createWriteStream(path.join(runtime, 'backend.log'), { flags: 'a' }),
|
||||
frontend: createWriteStream(path.join(runtime, 'frontend.log'), { flags: 'a' }),
|
||||
}
|
||||
const children = []
|
||||
function start(command, args, options, log) {
|
||||
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
child.stdout.pipe(log)
|
||||
child.stderr.pipe(log)
|
||||
children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
start(path.join(root, '.venv/bin/uvicorn'), ['backend.main:app', '--host', '127.0.0.1', '--port', '8781'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
DODO_DATABASE_URL: `sqlite+aiosqlite:///${path.join(runtime, 'dodo.sqlite3')}`,
|
||||
DODO_AUTO_CREATE_SCHEMA: 'true',
|
||||
DODO_COOKIE_SECURE: 'false',
|
||||
DODO_ATTACHMENT_DIR: attachments,
|
||||
DODO_BACKUP_STAGING_DIR: staging,
|
||||
},
|
||||
}, logs.backend)
|
||||
start('pnpm', ['exec', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { cwd: frontend, env: process.env }, logs.frontend)
|
||||
|
||||
let stopping = false
|
||||
function stop(signal = 'SIGTERM') {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
for (const child of children) if (!child.killed) child.kill(signal)
|
||||
setTimeout(() => { for (const child of children) if (!child.killed) child.kill('SIGKILL') }, 3000).unref()
|
||||
}
|
||||
process.on('SIGTERM', () => stop())
|
||||
process.on('SIGINT', () => stop())
|
||||
process.on('exit', () => stop())
|
||||
|
||||
await Promise.all(children.map(child => new Promise((resolve, reject) => {
|
||||
child.once('exit', (code, signal) => stopping ? resolve() : reject(new Error(`server exited code=${code} signal=${signal}`)))
|
||||
})))
|
||||
@@ -0,0 +1,15 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const projects = ['mobile-390', 'mobile-375']
|
||||
for (const project of projects) {
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn('pnpm', ['exec', 'playwright', 'test', '--project', project], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DODO_E2E_PROJECT: project },
|
||||
stdio: 'inherit',
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', value => resolve(value ?? 1))
|
||||
})
|
||||
if (code !== 0) process.exit(code)
|
||||
}
|
||||
+35
-79
@@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
@@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
import TaskDueDisplay from './components/TaskDueDisplay.vue'
|
||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||
|
||||
@@ -49,8 +50,6 @@ let archivedListActionTrigger: HTMLElement | null = null
|
||||
const purgeListTarget = ref<TaskList | null>(null)
|
||||
const purgeListSubmitting = ref(false)
|
||||
const purgeListError = ref('')
|
||||
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
|
||||
const purgeListDialog = ref<HTMLElement | null>(null)
|
||||
let purgeListTrigger: HTMLElement | null = null
|
||||
const tasks = ref<Task[]>([])
|
||||
const overdueTasks = ref<Task[]>([])
|
||||
@@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock()
|
||||
const mobileSidebar = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileDetail = ref(false)
|
||||
const mobileMore = ref(false)
|
||||
const moreSettingsOpen = ref(false)
|
||||
const markdownPreview = ref(false)
|
||||
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -296,39 +294,16 @@ function toggleSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalLabel = ref('')
|
||||
const modalValue = ref('')
|
||||
const modalError = ref('')
|
||||
const modalConfirmText = ref('确定')
|
||||
const modalResolve = ref<((value: string | null) => void) | null>(null)
|
||||
function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
modalTitle.value = title
|
||||
modalLabel.value = label
|
||||
modalValue.value = initial
|
||||
modalError.value = ''
|
||||
modalConfirmText.value = confirmText
|
||||
modalVisible.value = true
|
||||
modalResolve.value = resolve
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
async function confirmAction(title: string, description?: string, danger = false) {
|
||||
return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
|
||||
}
|
||||
async function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||
const result = await appDialog.value?.show({
|
||||
title, label, initial, confirmText,
|
||||
validate: label ? (value) => normalizeRequiredName(value).error : undefined,
|
||||
})
|
||||
}
|
||||
function closeModal() {
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
|
||||
}
|
||||
function confirmModal() {
|
||||
if (modalLabel.value) {
|
||||
const normalized = normalizeRequiredName(modalValue.value)
|
||||
if (normalized.error) {
|
||||
modalError.value = normalized.error
|
||||
return
|
||||
}
|
||||
modalValue.value = normalized.value
|
||||
}
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
|
||||
return typeof result === 'string' ? result.trim() : null
|
||||
}
|
||||
|
||||
const activeName = computed(() => {
|
||||
@@ -679,7 +654,7 @@ async function loadTrash() {
|
||||
})
|
||||
}
|
||||
async function switchView(view: View, listId?: string) {
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||
taskMutationNavigation.value += 1
|
||||
taskReorderMode.value = false
|
||||
cancelTaskReorder()
|
||||
@@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) {
|
||||
if (listId) activeList.value = listId
|
||||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||||
page.value = 1
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
if (view !== 'memos') memoDetailOpen.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
else if (view === 'today') await loadTodayView()
|
||||
@@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() {
|
||||
}
|
||||
}
|
||||
async function removeTask(task: Task) {
|
||||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||||
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
|
||||
try {
|
||||
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
||||
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
||||
@@ -1014,7 +989,7 @@ async function restoreTask(task: Task) {
|
||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
|
||||
}
|
||||
async function purgeTask(task: Task) {
|
||||
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
||||
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
|
||||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
|
||||
}
|
||||
async function addSubtask() {
|
||||
@@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) {
|
||||
purgeListError.value = ''
|
||||
archivedListAction.value = null
|
||||
archivedListActionTrigger = null
|
||||
nextTick(() => purgeCancelButton.value?.focus())
|
||||
}
|
||||
function focusPurgeListTrigger() {
|
||||
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
||||
@@ -1164,15 +1138,6 @@ function closePurgeList() {
|
||||
purgeListError.value = ''
|
||||
focusPurgeListTrigger()
|
||||
}
|
||||
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
||||
if (event.key !== 'Tab' || !purgeListDialog.value) return
|
||||
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
|
||||
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
|
||||
}
|
||||
async function confirmPurgeList() {
|
||||
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
||||
purgeListSubmitting.value = true
|
||||
@@ -1467,8 +1432,8 @@ onUnmounted(() => {
|
||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</nav>
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction">
|
||||
<section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`">
|
||||
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
|
||||
<template v-if="sidebarAction">
|
||||
<template v-if="!listMoveMenuOpen">
|
||||
<header class="app-sheet__header sidebar-action-header">
|
||||
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
||||
@@ -1505,8 +1470,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</aside>
|
||||
|
||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
@@ -1573,8 +1538,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
||||
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" @close="closeTaskDetail">
|
||||
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||
<div class="detail-form">
|
||||
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||
@@ -1608,14 +1573,11 @@ onUnmounted(() => {
|
||||
</div></details>
|
||||
<div class="detail-actions"><button class="secondary" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||||
</div>
|
||||
</aside>
|
||||
</AppSheet>
|
||||
|
||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<Transition name="task-compose">
|
||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
||||
@@ -1638,28 +1600,22 @@ onUnmounted(() => {
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
<Teleport to="body">
|
||||
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
||||
</Teleport>
|
||||
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
|
||||
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
||||
<div class="modal-box" role="dialog" aria-modal="true">
|
||||
<h3>{{ modalTitle }}</h3>
|
||||
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
|
||||
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
|
||||
<template v-if="purgeListTarget">
|
||||
<div class="app-sheet__body">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import { invalidateCountdownCache } from './lib/mvp-utils'
|
||||
|
||||
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||
const cleanups: Array<() => void> = []
|
||||
const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' }
|
||||
const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
|
||||
function deferred<T>() { let resolve!: (value:T)=>void; let reject!: (reason?:unknown)=>void; const promise = new Promise<T>((yes,no)=>{ resolve=yes; reject=no }); return { promise, resolve, reject } }
|
||||
async function flush() { await Promise.resolve(); await new Promise((resolve)=>setTimeout(resolve, 0)); await Promise.resolve(); await nextTick() }
|
||||
async function mountWithFetch(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
invalidateCountdownCache()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const notices:string[]=[]
|
||||
const host=document.createElement('div'); document.body.append(host)
|
||||
const app=createApp(()=>h(CountdownPanel,{ onNotice:(message:string)=>notices.push(message) })); app.mount(host)
|
||||
const unmount=()=>{ app.unmount(); host.remove() }; cleanups.push(unmount)
|
||||
await flush(); return { host, notices, unmount }
|
||||
}
|
||||
function clickCountdown(host:HTMLElement, title:string) {
|
||||
const button=[...host.querySelectorAll<HTMLButtonElement>('.countdown-focus,.countdown-row')].find((candidate)=>candidate.textContent?.includes(title))
|
||||
expect(button).toBeTruthy(); button!.click()
|
||||
}
|
||||
function detailButton(label:string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet button')].find((button)=>button.textContent?.includes(label))!
|
||||
}
|
||||
function json(value: unknown) { return new Response(JSON.stringify(value), { status:200, headers:{ 'content-type':'application/json' } }) }
|
||||
afterEach(()=>{ cleanups.splice(0).forEach((cleanup)=>cleanup()); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
describe('countdown modal accessibility', () => {
|
||||
it('names the dialog and supports focus and Escape close', () => {
|
||||
expect(source).toContain('aria-labelledby="countdown-dialog-title"')
|
||||
it('names the shared dialog contract and delegates focus and Escape handling', () => {
|
||||
expect(source).toContain('title-id="countdown-dialog-title"')
|
||||
expect(source).toContain('id="countdown-dialog-title"')
|
||||
expect(source).toContain('@keydown.esc="closeDialog"')
|
||||
expect(source).toContain('ref="titleInput"')
|
||||
expect(source).toContain('titleInput.value?.focus()')
|
||||
expect(source).toContain('initial-focus="input[aria-label=\'倒数日名称\']"')
|
||||
expect(source).not.toContain('trapDialogFocus')
|
||||
expect(source).not.toContain('ref="titleInput"')
|
||||
expect(source).not.toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
||||
})
|
||||
|
||||
@@ -21,10 +48,10 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain('item.id !== focusItem.value?.id')
|
||||
expect(source).toContain('`kind-${item.kind}`')
|
||||
expect(source).toContain(':class="`kind-${focusItem.kind}`"')
|
||||
expect(source).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(source).toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain('detailCloseButton.value?.focus()')
|
||||
expect(source).toContain('@keydown="trapDetailFocus"')
|
||||
expect(source).toContain('panel-class="countdown-detail-sheet"')
|
||||
expect(source).not.toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain('initial-focus="button[aria-label=\'关闭详情\']"')
|
||||
expect(source).not.toContain('trapDetailFocus')
|
||||
expect(source).not.toContain('class="countdown-actions"')
|
||||
})
|
||||
|
||||
@@ -51,8 +78,8 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
||||
expect(source).toContain('const generation = getCountdownCacheGeneration()')
|
||||
expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return')
|
||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) error.value=')
|
||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
||||
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=')
|
||||
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
||||
})
|
||||
|
||||
it('prevents duplicate submits and sends the edit precondition', () => {
|
||||
@@ -85,4 +112,92 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain('添加第一个重要日子')
|
||||
expect(source).toContain('@click="openFromEmpty"')
|
||||
})
|
||||
|
||||
it('sends only one pin request on a rapid double click and disables all detail writes', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url} ${options?.method}`)
|
||||
})
|
||||
const { host } = await mountWithFetch(fetchMock)
|
||||
host.querySelector<HTMLButtonElement>('.countdown-focus')!.click(); await nextTick()
|
||||
const pinButton = [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].find((button)=>button.textContent?.includes('置顶'))!
|
||||
pinButton.click(); pinButton.click(); await nextTick()
|
||||
expect(fetchMock.mock.calls.filter(([url])=>String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
||||
expect([...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].every((button)=>button.disabled)).toBe(true)
|
||||
pin.resolve(json({})); await flush()
|
||||
})
|
||||
|
||||
it('ignores a stale successful detail write after close and opening another countdown', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||
clickCountdown(host, '旅行日'); await nextTick()
|
||||
|
||||
pin.resolve(json({})); await flush()
|
||||
|
||||
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||
expect(notices).toEqual([])
|
||||
expect(detailButton('置顶').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale failed detail write without polluting the new detail or unlocking its operation', async () => {
|
||||
const oldPin = deferred<Response>()
|
||||
const newPin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return oldPin.promise
|
||||
if (url.endsWith('/countdowns/c2/pin')) return newPin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||
clickCountdown(host, '旅行日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
|
||||
oldPin.reject(new Error('旧请求失败')); await flush()
|
||||
|
||||
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||
expect(host.querySelector('.inline-error')?.textContent ?? '').not.toContain('旧请求失败')
|
||||
expect(notices).toEqual([])
|
||||
expect(detailButton('置顶').disabled).toBe(true)
|
||||
newPin.resolve(json({})); await flush()
|
||||
})
|
||||
|
||||
it('keeps the normal current-detail pin flow working', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); pin.resolve(json({})); await flush()
|
||||
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
||||
expect(host.querySelector('.countdown-view')?.classList.contains('loading')).toBe(false)
|
||||
expect(notices).toEqual(['已置顶'])
|
||||
})
|
||||
|
||||
it('guards mutation commits and cleanup with the captured detail context', () => {
|
||||
expect(source).toContain('const operationGeneration = ref(0)')
|
||||
expect(source).toContain('const detailGeneration = ref(0)')
|
||||
expect(source).toContain('context.detailGeneration === detailGeneration.value')
|
||||
expect(source).toContain("context.detailId === (detailItem.value?.id ?? null)")
|
||||
expect(source).toContain('if (currentContext(context)) busy.value=false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
|
||||
type Countdown = {
|
||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||
@@ -17,34 +19,21 @@ const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
||||
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
||||
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
|
||||
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
||||
const operationGeneration = ref(0)
|
||||
const detailGeneration = ref(0)
|
||||
type OperationContext = { generation:number; detailId:string|null; detailGeneration:number }
|
||||
let activeOperation:OperationContext|null = null
|
||||
let mounted = true
|
||||
const currentYear = new Date().getFullYear()
|
||||
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
||||
const form = ref<Form>(freshForm())
|
||||
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const detailCloseButton = ref<HTMLButtonElement | null>(null)
|
||||
let previousFocus: HTMLElement | null = null
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
|
||||
function focusDialog() {
|
||||
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
void nextTick(() => titleInput.value?.focus())
|
||||
}
|
||||
function closeDialog() {
|
||||
open.value = false
|
||||
showAdvanced.value = false
|
||||
void nextTick(() => previousFocus?.focus())
|
||||
}
|
||||
function trapDialogFocus(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return
|
||||
const dialog = event.currentTarget as HTMLElement
|
||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter((item) => !item.hasAttribute('disabled'))
|
||||
if (!controls.length) return
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
|
||||
function primaryDate(item: Countdown) {
|
||||
@@ -99,7 +88,25 @@ async function request(path:string, options:RequestInit={}) {
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
|
||||
function currentContext(context:OperationContext) {
|
||||
return mounted && context.generation === operationGeneration.value && context.detailGeneration === detailGeneration.value && context.detailId === (detailItem.value?.id ?? null)
|
||||
}
|
||||
async function safe(detailId:string|null, work:(context:OperationContext)=>Promise<void>) {
|
||||
if (activeOperation && currentContext(activeOperation)) return
|
||||
const context={ generation:++operationGeneration.value, detailId, detailGeneration:detailGeneration.value }
|
||||
activeOperation=context
|
||||
busy.value=true
|
||||
error.value=''
|
||||
try {
|
||||
await work(context)
|
||||
} catch(reason) {
|
||||
if (!currentContext(context)) return
|
||||
error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
} finally {
|
||||
if (activeOperation === context) activeOperation=null
|
||||
if (currentContext(context)) busy.value=false
|
||||
}
|
||||
}
|
||||
async function fetchCountdowns() {
|
||||
const [active, archivedItems] = await Promise.all([
|
||||
request('/countdowns') as Promise<Countdown[]>,
|
||||
@@ -107,31 +114,36 @@ async function fetchCountdowns() {
|
||||
])
|
||||
return { items: active, archived: archivedItems }
|
||||
}
|
||||
async function load(force = false) {
|
||||
async function load(force = false, manageBusy = true) {
|
||||
const generation = getCountdownCacheGeneration()
|
||||
const cached = readCountdownCache<Countdown>()
|
||||
if (cached) { items.value=cached.items; archived.value=cached.archived }
|
||||
if (!cached) busy.value=true
|
||||
error.value=''
|
||||
if (!cached && manageBusy) busy.value=true
|
||||
if (manageBusy) error.value=''
|
||||
try {
|
||||
const data = await loadCountdownCache(fetchCountdowns, { force })
|
||||
if (!isCountdownCacheGenerationCurrent(generation)) return
|
||||
items.value=data.items
|
||||
archived.value=data.archived
|
||||
} catch(reason) {
|
||||
if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
} finally {
|
||||
if (isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||
}
|
||||
}
|
||||
function selectDetail(item:Countdown|null) {
|
||||
detailGeneration.value += 1
|
||||
detailItem.value=item
|
||||
error.value=''
|
||||
if (!activeOperation || !currentContext(activeOperation)) busy.value=false
|
||||
}
|
||||
function edit(item:Countdown) {
|
||||
detailItem.value=null
|
||||
selectDetail(null)
|
||||
editingId.value=item.id
|
||||
editingItem.value=item
|
||||
showAdvanced.value=false
|
||||
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
||||
open.value=true
|
||||
focusDialog()
|
||||
}
|
||||
function applyKindDefaults() {
|
||||
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
|
||||
@@ -140,40 +152,35 @@ function applyKindDefaults() {
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!form.value.title.trim()) return
|
||||
await safe(async()=>{
|
||||
await safe(null, async(context)=>{
|
||||
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
||||
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
|
||||
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
||||
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
||||
invalidateCountdownCache(); closeDialog(); await load(true); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
if (!currentContext(context)) return
|
||||
invalidateCountdownCache(); closeDialog(); await load(true, false)
|
||||
if (!currentContext(context)) return
|
||||
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
})
|
||||
}
|
||||
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已置顶')})}
|
||||
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})}
|
||||
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);emit('notice','已永久删除')})}
|
||||
async function pin(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已置顶');closeDetail();await load(true,false)})}
|
||||
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
|
||||
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已永久删除')})}
|
||||
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`}
|
||||
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
|
||||
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
||||
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
|
||||
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
|
||||
function trapDetailFocus(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return
|
||||
const dialog = event.currentTarget as HTMLElement
|
||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter((item) => !item.hasAttribute('disabled'))
|
||||
if (!controls.length) return
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function openDetail(item:Countdown){selectDetail(item)}
|
||||
function closeDetail(){selectDetail(null)}
|
||||
function openFromEmpty(){openCountdownComposer()}
|
||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;selectDetail(null);editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true}
|
||||
defineExpose({ openCountdownComposer })
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { previousFocus = null })
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
operationGeneration.value += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -199,21 +206,20 @@ onBeforeUnmount(() => { previousFocus = null })
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
||||
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||
<button v-if="archived.length" class="archived-toggle" :disabled="busy" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||
</div>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="detailItem" class="countdown-detail-mask app-sheet-mask" @click.self="closeDetail"><article class="countdown-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail" @keydown="trapDetailFocus">
|
||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
||||
<template v-if="detailItem">
|
||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
</article></div>
|
||||
</Transition>
|
||||
<Transition name="countdown-compose">
|
||||
<div v-if="open" class="countdown-modal-mask app-sheet-mask" @click.self="closeDialog"><form class="countdown-modal app-sheet app-sheet--create" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
|
||||
<header class="app-sheet__header"><div><small>{{editingId?'调整重要日子':'快速记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
||||
<div class="app-sheet__body"><label>名称<input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></label>
|
||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" :disabled="busy" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
|
||||
<header class="app-sheet__header"><div><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
||||
<div class="app-sheet__body"><label>名称<input v-model="form.title" aria-label="倒数日名称" maxlength="200" required placeholder="例如:去北海道旅行"></label>
|
||||
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
|
||||
<label>类型<select v-model="form.kind" @change="applyKindDefaults"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
||||
<details class="countdown-advanced" :open="showAdvanced" @toggle="showAdvanced=($event.target as HTMLDetailsElement).open"><summary><span>更多设置</span><ChevronDown/></summary>
|
||||
@@ -228,7 +234,7 @@ onBeforeUnmount(() => { previousFocus = null })
|
||||
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||
</details></div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
|
||||
</form></div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('memo shell integration', () => {
|
||||
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
|
||||
expect(panel).toContain("import MemoRow")
|
||||
expect(panel).toContain("import MemoEditor")
|
||||
expect(app).toContain('<span>任务详情</span>')
|
||||
expect(app).toContain('<span id="task-detail-title">任务详情</span>')
|
||||
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
||||
})
|
||||
|
||||
@@ -46,6 +46,16 @@ describe('memo shell integration', () => {
|
||||
expect(css).toContain('.memo-markdown-preview{min-height:250px')
|
||||
})
|
||||
|
||||
it('uses AppSheet for mobile memo detail while keeping desktop detail non-modal', () => {
|
||||
expect(panel).toContain("import AppSheet from './components/AppSheet.vue'")
|
||||
expect(panel).toContain('<AppSheet :open="Boolean(selected)" :modal="mobileDetail"')
|
||||
expect(panel).toContain('panel-class="memo-editor"')
|
||||
expect(panel).toContain('title-id="memo-editor-title"')
|
||||
expect(panel).not.toContain('memo-editor-scrim')
|
||||
expect(editor).not.toContain('aria-modal')
|
||||
expect(editor).not.toContain("event.key !== 'Tab'")
|
||||
})
|
||||
|
||||
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
|
||||
expect(app).toContain('const memoDetailOpen = ref(false)')
|
||||
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
|
||||
@@ -68,6 +78,5 @@ describe('memo shell integration', () => {
|
||||
expect(css).toContain('height:min(92dvh,820px)')
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
||||
expect(editor).toContain("window.addEventListener('beforeunload'")
|
||||
expect(editor).toContain("event.key !== 'Tab'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,12 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
async function answerDialog(confirm: boolean) {
|
||||
await nextTick()
|
||||
const selector = confirm ? '.app-dialog button[type="submit"]' : '.app-dialog .secondary'
|
||||
document.querySelector<HTMLButtonElement>(selector)!.click()
|
||||
await flush()
|
||||
}
|
||||
|
||||
async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
@@ -85,7 +91,6 @@ describe('MemoPanel', () => {
|
||||
['success', null],
|
||||
['error', new Error('迟到保存失败')],
|
||||
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const pending = deferred<unknown>()
|
||||
const notices: string[] = []
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH'
|
||||
@@ -96,7 +101,7 @@ describe('MemoPanel', () => {
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
if (failure) pending.reject(failure)
|
||||
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
||||
await flush()
|
||||
@@ -119,8 +124,7 @@ describe('MemoPanel', () => {
|
||||
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
@@ -130,7 +134,6 @@ describe('MemoPanel', () => {
|
||||
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const scoped = { ...item, deleted_at: deletedAt }
|
||||
const notices: string[] = []
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
@@ -141,13 +144,14 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request, (message) => notices.push(message))
|
||||
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click()
|
||||
if (_name !== 'restore') await answerDialog(true)
|
||||
await flush()
|
||||
expect(notices).toEqual([message])
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const details: boolean[] = []
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '正文' }
|
||||
@@ -157,7 +161,7 @@ describe('MemoPanel', () => {
|
||||
row.click(); await flush()
|
||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(false)
|
||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||
expect(details).toEqual([true])
|
||||
expect(document.activeElement).toBe(content)
|
||||
@@ -174,7 +178,7 @@ describe('MemoPanel', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||
window.dispatchEvent(new Event('resize')); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
||||
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||
expect(document.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||
})
|
||||
|
||||
@@ -247,8 +251,8 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||
if (lifecycle === 'delete') {
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||
await flush()
|
||||
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
||||
@@ -510,8 +514,7 @@ describe('MemoPanel', () => {
|
||||
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
|
||||
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 })
|
||||
@@ -539,8 +542,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -574,8 +576,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -613,8 +614,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -742,7 +742,6 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
|
||||
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const remove = deferred<unknown>()
|
||||
const other = { ...item, id: 'm2', title: '第二条' }
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
@@ -754,6 +753,7 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
remove.resolve(undefined); await flush()
|
||||
|
||||
@@ -762,7 +762,6 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
|
||||
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
||||
if (path === '/memos/m1') return { ...item, content: '正文' }
|
||||
@@ -770,7 +769,7 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await answerDialog(true)
|
||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||
})
|
||||
|
||||
@@ -791,6 +790,26 @@ describe('MemoPanel', () => {
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses AppDialog for dirty draft creation and honors cancel then confirm', async () => {
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '正文' }
|
||||
: { items: [item], total: 1 })
|
||||
const nativeConfirm = vi.spyOn(window, 'confirm')
|
||||
const { host, vm } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '未保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
|
||||
void vm.createMemo(); await nextTick()
|
||||
expect(document.querySelector('.app-dialog')?.textContent).toContain('放弃未保存的更改')
|
||||
await answerDialog(false)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('未保存')
|
||||
|
||||
void vm.createMemo(); await nextTick(); await answerDialog(true)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||
expect(nativeConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches between active and trash and opens a local draft through the exposed FAB action', async () => {
|
||||
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||
const { host, vm } = await mount(request)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ request: RequestFn }>()
|
||||
@@ -18,6 +20,7 @@ const error = ref('')
|
||||
const selected = ref<MemoEditorValue | null>(null)
|
||||
const selectedToken = ref(0)
|
||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const mobileDetail = ref(window.innerWidth <= 930)
|
||||
let detailOpener: HTMLElement | null = null
|
||||
@@ -87,13 +90,20 @@ function closeDetail() {
|
||||
detailOpener = null
|
||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||
}
|
||||
function showConfirm(options: AppDialogOptions) {
|
||||
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
|
||||
}
|
||||
function confirmDiscard(description: string) {
|
||||
if (!editor.value?.dirty) return Promise.resolve(true)
|
||||
return showConfirm({ title: '放弃未保存的更改?', description, danger: true, confirmText: '放弃更改' })
|
||||
}
|
||||
async function setScope(next: 'active' | 'trash') {
|
||||
if (next === scope.value) return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||
closeDetail(); scope.value = next; emit('scope', next); await load()
|
||||
}
|
||||
async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||
if (opener instanceof HTMLElement) detailOpener = opener
|
||||
const token = ++detailGeneration
|
||||
try {
|
||||
@@ -105,7 +115,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||
}
|
||||
async function createMemo() {
|
||||
if (scope.value === 'trash') return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('新建后,当前草稿不会保存。'))) return
|
||||
const token = ++detailGeneration
|
||||
selectedToken.value = token
|
||||
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||
@@ -195,13 +205,15 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
||||
</div>
|
||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
|
||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||
</div>
|
||||
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
||||
</div>
|
||||
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||
<AppSheet :open="Boolean(selected)" :modal="mobileDetail" variant="detail" panel-class="memo-editor" title-id="memo-editor-title" initial-focus="input[aria-label='备忘录标题']" :close-on-scrim="mobileDetail" @close="editor?.requestClose()">
|
||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" :confirm-action="showConfirm" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+114
-50
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
|
||||
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
|
||||
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
|
||||
type View = 'habits' | 'today-habits' | 'settings'
|
||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||
@@ -51,12 +55,30 @@ const habitHistoryNextTo = ref('')
|
||||
const habitHistoryHasMore = ref(false)
|
||||
let habitHistoryRequest = 0
|
||||
const habitDetailClickSuppressed = ref(false)
|
||||
const habitDetailSheet = ref<HTMLElement | null>(null)
|
||||
let habitDetailOpener: HTMLElement | null = null
|
||||
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
|
||||
const habitNameInput = ref<HTMLInputElement | null>(null)
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
async function confirmAction(title: string, description?: string) {
|
||||
return await appDialog.value?.show({ title, description, danger: true, confirmText: '确认' }) === true
|
||||
}
|
||||
const restoreFile = ref<File | null>(null)
|
||||
const restoreMode = ref<BackupMode>('merge')
|
||||
const restorePreflight = ref<BackupPreflight | null>(null)
|
||||
const backupBusy = ref(false)
|
||||
const backupError = ref('')
|
||||
const restoreInput = ref<HTMLInputElement | null>(null)
|
||||
let preflightGeneration = 0
|
||||
let preflightController: AbortController | null = null
|
||||
let acceptedPreflightSnapshot: BackupFileSnapshot | null = null
|
||||
const legacyRestore = computed(() => Boolean(restoreFile.value && isLegacyBackup(restoreFile.value)))
|
||||
function cancelPreflight() {
|
||||
preflightGeneration += 1
|
||||
preflightController?.abort()
|
||||
preflightController = null
|
||||
backupBusy.value = false
|
||||
}
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
@@ -101,18 +123,16 @@ watch(habitReorderAvailable, (available) => {
|
||||
})
|
||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
|
||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
||||
const csrf = csrfHeader(options.method)
|
||||
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
||||
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(formatHabitApiError((body as { detail?: unknown }).detail))
|
||||
async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
if (options.body instanceof FormData) {
|
||||
const file = options.body.get('file')
|
||||
if (file instanceof File) return uploadJson<T>(path, file, options)
|
||||
}
|
||||
const type = response.headers.get('content-type') || ''
|
||||
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
|
||||
let body: unknown = undefined
|
||||
if (typeof options.body === 'string') {
|
||||
try { body = JSON.parse(options.body) } catch { body = options.body }
|
||||
}
|
||||
return requestJson<T>(path, { ...options, body })
|
||||
}
|
||||
async function safe(work: () => Promise<void>) {
|
||||
busy.value = true; error.value = ''
|
||||
@@ -485,7 +505,6 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
||||
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
|
||||
selectedHabit.value = h
|
||||
void loadHabitHistory(true)
|
||||
void nextTick(() => habitDetailSheet.value?.focus())
|
||||
}
|
||||
function closeHabitDetail() {
|
||||
habitHistoryRequest += 1
|
||||
@@ -503,7 +522,7 @@ function closeHabitDetail() {
|
||||
}
|
||||
defineExpose({ openHabitComposer, refreshHabits: loadHabits })
|
||||
async function archiveHabit(h: Habit) {
|
||||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||||
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||
selectedHabit.value = null
|
||||
@@ -532,7 +551,7 @@ async function restoreHabit(h: Habit) {
|
||||
}
|
||||
}
|
||||
async function deleteHabit(h: Habit) {
|
||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||
if (!h.archived_at || !(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return
|
||||
error.value = ''
|
||||
try {
|
||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||
@@ -587,7 +606,7 @@ async function loadHabits() {
|
||||
}
|
||||
async function loadSettings() {
|
||||
await safe(async () => {
|
||||
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
|
||||
const [s, a] = await Promise.all([request<Session[] | { items?: Session[] }>('/sessions').catch(() => []), request<any[] | { items?: any[] }>('/audit-logs?limit=20').catch(() => [])])
|
||||
sessions.value = mergePage<Session>(s).items
|
||||
audit.value = mergePage<any>(a).items
|
||||
})
|
||||
@@ -596,7 +615,7 @@ async function revoke(id: string) {
|
||||
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||||
}
|
||||
async function revokeOtherSessions() {
|
||||
if (!confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')) return
|
||||
if (!(await confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。'))) return
|
||||
await safe(async () => {
|
||||
await request('/sessions/others', { method: 'DELETE' })
|
||||
await loadSettings()
|
||||
@@ -607,26 +626,74 @@ function downloadBlob(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
async function exportData() {
|
||||
await safe(async () => {
|
||||
const response = await fetch('/api/v1/export.csv', { credentials: 'include' })
|
||||
if (!response.ok) throw new Error('导出失败')
|
||||
downloadBlob(await response.blob(), 'dodo-export.csv')
|
||||
})
|
||||
backupBusy.value = true; backupError.value = ''
|
||||
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
||||
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
||||
finally { backupBusy.value = false }
|
||||
}
|
||||
function selectRestoreFile(event: Event) {
|
||||
cancelPreflight()
|
||||
restoreFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||
if (restoreFile.value && isLegacyBackup(restoreFile.value)) restoreMode.value = 'merge'
|
||||
restorePreflight.value = null
|
||||
acceptedPreflightSnapshot = null
|
||||
backupError.value = ''
|
||||
}
|
||||
watch(restoreMode, () => {
|
||||
cancelPreflight()
|
||||
restorePreflight.value = null
|
||||
acceptedPreflightSnapshot = null
|
||||
backupError.value = ''
|
||||
})
|
||||
const backupEntityTotal = computed(() => Object.values(restorePreflight.value?.entities ?? {}).reduce((sum, count) => sum + count, 0))
|
||||
async function runPreflight() {
|
||||
const file = restoreFile.value
|
||||
if (!file || isLegacyBackup(file)) return
|
||||
cancelPreflight()
|
||||
const generation = preflightGeneration
|
||||
const snapshot = backupFileSnapshot(file, restoreMode.value)
|
||||
const controller = new AbortController()
|
||||
preflightController = controller
|
||||
backupBusy.value = true; backupError.value = ''; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||
try {
|
||||
const result = await preflightBackup(file, snapshot.mode, controller.signal)
|
||||
if (!shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)) return
|
||||
restorePreflight.value = result
|
||||
acceptedPreflightSnapshot = snapshot
|
||||
} catch (reason) {
|
||||
if (generation !== preflightGeneration || controller.signal.aborted) return
|
||||
backupError.value = reason instanceof Error ? reason.message : '备份预检失败'
|
||||
} finally {
|
||||
if (generation === preflightGeneration) {
|
||||
backupBusy.value = false
|
||||
preflightController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
if (!restoreFile.value) return
|
||||
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
|
||||
await safe(async () => {
|
||||
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) {
|
||||
const form = new FormData()
|
||||
form.append('file', restoreFile.value!)
|
||||
await request('/restore.csv?mode=merge', { method: 'POST', body: form })
|
||||
const file = restoreFile.value
|
||||
const mode = restoreMode.value
|
||||
const preview = restorePreflight.value
|
||||
const legacy = Boolean(file && isLegacyBackup(file))
|
||||
if (!file || mode !== restoreMode.value || (legacy ? mode !== 'merge' : !preview?.valid || !acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode))) return
|
||||
const destructive = legacy ? '旧格式将在恢复时由服务端校验,仅支持合并恢复。' : mode === 'replace' ? '现有数据将被备份内容替换,此操作不可撤销。' : '同名或相同标识的数据将按合并规则处理。'
|
||||
if (!(await confirmAction(mode === 'replace' ? '确认替换全部数据?' : '确认合并备份?', destructive))) return
|
||||
if (file !== restoreFile.value || mode !== restoreMode.value || (!legacy && (!acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode)))) return
|
||||
backupBusy.value = true; backupError.value = ''
|
||||
try {
|
||||
if (!legacy) {
|
||||
if (!preview?.preflight_token) throw new Error('预检令牌无效,请重新预检')
|
||||
await restoreBackup(preview.preflight_token, mode)
|
||||
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||
await uploadJson('/restore.csv?mode=merge', file)
|
||||
} else {
|
||||
const text = await restoreFile.value!.text()
|
||||
await request('/restore?mode=merge', { method: 'POST', body: text })
|
||||
await requestJson('/restore?mode=merge', { method: 'POST', body: JSON.parse(await file.text()) })
|
||||
}
|
||||
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||
if (restoreInput.value) restoreInput.value.value = ''
|
||||
emit('changed'); emit('notice', '数据已恢复')
|
||||
})
|
||||
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||
finally { backupBusy.value = false }
|
||||
}
|
||||
async function changePassword() {
|
||||
passwordError.value = ''
|
||||
@@ -665,6 +732,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
cancelPreflight()
|
||||
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -694,9 +762,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- 完整习惯列表 -->
|
||||
<Transition name="task-compose">
|
||||
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
|
||||
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
|
||||
<AppSheet :open="habitComposerOpen" variant="create" panel-class="task-compose-sheet habit-compose-sheet" title-id="habit-compose-title" initial-focus="input[aria-label='新习惯名称']" :busy="busy" :style="habitComposeStyle" @close="closeHabitComposer" @submit.prevent="saveHabit">
|
||||
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
||||
@@ -708,9 +774,7 @@ onBeforeUnmount(() => {
|
||||
<label v-if="habitSchedule === 'interval'">间隔天数<input v-model.number="habitIntervalDays" type="number" min="1" step="1" :aria-invalid="Boolean(habitErrors.interval_days)" aria-describedby="habit-interval-error"><small v-if="habitErrors.interval_days" id="habit-interval-error" class="field-error" role="alert">{{ habitErrors.interval_days }}</small></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中…' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
|
||||
|
||||
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||
@@ -742,9 +806,8 @@ onBeforeUnmount(() => {
|
||||
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
||||
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||||
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
||||
<template v-if="selectedHabit">
|
||||
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||
@@ -767,9 +830,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</template>
|
||||
|
||||
<!-- 设置与数据 -->
|
||||
@@ -777,12 +839,14 @@ onBeforeUnmount(() => {
|
||||
<header class="view-intro">
|
||||
<div><small>备份、迁移与安全</small></div>
|
||||
</header>
|
||||
<div class="settings-grid">
|
||||
<article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><p>导出完整 CSV 数据,或从 CSV / JSON 备份恢复。</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
||||
<article class="tool-card password-card"><Activity /><h2>修改密码</h2><p>修改后当前设备保持登录,其他设备会自动退出。</p><form class="password-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></article>
|
||||
<article class="tool-card wide"><LogOut /><h2>登录会话</h2><div class="session-card-actions"><p>可单独撤销设备,也可以一次撤销除当前设备外的全部会话。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div><div v-for="s in sessions" :key="s.id" class="session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
||||
<article v-if="audit.length" class="tool-card wide"><Activity /><h2>最近活动</h2><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><div class="audit-copy"><span class="audit-action" :title="row.action ?? row.event ?? undefined">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div></article>
|
||||
<div class="settings-sections">
|
||||
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件;CSV / JSON 继续用于旧格式兼容。</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
||||
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录,其他设备自动退出。</p></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></section>
|
||||
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作。</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||
<section class="settings-group settings-danger"><header><h2>危险操作</h2><p>替换恢复会覆盖当前数据,请先导出完整备份。</p></header></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, requestBlob, requestJson, uploadJson } from './http'
|
||||
import { downloadFullBackup, preflightBackup, restoreBackup } from './backups'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function response(body: BodyInit | null, init: ResponseInit = {}) {
|
||||
return new Response(body, { status: 200, ...init })
|
||||
}
|
||||
|
||||
describe('typed HTTP client', () => {
|
||||
it('sends credentials, CSRF, JSON and AbortSignal consistently', async () => {
|
||||
document.cookie = 'dodo_csrf=csrf-token'
|
||||
const signal = new AbortController().signal
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response('{"ok":true}', { headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
await requestJson<{ ok: boolean }>('/example', { method: 'POST', body: { value: 1 }, signal })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/example', expect.objectContaining({ credentials: 'include', signal, method: 'POST', body: '{"value":1}' }))
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers)
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
expect(headers.get('x-csrf-token')).toBe('csrf-token')
|
||||
})
|
||||
|
||||
it('normalizes FastAPI validation details without losing the status or code', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(JSON.stringify({ detail: [{ loc: ['body', 'name'], msg: '必填', type: 'missing' }], code: 'invalid_backup' }), { status: 422, headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
const error = await requestJson('/bad').catch((reason) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
const apiError = error as ApiError
|
||||
expect(apiError).toMatchObject({ status: 422, code: 'invalid_backup' })
|
||||
expect(apiError.message).toContain('name')
|
||||
expect(apiError.message).toContain('必填')
|
||||
})
|
||||
|
||||
it('keeps blob and multipart requests typed without forcing JSON content type', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||
.mockResolvedValueOnce(response('{"valid":true}', { headers: { 'content-type': 'application/json' } }))
|
||||
const blob = await requestBlob('/backup/export.zip')
|
||||
expect(blob.size).toBe(8)
|
||||
expect(blob.type).toBe('application/zip')
|
||||
await uploadJson('/backup/preflight?mode=merge', new File(['zip'], 'backup.zip'))
|
||||
const uploadHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers)
|
||||
expect(uploadHeaders.has('content-type')).toBe(false)
|
||||
expect(fetchMock.mock.calls[1][1]?.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backup API', () => {
|
||||
it('uses the versioned ZIP export, preflight and restore endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ valid: true, preflight_token: 'token', backup_id: 'backup', archive_sha256: 'sha', entities: { tasks: 2, attachments: 1 } }), { headers: { 'content-type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ restored: 3, mode: 'replace' }), { headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
await downloadFullBackup()
|
||||
const preview = await preflightBackup(new File(['zip'], 'dodo.zip'), 'replace')
|
||||
await restoreBackup(preview.preflight_token!, 'replace')
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/v1/backup/export.zip',
|
||||
'/api/v1/backup/preflight?mode=replace',
|
||||
'/api/v1/backup/restore',
|
||||
])
|
||||
expect(preview.entities.tasks).toBe(2)
|
||||
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ preflight_token: 'token', mode: 'replace' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { requestBlob, requestJson, uploadJson } from './http'
|
||||
|
||||
export type BackupMode = 'merge' | 'replace'
|
||||
export type BackupPreflight = {
|
||||
valid: boolean
|
||||
preflight_token?: string
|
||||
backup_id?: string
|
||||
archive_sha256?: string
|
||||
version?: number
|
||||
entities: Record<string, number>
|
||||
attachment_count?: number
|
||||
attachment_bytes?: number
|
||||
warnings?: string[]
|
||||
destructive_summary?: string[]
|
||||
}
|
||||
export type BackupRestoreResult = { restored: number; mode: BackupMode; already_imported?: boolean; cleanup_retried?: boolean }
|
||||
|
||||
export function downloadFullBackup(signal?: AbortSignal) {
|
||||
return requestBlob('/backup/export.zip', { signal })
|
||||
}
|
||||
|
||||
export function preflightBackup(file: File, mode: BackupMode, signal?: AbortSignal) {
|
||||
return uploadJson<BackupPreflight>(`/backup/preflight?mode=${mode}`, file, { signal })
|
||||
}
|
||||
|
||||
export function restoreBackup(preflightToken: string, mode: BackupMode, signal?: AbortSignal) {
|
||||
return requestJson<BackupRestoreResult>('/backup/restore', { method: 'POST', body: { preflight_token: preflightToken, mode }, signal })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type FastApiValidationIssue = { loc?: Array<string | number>; msg?: string; type?: string }
|
||||
|
||||
export function formatApiErrorDetail(detail: unknown): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map((issue) => {
|
||||
if (!issue || typeof issue !== 'object') return String(issue)
|
||||
const value = issue as FastApiValidationIssue
|
||||
const location = value.loc?.filter((part) => part !== 'body').join('.')
|
||||
return [location, value.msg].filter(Boolean).join(':') || value.type || '请求数据无效'
|
||||
}).join(';')
|
||||
if (detail && typeof detail === 'object') {
|
||||
const value = detail as { message?: unknown; code?: unknown }
|
||||
if (typeof value.message === 'string') return value.message
|
||||
if (typeof value.code === 'string') return value.code
|
||||
}
|
||||
return '请求失败'
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly detail: unknown
|
||||
|
||||
constructor(message: string, status: number, code?: string, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { csrfHeader } from '../lib/csrf'
|
||||
import { ApiError, formatApiErrorDetail } from './errors'
|
||||
|
||||
export type JsonRequestOptions = Omit<RequestInit, 'body'> & { body?: unknown }
|
||||
|
||||
function apiUrl(path: string) {
|
||||
return path.startsWith('/api/') ? path : `/api/v1${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options: RequestInit = {}) {
|
||||
const headers = new Headers(options.headers)
|
||||
const csrf = csrfHeader(options.method)
|
||||
if (csrf['x-csrf-token']) headers.set('x-csrf-token', csrf['x-csrf-token'])
|
||||
const response = await fetch(apiUrl(path), { credentials: 'include', ...options, headers })
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const body = contentType.includes('json') ? await response.json().catch(() => ({})) : await response.text().catch(() => '')
|
||||
const detail = body && typeof body === 'object' && 'detail' in body ? body.detail : body
|
||||
const code = body && typeof body === 'object' && typeof body.code === 'string'
|
||||
? body.code
|
||||
: detail && typeof detail === 'object' && !Array.isArray(detail) && typeof detail.code === 'string' ? detail.code : undefined
|
||||
throw new ApiError(formatApiErrorDetail(detail), response.status, code, detail)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function requestJson<T>(path: string, options: JsonRequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers)
|
||||
const { body: ignoredBody, ...requestOptions } = options
|
||||
void ignoredBody
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body)
|
||||
if (body !== undefined) headers.set('content-type', 'application/json')
|
||||
const response = await apiFetch(path, { ...requestOptions, headers, body })
|
||||
return response.status === 204 ? undefined as T : await response.json() as T
|
||||
}
|
||||
|
||||
export async function requestBlob(path: string, options: RequestInit = {}): Promise<Blob> {
|
||||
return (await apiFetch(path, options)).blob()
|
||||
}
|
||||
|
||||
export async function uploadJson<T>(path: string, file: File, options: Omit<RequestInit, 'body'> = {}): Promise<T> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await apiFetch(path, { ...options, method: options.method ?? 'POST', body: form })
|
||||
return response.status === 204 ? undefined as T : await response.json() as T
|
||||
}
|
||||
|
||||
export { ApiError } from './errors'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './errors'
|
||||
export * from './http'
|
||||
export * from './backups'
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import AppSheet from './AppSheet.vue'
|
||||
|
||||
export type AppDialogOptions = {
|
||||
title: string
|
||||
description?: string
|
||||
label?: string
|
||||
initial?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
validate?: (value: string) => string | null
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const busy = ref(false)
|
||||
const options = ref<AppDialogOptions>({ title: '' })
|
||||
const value = ref('')
|
||||
const error = ref('')
|
||||
let resolveDialog: ((value: boolean | string | null) => void) | null = null
|
||||
|
||||
function finish(result: boolean | string | null) {
|
||||
if (!open.value || busy.value) return
|
||||
open.value = false
|
||||
resolveDialog?.(result)
|
||||
resolveDialog = null
|
||||
}
|
||||
function cancel() { finish(options.value.label ? null : false) }
|
||||
function confirm() {
|
||||
if (options.value.label) {
|
||||
const message = options.value.validate?.(value.value) ?? null
|
||||
if (message) { error.value = message; void nextTick(() => document.querySelector<HTMLElement>('#app-dialog-error')?.focus()); return }
|
||||
finish(value.value)
|
||||
} else finish(true)
|
||||
}
|
||||
function show(next: AppDialogOptions) {
|
||||
if (resolveDialog) resolveDialog(options.value.label ? null : false)
|
||||
options.value = next
|
||||
value.value = next.initial ?? ''
|
||||
error.value = ''
|
||||
open.value = true
|
||||
return new Promise<boolean | string | null>((resolve) => { resolveDialog = resolve })
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
resolveDialog?.(options.value.label ? null : false)
|
||||
resolveDialog = null
|
||||
})
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSheet :open="open" variant="actions" panel-class="app-dialog" title-id="app-dialog-title" :description-id="options.description ? 'app-dialog-description' : undefined" initial-focus="[data-dialog-initial]" :busy="busy" @close="cancel" @submit.prevent="confirm">
|
||||
<header class="app-sheet__header"><div><h2 id="app-dialog-title">{{ options.title }}</h2><p v-if="options.description" id="app-dialog-description">{{ options.description }}</p></div></header>
|
||||
<div v-if="options.label" class="app-sheet__body"><label>{{ options.label }}<input v-model="value" data-dialog-initial class="modal-input" :aria-invalid="Boolean(error)" :aria-describedby="error ? 'app-dialog-error' : undefined" @input="error=''" /></label><small v-if="error" id="app-dialog-error" class="field-error" role="alert" tabindex="-1">{{ error }}</small></div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" data-dialog-initial :disabled="busy" @click="cancel">{{ options.cancelText ?? '取消' }}</button><button type="submit" :class="options.danger ? 'danger-button' : 'primary-small'" :disabled="busy">{{ options.confirmText ?? '确定' }}</button></footer>
|
||||
</AppSheet>
|
||||
</template>
|
||||
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import AppSheet from './AppSheet.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => { cleanups.splice(0).forEach((fn) => fn()); document.body.innerHTML = '' })
|
||||
|
||||
async function mountSheet(options: { busy?: boolean; initialFocus?: string; modal?: boolean } = {}) {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.textContent = 'open'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const open = ref(true)
|
||||
const close = vi.fn(() => { open.value = false })
|
||||
const app = createApp({
|
||||
setup: () => () => h(AppSheet, {
|
||||
open: open.value,
|
||||
titleId: 'sheet-title',
|
||||
descriptionId: 'sheet-description',
|
||||
busy: options.busy,
|
||||
modal: options.modal,
|
||||
initialFocus: options.initialFocus,
|
||||
onClose: close,
|
||||
}, {
|
||||
default: () => [h('h2', { id: 'sheet-title' }, '标题'), h('p', { id: 'sheet-description' }, '说明'), h('button', { id: 'first' }, 'first'), h('button', { id: 'last' }, 'last')],
|
||||
}),
|
||||
})
|
||||
app.mount(host)
|
||||
cleanups.push(() => app.unmount())
|
||||
for (const element of document.querySelectorAll<HTMLElement>('#first,#last')) {
|
||||
Object.defineProperty(element, 'getClientRects', { configurable: true, value: () => [{ width: 20, height: 20 }] })
|
||||
}
|
||||
await nextTick(); await nextTick()
|
||||
return { host, opener, open, close }
|
||||
}
|
||||
|
||||
describe('AppSheet', () => {
|
||||
it('teleports an accessible modal and makes application background inert', async () => {
|
||||
const { host } = await mountSheet({ initialFocus: '#last' })
|
||||
const dialog = document.querySelector<HTMLElement>('#overlay-root [role="dialog"]')!
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||
expect(dialog.getAttribute('aria-labelledby')).toBe('sheet-title')
|
||||
expect(dialog.getAttribute('aria-describedby')).toBe('sheet-description')
|
||||
expect(document.activeElement?.id).toBe('last')
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
expect(host.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('traps Tab and restores focus after closing', async () => {
|
||||
const { opener } = await mountSheet({ initialFocus: '#first' })
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
const first = document.querySelector<HTMLButtonElement>('#first')!
|
||||
const last = document.querySelector<HTMLButtonElement>('#last')!
|
||||
last.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(first)
|
||||
first.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(last)
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('blocks scrim and Escape closing while busy', async () => {
|
||||
const { close } = await mountSheet({ busy: true })
|
||||
document.querySelector<HTMLElement>('.app-overlay')!.click()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a real form when submit listeners are provided', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const submitted = vi.fn()
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, titleId:'form-title', onSubmit:(event: Event) => { event.preventDefault(); submitted() } }, {
|
||||
default:() => [h('h2',{id:'form-title'},'form'), h('button',{type:'submit'},'save')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(dialog.tagName).toBe('FORM')
|
||||
dialog.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
expect(submitted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps desktop non-modal details inline without inerting the app', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:false, titleId:'detail-title' }, {
|
||||
default:() => [h('h2',{id:'detail-title'},'detail'), h('button','close')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
const dialog = host.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.getAttribute('aria-modal')).toBeNull()
|
||||
expect(document.querySelector('#overlay-root [role="dialog"]')).toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
})
|
||||
|
||||
it('activates and deactivates the overlay when modal changes while open', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const modal = ref(false)
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:modal.value, titleId:'dynamic-title' }, {
|
||||
default:() => [h('h2',{id:'dynamic-title'},'detail'), h('button',{id:'dynamic-close'},'close')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
|
||||
modal.value = true; await nextTick(); await nextTick()
|
||||
expect(document.querySelector('#overlay-root [role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
|
||||
modal.value = false; await nextTick(); await nextTick()
|
||||
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps background inert until the last stacked modal closes', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const first = ref(true); const second = ref(true)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'stack-one', onClose:() => { first.value=false } }, { default:() => h('h2',{id:'stack-one'},'one') }),
|
||||
h(AppSheet, { open:second.value, titleId:'stack-two', onClose:() => { second.value=false } }, { default:() => h('h2',{id:'stack-two'},'two') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
second.value=false; await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
first.value=false; await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
expect(host.getAttribute('aria-hidden')).toBeNull()
|
||||
})
|
||||
|
||||
it('focuses prompt input and confirm dialog cancel action', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
||||
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||
void dialog.value!.show({ title:'prompt', label:'name' }); await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.tagName).toBe('INPUT')
|
||||
document.querySelector<HTMLButtonElement>('.app-dialog .secondary')!.click(); await nextTick()
|
||||
void dialog.value!.show({ title:'confirm' }); await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(document.querySelector('.app-dialog .secondary'))
|
||||
})
|
||||
|
||||
it('settles replaced and unmounted dialog promises safely', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
||||
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||
const first = dialog.value!.show({ title:'one' })
|
||||
const second = dialog.value!.show({ title:'two', label:'name' })
|
||||
await expect(first).resolves.toBe(false)
|
||||
app.unmount()
|
||||
await expect(second).resolves.toBe(null)
|
||||
})
|
||||
|
||||
it('returns focus to the lower overlay when same-tick upper overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'stack-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||
const visibleRef = (element: unknown) => {
|
||||
if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] })
|
||||
}
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:lowerOpen.value, titleId:'focus-lower' }, { default:() => [h('h2',{id:'focus-lower'},'lower'), h('button',{id:'focus-lower-button', ref:visibleRef},'lower button')] }),
|
||||
h(AppSheet, { open:upperOpen.value, titleId:'focus-upper' }, { default:() => [h('h2',{id:'focus-upper'},'upper'), h('button',{id:'focus-upper-button', ref:visibleRef},'upper button')] }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.id).toBe('focus-upper-button')
|
||||
|
||||
upperOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.id).toBe('focus-lower-button')
|
||||
})
|
||||
|
||||
it('restores the background opener only after the last stacked overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'last-stack-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:lowerOpen.value, titleId:'last-lower' }, { default:() => [h('h2',{id:'last-lower'},'lower'), h('button',{id:'last-lower-button'},'lower button')] }),
|
||||
h(AppSheet, { open:upperOpen.value, titleId:'last-upper' }, { default:() => h('h2',{id:'last-upper'},'upper') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
|
||||
upperOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).not.toBe(opener)
|
||||
lowerOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('keeps focus in the upper overlay when a non-top lower overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'lower-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const first = ref(true); const second = ref(false)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'lower' }, { default:() => [h('h2',{id:'lower'},'lower'), h('button',{id:'lower-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'lower button')] }),
|
||||
h(AppSheet, { open:second.value, titleId:'upper' }, { default:() => [h('h2',{id:'upper'},'upper'), h('button',{id:'upper-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'upper button')] }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
second.value=true; await nextTick(); await nextTick()
|
||||
const upper = document.querySelector<HTMLButtonElement>('#upper-button')!
|
||||
expect(document.activeElement).toBe(upper)
|
||||
first.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(upper)
|
||||
})
|
||||
|
||||
it('skips focusables hidden by ancestors, aria-hidden, inert, styles, disabled state, or empty client rects', async () => {
|
||||
const { host } = await mountSheet({ initialFocus: '#first' })
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
dialog.querySelector('#first')?.remove()
|
||||
dialog.querySelector('#last')?.remove()
|
||||
const hiddenParent = document.createElement('div')
|
||||
hiddenParent.hidden = true
|
||||
hiddenParent.innerHTML = '<button id="hidden-child">hidden</button>'
|
||||
const ariaParent = document.createElement('div')
|
||||
ariaParent.setAttribute('aria-hidden', 'true')
|
||||
ariaParent.innerHTML = '<button id="aria-child">aria</button>'
|
||||
const inertParent = document.createElement('div')
|
||||
inertParent.setAttribute('inert', '')
|
||||
inertParent.innerHTML = '<button id="inert-child">inert</button>'
|
||||
const displayNone = document.createElement('button')
|
||||
displayNone.id = 'display-none'; displayNone.style.display = 'none'
|
||||
const invisible = document.createElement('button')
|
||||
invisible.id = 'invisible'; invisible.style.visibility = 'hidden'
|
||||
const disabled = document.createElement('button')
|
||||
disabled.id = 'disabled'; disabled.disabled = true
|
||||
const noRect = document.createElement('button')
|
||||
noRect.id = 'no-rect'
|
||||
const visible = document.createElement('button')
|
||||
visible.id = 'visible'
|
||||
Object.defineProperty(visible, 'getClientRects', { value: () => [{ width: 20, height: 20 }] })
|
||||
dialog.append(hiddenParent, ariaParent, inertParent, displayNone, invisible, disabled, noRect, visible)
|
||||
dialog.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key:'Tab', bubbles:true, cancelable:true }))
|
||||
expect(document.activeElement).toBe(visible)
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('only closes the top overlay on Escape', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const first = ref(true); const second = ref(true); const calls: string[] = []
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'one', onClose:() => { calls.push('one'); first.value=false } }, { default:() => h('h2',{id:'one'},'one') }),
|
||||
h(AppSheet, { open:second.value, titleId:'two', onClose:() => { calls.push('two'); second.value=false } }, { default:() => h('h2',{id:'two'},'two') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', bubbles:true, cancelable:true }))
|
||||
await nextTick()
|
||||
expect(calls).toEqual(['two'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { isTopOverlay, overlayRoot, popOverlay, pushOverlay } from '../composables/useOverlayStack'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
titleId?: string
|
||||
descriptionId?: string
|
||||
label?: string
|
||||
busy?: boolean
|
||||
initialFocus?: string
|
||||
modal?: boolean
|
||||
closeOnScrim?: boolean
|
||||
variant?: 'create' | 'detail' | 'actions'
|
||||
panelClass?: string
|
||||
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '' })
|
||||
defineOptions({ inheritAttrs: false })
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
let overlayId: symbol | null = null
|
||||
|
||||
function requestClose() {
|
||||
if (!props.busy && (!props.modal || isTopOverlay(overlayId))) emit('close')
|
||||
}
|
||||
function scrimClose(event: MouseEvent) {
|
||||
if (props.closeOnScrim && event.target === event.currentTarget) requestClose()
|
||||
}
|
||||
function isVisibleFocusable(element: HTMLElement) {
|
||||
if (element.matches(':disabled') || element.closest('[hidden],[aria-hidden="true"],[inert]')) return false
|
||||
const style = window.getComputedStyle(element)
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false
|
||||
return element.getClientRects().length > 0 || (element.offsetWidth > 0 && element.offsetHeight > 0)
|
||||
}
|
||||
function focusables() {
|
||||
if (!panel.value) return []
|
||||
return Array.from(panel.value.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter(isVisibleFocusable)
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
||||
const controls = focusables()
|
||||
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.value)) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function focusIntoPanel() {
|
||||
if (!panel.value?.contains(document.activeElement)) {
|
||||
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
|
||||
;(target ?? focusables()[0] ?? panel.value)?.focus()
|
||||
}
|
||||
}
|
||||
async function activate() {
|
||||
if (!props.open || !props.modal || overlayId) return
|
||||
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||
await nextTick()
|
||||
if (!props.open || !props.modal || !overlayId) return
|
||||
focusIntoPanel()
|
||||
}
|
||||
function deactivate() {
|
||||
if (overlayId) popOverlay(overlayId)
|
||||
overlayId = null
|
||||
}
|
||||
watch([() => props.open, () => props.modal], ([open, modal]) => {
|
||||
if (open && modal) void activate()
|
||||
else deactivate()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(deactivate)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modal" :to="overlayRoot()">
|
||||
<div v-if="open" class="app-overlay app-sheet-mask" :aria-busy="busy || undefined" @click="scrimClose">
|
||||
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
|
||||
<slot />
|
||||
</component>
|
||||
</div>
|
||||
</Teleport>
|
||||
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -36,5 +36,6 @@ describe('add-task CalendarPicker integration', () => {
|
||||
expect(picker).toContain('data-action="clear"')
|
||||
expect(picker).toContain('data-action="cancel"')
|
||||
expect(picker).toContain('data-action="done"')
|
||||
expect(picker).toContain("event.preventDefault(); event.stopPropagation(); close()")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ function onGridKey(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
function onDialogKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
|
||||
if (event.key !== 'Tab' || !dialog.value) return
|
||||
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
|
||||
if (!focusables.length) return
|
||||
|
||||
@@ -17,7 +17,8 @@ async function mount(overrides: Record<string, unknown> = {}) {
|
||||
const events: Record<string, unknown[]> = { saved: [], close: [] }
|
||||
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
|
||||
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||
const confirmAction = (overrides.confirmAction ?? vi.fn(async () => true)) as (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean>
|
||||
const app = createApp(() => h(MemoEditor, { memo, request, confirmAction, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
||||
}
|
||||
@@ -105,19 +106,19 @@ describe('MemoEditor', () => {
|
||||
})
|
||||
|
||||
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const confirmAction = vi.fn(async () => false)
|
||||
const request = vi.fn()
|
||||
const clean = await mount({ memo: draft, request })
|
||||
const clean = await mount({ memo: draft, request, confirmAction })
|
||||
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(confirmAction).not.toHaveBeenCalled()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(clean.events.close).toEqual([true])
|
||||
|
||||
const edited = await mount({ memo: draft, request })
|
||||
const edited = await mount({ memo: draft, request, confirmAction })
|
||||
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(confirm).toHaveBeenCalledOnce()
|
||||
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(confirmAction).toHaveBeenCalledOnce()
|
||||
expect(edited.events.close).toEqual([])
|
||||
})
|
||||
|
||||
@@ -226,22 +227,15 @@ describe('MemoEditor', () => {
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => {
|
||||
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
||||
it('leaves mobile modal focus trapping and Escape close to AppSheet', async () => {
|
||||
const { host, events } = await mount({ mobile: true })
|
||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
|
||||
last.focus()
|
||||
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||
dialog.dispatchEvent(tab)
|
||||
expect(tab.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(dialog.querySelector('button'))
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
const editor = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
expect(editor.getAttribute('aria-modal')).toBeNull()
|
||||
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
editor.dispatchEvent(escape)
|
||||
await nextTick()
|
||||
expect(events.close).toEqual([true])
|
||||
expect(document.activeElement).not.toBe(opener)
|
||||
opener.remove()
|
||||
expect(escape.defaultPrevented).toBe(false)
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a stale reload after its editor selection changes', async () => {
|
||||
@@ -424,15 +418,15 @@ describe('MemoEditor', () => {
|
||||
})
|
||||
|
||||
it('guards dirty close and allows clean close', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { host, events } = await mount()
|
||||
const confirmAction = vi.fn(async () => false)
|
||||
const { host, events } = await mount({ confirmAction })
|
||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(confirm).toHaveBeenCalled()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(confirmAction).toHaveBeenCalled()
|
||||
expect(events.close).toEqual([])
|
||||
confirm.mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
confirmAction.mockResolvedValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(events.close).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ export type MemoDraft = { id: null; title: string; content: string; version: nul
|
||||
export type Memo = MemoRecord
|
||||
export type MemoEditorValue = MemoRecord | MemoDraft
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { selectionToken: 0 })
|
||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number; confirmAction?: (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean> }>(), { selectionToken: 0 })
|
||||
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
@@ -18,7 +18,6 @@ let lifecycleGeneration = 0
|
||||
const error = ref('')
|
||||
const conflict = ref(false)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const initial = ref({ title: '', content: '' })
|
||||
const memoPreview = ref(false)
|
||||
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -35,8 +34,8 @@ watch(() => props.selectionToken, () => {
|
||||
lifecycleGeneration += 1
|
||||
lifecycleBusy.value = false
|
||||
})
|
||||
function close() {
|
||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
async function close() {
|
||||
if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
|
||||
emit('close')
|
||||
}
|
||||
function validate() {
|
||||
@@ -91,7 +90,7 @@ async function reload() {
|
||||
async function remove() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||
if (!(await props.confirmAction?.({ title: `把“${props.memo.title}”移到回收站?`, description: '之后可以在回收站恢复。', confirmText: '移到回收站', danger: true }))) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
@@ -130,7 +129,7 @@ async function restore() {
|
||||
async function purge() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||
if (!(await props.confirmAction?.({ title: `永久删除“${props.memo.title}”?`, description: '此操作无法撤销。', confirmText: '永久删除', danger: true }))) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
@@ -170,14 +169,7 @@ function handleMemoBodyShortcut(event: KeyboardEvent) {
|
||||
formatMemoBody(format)
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
||||
if (!props.mobile || event.key !== 'Tab' || !root.value) return
|
||||
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const first = controls[0], last = controls.at(-1)!
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save() }
|
||||
}
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
@@ -186,7 +178,7 @@ defineExpose({ dirty, requestClose: close })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
|
||||
<div class="memo-editor" @keydown="keydown">
|
||||
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
|
||||
<div class="memo-editor__fields">
|
||||
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
|
||||
@@ -213,5 +205,5 @@ defineExpose({ dirty, requestClose: close })
|
||||
</div>
|
||||
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
type OverlayEntry = {
|
||||
id: symbol
|
||||
close: () => void
|
||||
busy: () => boolean
|
||||
restoreFocus: HTMLElement | null
|
||||
focusPanel: () => void
|
||||
}
|
||||
|
||||
const stack: OverlayEntry[] = []
|
||||
const background = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>()
|
||||
let listening = false
|
||||
|
||||
function root() {
|
||||
let element = document.getElementById('overlay-root')
|
||||
if (!element) {
|
||||
element = document.createElement('div')
|
||||
element.id = 'overlay-root'
|
||||
document.body.appendChild(element)
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function syncBackground() {
|
||||
const overlayRoot = root()
|
||||
if (stack.length) {
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!(child instanceof HTMLElement) || child === overlayRoot || background.has(child)) continue
|
||||
background.set(child, { inert: child.hasAttribute('inert'), ariaHidden: child.getAttribute('aria-hidden') })
|
||||
child.setAttribute('inert', '')
|
||||
child.setAttribute('aria-hidden', 'true')
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const [element, state] of background) {
|
||||
if (!state.inert) element.removeAttribute('inert')
|
||||
if (state.ariaHidden === null) element.removeAttribute('aria-hidden')
|
||||
else element.setAttribute('aria-hidden', state.ariaHidden)
|
||||
}
|
||||
background.clear()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
const entry = stack.at(-1)
|
||||
if (!entry || entry.busy()) return
|
||||
event.preventDefault()
|
||||
entry.close()
|
||||
}
|
||||
|
||||
export function overlayRoot() { return root() }
|
||||
|
||||
export function pushOverlay(close: () => void, busy: () => boolean, focusPanel: () => void) {
|
||||
const entry: OverlayEntry = {
|
||||
id: Symbol('overlay'), close, busy, focusPanel,
|
||||
restoreFocus: document.activeElement instanceof HTMLElement ? document.activeElement : null,
|
||||
}
|
||||
stack.push(entry)
|
||||
if (!listening) { document.addEventListener('keydown', onKeydown); listening = true }
|
||||
syncBackground()
|
||||
return entry.id
|
||||
}
|
||||
|
||||
export function popOverlay(id: symbol) {
|
||||
const index = stack.findIndex((entry) => entry.id === id)
|
||||
if (index < 0) return
|
||||
const wasTop = index === stack.length - 1
|
||||
const [entry] = stack.splice(index, 1)
|
||||
if (!stack.length && listening) { document.removeEventListener('keydown', onKeydown); listening = false }
|
||||
syncBackground()
|
||||
if (!wasTop) return
|
||||
const newTop = stack.at(-1)
|
||||
void nextTick(() => {
|
||||
if (newTop) newTop.focusPanel()
|
||||
else if (entry.restoreFocus?.isConnected) entry.restoreFocus.focus()
|
||||
})
|
||||
}
|
||||
|
||||
export function isTopOverlay(id: symbol | null) {
|
||||
return Boolean(id && stack.at(-1)?.id === id)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { backupFileSnapshot, isCurrentBackupSnapshot, shouldCommitBackupPreflight } from './backup-preflight-state'
|
||||
|
||||
describe('backup preflight identity', () => {
|
||||
it('rejects a preflight result after the selected file changes even when the old request finishes last', () => {
|
||||
const oldFile = new File(['old'], 'old.zip', { lastModified: 10 })
|
||||
const newFile = new File(['new'], 'new.zip', { lastModified: 20 })
|
||||
const started = backupFileSnapshot(oldFile, 'merge')
|
||||
|
||||
expect(isCurrentBackupSnapshot(started, newFile, 'merge')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a token for one filename authorize another file with matching metadata', () => {
|
||||
const first = new File(['same'], 'first.zip', { lastModified: 10 })
|
||||
const second = new File(['same'], 'second.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), second, 'merge')).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates a preflight when restore mode changes', () => {
|
||||
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(file, 'merge'), file, 'replace')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an older generation even when its file snapshot still matches', () => {
|
||||
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||
expect(shouldCommitBackupPreflight(1, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(false)
|
||||
expect(shouldCommitBackupPreflight(2, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(true)
|
||||
})
|
||||
|
||||
it('binds identity to the exact File object as well as name size and modified time', () => {
|
||||
const first = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||
const replacement = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), replacement, 'merge')).toBe(false)
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), first, 'merge')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BackupMode } from '../api'
|
||||
|
||||
export type BackupFileSnapshot = {
|
||||
file: File
|
||||
name: string
|
||||
size: number
|
||||
lastModified: number
|
||||
mode: BackupMode
|
||||
}
|
||||
|
||||
export function backupFileSnapshot(file: File, mode: BackupMode): BackupFileSnapshot {
|
||||
return { file, name: file.name, size: file.size, lastModified: file.lastModified, mode }
|
||||
}
|
||||
|
||||
export function isCurrentBackupSnapshot(snapshot: BackupFileSnapshot, file: File | null, mode: BackupMode) {
|
||||
return Boolean(file)
|
||||
&& snapshot.file === file
|
||||
&& snapshot.name === file!.name
|
||||
&& snapshot.size === file!.size
|
||||
&& snapshot.lastModified === file!.lastModified
|
||||
&& snapshot.mode === mode
|
||||
}
|
||||
|
||||
export function shouldCommitBackupPreflight(
|
||||
generation: number,
|
||||
currentGeneration: number,
|
||||
snapshot: BackupFileSnapshot,
|
||||
file: File | null,
|
||||
mode: BackupMode,
|
||||
) {
|
||||
return generation === currentGeneration && isCurrentBackupSnapshot(snapshot, file, mode)
|
||||
}
|
||||
|
||||
export function isLegacyBackup(file: File) {
|
||||
return !file.name.toLowerCase().endsWith('.zip')
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextDialogFocusIndex } from './list-purge'
|
||||
|
||||
describe('archived list purge dialog behavior', () => {
|
||||
it('wraps Tab focus between the cancel and destructive actions', () => {
|
||||
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
|
||||
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves focus alone while moving between interior controls', () => {
|
||||
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
|
||||
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
|
||||
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
|
||||
if (controlCount < 2) return null
|
||||
if (shiftKey && currentIndex === 0) return controlCount - 1
|
||||
if (!shiftKey && currentIndex === controlCount - 1) return 0
|
||||
return null
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||
|
||||
+11
-14
File diff suppressed because one or more lines are too long
+75
-48
@@ -99,10 +99,19 @@ describe('mobile navigation styles', () => {
|
||||
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
||||
})
|
||||
|
||||
it('keeps the mobile More sheet visible when it is rendered', () => {
|
||||
expect(css).not.toContain('.more-mask{display:none}')
|
||||
expect(css).toContain('@media(max-width:930px){.shell')
|
||||
expect(css).toContain('.more-mask{position:fixed;z-index:45;')
|
||||
it('has no unreachable mobile More state, template, or styles', () => {
|
||||
expect(app).not.toContain('mobileMore')
|
||||
expect(app).not.toContain('mobile-more-menu')
|
||||
expect(app).not.toContain('more-mask')
|
||||
expect(css).not.toContain('.more-mask')
|
||||
expect(css).not.toContain('.more-sheet')
|
||||
expect(css).not.toContain('.task-compose-mask')
|
||||
expect(css).not.toContain('.habit-detail-mask')
|
||||
expect(css).not.toContain('.countdown-detail-mask')
|
||||
expect(css).not.toContain('.countdown-modal-mask')
|
||||
expect(css).not.toContain('.modal-mask')
|
||||
expect(css).not.toContain('.modal-box')
|
||||
expect(css).not.toContain('.countdown-compose-enter')
|
||||
})
|
||||
|
||||
it('lets the mobile sidebar scrim cover the outside area and stay below the sidebar', () => {
|
||||
@@ -125,7 +134,7 @@ describe('settings sessions and audit activity', () => {
|
||||
expect(mvpPanel).toContain('class="session-card-actions"')
|
||||
expect(mvpPanel).toContain('撤销其他所有会话')
|
||||
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
|
||||
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')")
|
||||
expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
|
||||
expect(css).toContain('.session-card-actions{')
|
||||
})
|
||||
|
||||
@@ -186,10 +195,11 @@ describe('solid cream material system', () => {
|
||||
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
||||
expect(css).toContain('main{background:var(--surface-base)}')
|
||||
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
||||
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||
expect(css).toContain('.app-sheet,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||
expect(css).toContain('.toast{background:#3b342c')
|
||||
expect(css).toContain('.error-toast{background:var(--danger)')
|
||||
expect(css).toContain('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
|
||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
|
||||
})
|
||||
|
||||
it('keeps compact continuous lists without per-row outer shadows', () => {
|
||||
@@ -409,14 +419,19 @@ describe('archived task-list disclosure', () => {
|
||||
})
|
||||
|
||||
describe('mobile sheet contract', () => {
|
||||
it('uses shared roles for details, creation and secondary actions', () => {
|
||||
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
|
||||
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
|
||||
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||||
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
|
||||
it('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
|
||||
expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
|
||||
expect(css).toContain('.app-overlay>.detail{transform:none}')
|
||||
})
|
||||
|
||||
it('uses shared AppSheet variants for details and creation', () => {
|
||||
expect(app).toContain('panel-class="task-compose-sheet"')
|
||||
expect(app).toContain('variant="create"')
|
||||
expect(app).not.toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||||
expect(mvpPanel).toContain('panel-class="task-compose-sheet habit-compose-sheet"')
|
||||
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
||||
@@ -447,8 +462,7 @@ describe('mobile list row language', () => {
|
||||
|
||||
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
|
||||
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
||||
@@ -466,8 +480,8 @@ describe('mobile list row language', () => {
|
||||
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
|
||||
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('ref="habitDetailSheet"')
|
||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||||
expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
|
||||
expect(mvpPanel).not.toContain('habitDetailSheet')
|
||||
})
|
||||
|
||||
it('provides an archived-habit viewing path', () => {
|
||||
@@ -927,7 +941,7 @@ describe('task and habit row decoration', () => {
|
||||
})
|
||||
|
||||
it('shows a password form with confirmation and calls the protected endpoint', () => {
|
||||
expect(mvpPanel).toContain('class="password-form"')
|
||||
expect(mvpPanel).toContain('class="password-form settings-form"')
|
||||
expect(mvpPanel).toContain('aria-label="当前密码"')
|
||||
expect(mvpPanel).toContain('aria-label="新密码"')
|
||||
expect(mvpPanel).toContain('aria-label="确认新密码"')
|
||||
@@ -972,20 +986,22 @@ describe('mobile touch targets', () => {
|
||||
})
|
||||
|
||||
describe('approved habit safety and U2 title hierarchy', () => {
|
||||
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
|
||||
it('uses one page title and continuous settings section headings', () => {
|
||||
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
||||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||||
expect(mvpPanel).toContain('<h2>修改密码</h2>')
|
||||
expect(mvpPanel).toContain('<h2>登录会话</h2>')
|
||||
expect(mvpPanel).toContain('<h2>最近活动</h2>')
|
||||
expect(css).toContain('.tool-card>h2{')
|
||||
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||
expect(mvpPanel).toContain('class="settings-sections"')
|
||||
expect(mvpPanel).not.toContain('class="settings-grid"')
|
||||
expect(mvpPanel).not.toContain('class="tool-card')
|
||||
expect(css).toContain('.settings-sections{width:min(100%,760px);')
|
||||
expect(css).toContain('.settings-row{min-height:56px;')
|
||||
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
||||
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
|
||||
})
|
||||
|
||||
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
||||
expect(app).toContain('const modalError = ref')
|
||||
expect(app).toContain('role="alert" class="field-error"')
|
||||
expect(app).toContain('normalizeRequiredName')
|
||||
expect(app).toContain('const appDialog = ref')
|
||||
expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
|
||||
expect(mvpPanel).toContain('habitErrors.name')
|
||||
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
|
||||
expect(mvpPanel).toContain('const habitFormInvalid = computed')
|
||||
@@ -1009,17 +1025,26 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
||||
})
|
||||
|
||||
describe('settings data tools', () => {
|
||||
it('keeps backup export and restore but removes the standalone import tool', () => {
|
||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||||
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
|
||||
expect(mvpPanel).toContain("'dodo-export.csv'")
|
||||
expect(mvpPanel).toContain('导出 CSV')
|
||||
expect(mvpPanel).not.toContain('导出 JSON')
|
||||
expect(mvpPanel).toContain('@click="restore"')
|
||||
expect(mvpPanel).not.toContain('<h3>导入</h3>')
|
||||
it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
|
||||
expect(mvpPanel).toContain('<h2>数据</h2>')
|
||||
expect(mvpPanel).toContain("downloadFullBackup()")
|
||||
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
|
||||
expect(mvpPanel).toContain('导出 ZIP')
|
||||
expect(mvpPanel).toContain('accept=".zip,.csv,.json')
|
||||
expect(mvpPanel).toContain('v-model="restoreMode"')
|
||||
expect(mvpPanel).toContain('runPreflight')
|
||||
expect(mvpPanel).toContain('restorePreflight.valid')
|
||||
expect(mvpPanel).toContain('preflight_token')
|
||||
expect(mvpPanel).toContain('await restoreBackup')
|
||||
expect(mvpPanel).toContain("await uploadJson('/restore.csv?mode=merge'")
|
||||
expect(mvpPanel).toContain("await requestJson('/restore?mode=merge'")
|
||||
expect(mvpPanel).toContain('const snapshot = backupFileSnapshot(file, restoreMode.value)')
|
||||
expect(mvpPanel).toContain('shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)')
|
||||
expect(mvpPanel).toContain('preflightController?.abort()')
|
||||
expect(mvpPanel).toContain("旧格式将在恢复时校验,不支持完整预检或 Replace。")
|
||||
expect(mvpPanel).toContain('<option v-if="!legacyRestore" value="replace">')
|
||||
expect(mvpPanel).toContain("emit('changed'); emit('notice', '数据已恢复')")
|
||||
expect(mvpPanel).not.toContain("request('/import/ticktick")
|
||||
expect(mvpPanel).not.toContain('importFile')
|
||||
expect(mvpPanel).not.toContain('importPreview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1132,8 +1157,8 @@ describe('unified floating add interaction', () => {
|
||||
expect(floatingAdd).toContain("emit('activate',")
|
||||
expect(css).toContain('.unified-fab.dragging')
|
||||
expect(css).toContain('.unified-fab.snapping')
|
||||
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
|
||||
expect(css).toContain('.countdown-compose-enter-active')
|
||||
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
||||
})
|
||||
|
||||
@@ -1169,7 +1194,8 @@ describe('unified floating add interaction', () => {
|
||||
describe('desktop task detail disclosure', () => {
|
||||
it('gives the task list the full remaining width until a task is selected', () => {
|
||||
expect(app).toContain("'detail-open': Boolean(selectedTask)")
|
||||
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
|
||||
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
|
||||
expect(app).toContain('panel-class="detail"')
|
||||
expect(app).toContain('@click="closeTaskDetail"')
|
||||
expect(app).toContain('function closeTaskDetail()')
|
||||
expect(app).not.toContain('<div v-else class="paper">')
|
||||
@@ -1233,8 +1259,8 @@ describe('sidebar information hierarchy', () => {
|
||||
it('groups folder and list editing actions into a clear compact hierarchy', () => {
|
||||
expect(app).toContain('aria-label="打开文件夹操作"')
|
||||
expect(app).toContain('aria-label="打开清单操作"')
|
||||
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
|
||||
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
|
||||
expect(app).toContain('panel-class="sidebar-action-sheet"')
|
||||
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
|
||||
expect(app).toContain('class="sidebar-action-kind"')
|
||||
expect(app).toContain('class="sidebar-action-group"')
|
||||
expect(app).toContain('class="sidebar-action-group-title"')
|
||||
@@ -1382,10 +1408,11 @@ describe('sidebar layout', () => {
|
||||
|
||||
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
||||
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
||||
expect(app).toContain('ref="purgeCancelButton"')
|
||||
expect(app).toContain('purgeCancelButton.value?.focus()')
|
||||
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
|
||||
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
|
||||
expect(app).toContain('panel-class="purge-list-dialog"')
|
||||
expect(app).toContain('initial-focus=".secondary"')
|
||||
expect(app).toContain(':busy="purgeListSubmitting"')
|
||||
expect(app).toContain('@close="closePurgeList"')
|
||||
expect(app).not.toContain('handlePurgeDialogKeydown')
|
||||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
||||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""add durable backup preflights and import ledger
|
||||
|
||||
Revision ID: 0019_backup_imports
|
||||
Revises: 0018_task_completed_at
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0019_backup_imports"
|
||||
down_revision = "0018_task_completed_at"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"backup_preflights",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("archive_size", sa.Integer(), nullable=False),
|
||||
sa.Column("staging_path", sa.String(length=1024), nullable=False),
|
||||
sa.Column("mode", sa.String(length=16), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cleanup_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("staging_path"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("ix_backup_preflights_user_id", "backup_preflights", ["user_id"])
|
||||
op.create_index("ix_backup_preflights_backup_id", "backup_preflights", ["backup_id"])
|
||||
op.create_index("ix_backup_preflights_status", "backup_preflights", ["status"])
|
||||
op.create_index("ix_backup_preflights_expires_at", "backup_preflights", ["expires_at"])
|
||||
op.create_table(
|
||||
"backup_imports",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("archive_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("mode", sa.String(length=16), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id", "backup_id", name="uq_backup_import_user_backup"),
|
||||
)
|
||||
op.create_index("ix_backup_imports_user_id", "backup_imports", ["user_id"])
|
||||
op.create_index("ix_backup_imports_backup_id", "backup_imports", ["backup_id"])
|
||||
op.create_table(
|
||||
"backup_import_entities",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("backup_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("entity_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("target_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("content_digest", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"user_id", "backup_id", "entity_type", "source_id",
|
||||
name="uq_backup_import_entity_source",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_backup_import_entities_user_id", "backup_import_entities", ["user_id"])
|
||||
op.create_index("ix_backup_import_entities_backup_id", "backup_import_entities", ["backup_id"])
|
||||
op.create_index("ix_backup_import_entities_source_id", "backup_import_entities", ["source_id"])
|
||||
op.create_index("ix_backup_import_entities_target_id", "backup_import_entities", ["target_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_backup_import_entities_target_id", table_name="backup_import_entities")
|
||||
op.drop_index("ix_backup_import_entities_source_id", table_name="backup_import_entities")
|
||||
op.drop_index("ix_backup_import_entities_backup_id", table_name="backup_import_entities")
|
||||
op.drop_index("ix_backup_import_entities_user_id", table_name="backup_import_entities")
|
||||
op.drop_table("backup_import_entities")
|
||||
op.drop_index("ix_backup_imports_backup_id", table_name="backup_imports")
|
||||
op.drop_index("ix_backup_imports_user_id", table_name="backup_imports")
|
||||
op.drop_table("backup_imports")
|
||||
op.drop_index("ix_backup_preflights_expires_at", table_name="backup_preflights")
|
||||
op.drop_index("ix_backup_preflights_status", table_name="backup_preflights")
|
||||
op.drop_index("ix_backup_preflights_backup_id", table_name="backup_preflights")
|
||||
op.drop_index("ix_backup_preflights_user_id", table_name="backup_preflights")
|
||||
op.drop_table("backup_preflights")
|
||||
+12
-1
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -11,9 +13,18 @@ def client(tmp_path: Path):
|
||||
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
|
||||
from backend.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from backend.db import reset_engine
|
||||
from backend.db import get_engine, reset_engine
|
||||
reset_engine()
|
||||
engine = get_engine()
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def enable_sqlite_foreign_keys(dbapi_connection, _):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
from backend.main import app
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
asyncio.run(engine.dispose())
|
||||
reset_engine()
|
||||
|
||||
@@ -242,7 +242,7 @@ def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
||||
assert recurrence["after_completion_days"] == 2
|
||||
assert recurrence["last_completed_at"] is None
|
||||
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
||||
restored = client.post("/api/v1/restore?mode=merge", json=exported)
|
||||
assert restored.status_code == 200
|
||||
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0]
|
||||
restored_recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||
@@ -252,7 +252,7 @@ def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
|
||||
csv_export = client.get("/api/v1/export.csv")
|
||||
assert csv_export.status_code == 200
|
||||
csv_restore = client.post(
|
||||
"/api/v1/restore.csv?mode=replace",
|
||||
"/api/v1/restore.csv?mode=merge",
|
||||
files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
|
||||
)
|
||||
assert csv_restore.status_code == 200
|
||||
|
||||
+3
-2
@@ -532,12 +532,13 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
|
||||
"/api/v1/habits",
|
||||
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"},
|
||||
)
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported.json())
|
||||
restored = client.post("/api/v1/restore?mode=merge", json=exported.json())
|
||||
assert restored.status_code == 200
|
||||
|
||||
assert client.get("/api/v1/tags").status_code == 404
|
||||
habits = client.get("/api/v1/habits").json()
|
||||
assert [row["name"] for row in habits] == ["俯卧撑"]
|
||||
assert "俯卧撑" in [row["name"] for row in habits]
|
||||
assert "深蹲" in [row["name"] for row in habits]
|
||||
listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
|
||||
assert "tags" not in listed[0]
|
||||
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.models import Attachment, BackupImportEntity
|
||||
from tests.test_backup_v2 import _preflight
|
||||
from tests.test_mvp_backend import boot
|
||||
|
||||
|
||||
async def _attachment_state(attachment_id: str) -> tuple[Attachment, int]:
|
||||
from backend.db import get_engine
|
||||
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
attachment = await db.get(Attachment, UUID(attachment_id))
|
||||
ledger_count = await db.scalar(
|
||||
select(func.count()).select_from(BackupImportEntity).where(
|
||||
BackupImportEntity.entity_type == "attachments",
|
||||
BackupImportEntity.source_id == UUID(attachment_id),
|
||||
)
|
||||
)
|
||||
return attachment, ledger_count or 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["missing", "corrupt"])
|
||||
def test_merge_existing_attachment_without_ledger_never_certifies_bad_file(
|
||||
client, tmp_path, damage
|
||||
):
|
||||
from backend.config import get_settings
|
||||
|
||||
inbox = boot(client)
|
||||
root = tmp_path / "attachments"
|
||||
get_settings().attachment_dir = str(root)
|
||||
task = client.post(
|
||||
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
||||
).json()
|
||||
payload = b"trusted attachment bytes"
|
||||
attachment = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("proof.txt", payload, "text/plain")},
|
||||
).json()
|
||||
archive = client.get("/api/v1/backup/export.zip").content
|
||||
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||
assert ledger_count == 0
|
||||
stored = root / row.storage_name
|
||||
if damage == "missing":
|
||||
stored.unlink()
|
||||
else:
|
||||
stored.write_bytes(b"x" * len(payload))
|
||||
|
||||
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
||||
response = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": token, "mode": "merge"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"]["code"] in {
|
||||
"backup_entity_missing",
|
||||
"backup_entity_conflict",
|
||||
}
|
||||
_, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||
assert ledger_count == 0
|
||||
|
||||
|
||||
def test_merge_existing_attachment_without_ledger_hashes_equal_bytes_before_ledger(
|
||||
client, tmp_path
|
||||
):
|
||||
from backend.config import get_settings
|
||||
|
||||
inbox = boot(client)
|
||||
root = tmp_path / "attachments"
|
||||
get_settings().attachment_dir = str(root)
|
||||
task = client.post(
|
||||
"/api/v1/tasks", json={"title": "attachment collision", "list_id": inbox["id"]}
|
||||
).json()
|
||||
payload = b"trusted attachment bytes"
|
||||
attachment = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("proof.txt", payload, "text/plain")},
|
||||
).json()
|
||||
archive = client.get("/api/v1/backup/export.zip").content
|
||||
|
||||
token = _preflight(client, archive, "merge").json()["preflight_token"]
|
||||
response = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": token, "mode": "merge"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
row, ledger_count = asyncio.run(_attachment_state(attachment["id"]))
|
||||
assert ledger_count == 1
|
||||
assert Path(root / row.storage_name).read_bytes() == payload
|
||||
|
||||
|
||||
async def _business_counts() -> tuple[int, ...]:
|
||||
from backend.backup.service import ENTITY_MODELS
|
||||
from backend.db import get_engine
|
||||
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
counts = []
|
||||
for model in ENTITY_MODELS.values():
|
||||
counts.append((await db.scalar(select(func.count()).select_from(model))) or 0)
|
||||
return tuple(counts)
|
||||
|
||||
|
||||
def _base_graph() -> dict[str, list[dict]]:
|
||||
list_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
task_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
recurrence_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||
return {
|
||||
"lists": [{"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}],
|
||||
"tasks": [{
|
||||
"id": task_id,
|
||||
"list_id": list_id,
|
||||
"parent_id": None,
|
||||
"title": "valid task",
|
||||
"description": "",
|
||||
"priority": 0,
|
||||
"completed": False,
|
||||
"completed_at": None,
|
||||
"due_at": "2026-09-20T08:00:00+00:00",
|
||||
"due_has_time": True,
|
||||
"version": 1,
|
||||
"position": 0,
|
||||
"external_id": None,
|
||||
}],
|
||||
"recurrences": [{
|
||||
"id": recurrence_id,
|
||||
"task_id": task_id,
|
||||
"rrule": "FREQ=WEEKLY;BYDAY=MO",
|
||||
"starts_at": "2026-09-20T08:00:00+00:00",
|
||||
"ends_at": None,
|
||||
"trigger_mode": "scheduled",
|
||||
"after_completion_days": None,
|
||||
"last_completed_at": None,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutate", "label"),
|
||||
[
|
||||
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=None), "after completion days required"),
|
||||
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule="FREQ=DAILY", after_completion_days=1), "after completion excludes rrule"),
|
||||
(lambda graph: graph["recurrences"][0].update(trigger_mode="after_completion", rrule=None, after_completion_days=0), "after completion range"),
|
||||
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", rrule=None, after_completion_days=None), "scheduled requires rrule"),
|
||||
(lambda graph: graph["recurrences"][0].update(trigger_mode="scheduled", after_completion_days=1), "scheduled excludes days"),
|
||||
(lambda graph: graph["recurrences"][0].update(rrule="FREQ=NOPE"), "rrule parses"),
|
||||
(lambda graph: graph["recurrences"][0].update(ends_at="2026-09-19T08:00:00+00:00"), "ends after starts"),
|
||||
(lambda graph: graph["recurrences"][0].update(last_completed_at="2026-09-21T08:00:00+00:00"), "last completion before start"),
|
||||
(lambda graph: graph["tasks"][0].update(due_at=None, due_has_time=False), "recurring task has due"),
|
||||
(lambda graph: graph["tasks"][0].update(parent_id="dddddddd-dddd-4ddd-8ddd-dddddddddddd"), "recurring task top level"),
|
||||
],
|
||||
)
|
||||
def test_recurrence_preflight_rejects_invalid_contract_without_business_writes(client, mutate, label):
|
||||
boot(client)
|
||||
graph = _base_graph()
|
||||
if label == "recurring task top level":
|
||||
graph["tasks"].append({
|
||||
**graph["tasks"][0],
|
||||
"id": "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
"title": "parent",
|
||||
"parent_id": None,
|
||||
})
|
||||
mutate(graph)
|
||||
before = asyncio.run(_business_counts())
|
||||
|
||||
content = _make_archive_from_graph(graph)
|
||||
response = _preflight(client, content, "replace")
|
||||
|
||||
assert response.status_code == 422, (label, response.text)
|
||||
assert response.json()["detail"]["code"] == "backup_recurrence_invalid"
|
||||
assert asyncio.run(_business_counts()) == before
|
||||
|
||||
|
||||
def _make_archive_from_graph(
|
||||
graph: dict[str, list[dict]],
|
||||
files: dict[str, bytes] | None = None,
|
||||
*,
|
||||
backup_id: str = "11111111-1111-4111-8111-111111111111",
|
||||
) -> bytes:
|
||||
from tests.test_backup_v2 import _make_zip
|
||||
|
||||
entries = {
|
||||
f"data/{entity}.json": __import__("json").dumps(rows).encode()
|
||||
for entity, rows in graph.items()
|
||||
}
|
||||
entries.update(files or {})
|
||||
return _make_zip(entries, backup_id=backup_id)
|
||||
|
||||
|
||||
def _task_tree_graph(parent_ids: list[str | None]) -> dict[str, list[dict]]:
|
||||
graph = _base_graph()
|
||||
graph["recurrences"] = []
|
||||
template = graph["tasks"][0]
|
||||
task_ids = [
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
]
|
||||
graph["tasks"] = [
|
||||
{
|
||||
**template,
|
||||
"id": task_id,
|
||||
"parent_id": parent_id,
|
||||
"title": f"tree task {index}",
|
||||
"position": index,
|
||||
}
|
||||
for index, (task_id, parent_id) in enumerate(zip(task_ids, parent_ids, strict=True))
|
||||
]
|
||||
return graph
|
||||
|
||||
|
||||
def test_preflight_rejects_three_level_task_tree_without_business_writes(client):
|
||||
boot(client)
|
||||
graph = _task_tree_graph([
|
||||
None,
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
])
|
||||
before = asyncio.run(_business_counts())
|
||||
|
||||
response = _preflight(client, _make_archive_from_graph(graph), "replace")
|
||||
|
||||
assert response.status_code == 422, response.text
|
||||
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||
assert asyncio.run(_business_counts()) == before
|
||||
|
||||
|
||||
def test_parent_with_multiple_children_round_trips_in_arbitrary_zip_order(client):
|
||||
boot(client)
|
||||
parent_id = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
|
||||
graph = _task_tree_graph([parent_id, None, parent_id])
|
||||
graph["tasks"] = [graph["tasks"][2], graph["tasks"][1], graph["tasks"][0]]
|
||||
|
||||
preflight = _preflight(client, _make_archive_from_graph(graph), "replace")
|
||||
assert preflight.status_code == 200, preflight.text
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
|
||||
from tests.test_backup_v2 import _archive_rows
|
||||
|
||||
rows = _archive_rows(client.get("/api/v1/backup/export.zip").content, "tasks")
|
||||
by_id = {row["id"]: row for row in rows}
|
||||
assert by_id[parent_id]["parent_id"] is None
|
||||
assert {
|
||||
row["id"] for row in rows if row["parent_id"] == parent_id
|
||||
} == {
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
}
|
||||
items = client.get("/api/v1/tasks", params={"q": "tree task"}).json()["items"]
|
||||
restored_parent = next(item for item in items if item["id"] == parent_id)
|
||||
assert {child["id"] for child in restored_parent["subtasks"]} == {
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recurrence",
|
||||
[
|
||||
{
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1,15;COUNT=8",
|
||||
"trigger_mode": "scheduled",
|
||||
"after_completion_days": None,
|
||||
},
|
||||
{
|
||||
"rrule": None,
|
||||
"trigger_mode": "after_completion",
|
||||
"after_completion_days": 30,
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_both_recurrence_modes_round_trip_through_replace(client, recurrence):
|
||||
boot(client)
|
||||
graph = _base_graph()
|
||||
graph["recurrences"][0].update(recurrence)
|
||||
content = _make_archive_from_graph(graph)
|
||||
preflight = _preflight(client, content, "replace")
|
||||
assert preflight.status_code == 200, preflight.text
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
exported = client.get("/api/v1/backup/export.zip").content
|
||||
from tests.test_backup_v2 import _archive_rows
|
||||
|
||||
row = _archive_rows(exported, "recurrences")[0]
|
||||
assert row["rrule"] == recurrence["rrule"]
|
||||
assert row["trigger_mode"] == recurrence["trigger_mode"]
|
||||
assert row["after_completion_days"] == recurrence["after_completion_days"]
|
||||
|
||||
|
||||
def _entity_case(entity: str, row: dict, files: dict[str, bytes] | None = None) -> bytes:
|
||||
graph = _base_graph()
|
||||
graph[entity] = [row]
|
||||
return _make_archive_from_graph(graph, files)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity", "row", "files"),
|
||||
[
|
||||
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": " ", "position": 0}, None),
|
||||
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x" * 121, "position": 0}, None),
|
||||
("folders", {"id": "10101010-1010-4010-8010-101010101010", "name": "x", "position": -1}, None),
|
||||
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "x" * 121, "is_inbox": True, "position": 0}, None),
|
||||
("lists", {"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "name": "Inbox", "is_inbox": True, "position": -1}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "title": " "}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "priority": 4}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "version": 0}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "position": -1}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "due_at": None, "due_has_time": True}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "completed": False, "completed_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||
("tasks", {**_base_graph()["tasks"][0], "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "bad", "lunar_month": None, "lunar_day": None, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
||||
("countdowns", {"id": "20202020-2020-4020-8020-202020202020", "title": "x", "event_date": "2026-09-20", "calendar_mode": "solar", "lunar_month": 1, "lunar_day": 1, "ignore_year": False, "kind": "countdown", "repeat_rule": "none", "icon": "x", "pinned": False}, None),
|
||||
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": " ", "content": "", "version": 1}, None),
|
||||
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 0}, None),
|
||||
("memos", {"id": "30303030-3030-4030-8030-303030303030", "title": "x", "content": "", "version": 1, "created_at": "2026-09-21T08:00:00+00:00", "updated_at": "2026-09-20T08:00:00+00:00"}, None),
|
||||
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x" * 256, "mime_type": "text/plain", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "application/x-danger", "size": 1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||
("attachments", {"id": "40404040-4040-4040-8040-404040404040", "task_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "filename": "x", "mime_type": "text/plain", "size": -1, "archive_path": "attachments/40404040-4040-4040-8040-404040404040/content"}, {"attachments/40404040-4040-4040-8040-404040404040/content": b"x"}),
|
||||
],
|
||||
)
|
||||
def test_all_entity_contracts_fail_preflight_without_business_writes(client, entity, row, files):
|
||||
boot(client)
|
||||
before = asyncio.run(_business_counts())
|
||||
|
||||
response = _preflight(client, _entity_case(entity, row, files), "replace")
|
||||
|
||||
assert response.status_code == 422, (entity, response.text)
|
||||
assert asyncio.run(_business_counts()) == before
|
||||
|
||||
|
||||
def test_legacy_replace_is_rejected_without_mutating_data(client):
|
||||
inbox = boot(client)
|
||||
task = client.post(
|
||||
"/api/v1/tasks", json={"title": "must survive", "list_id": inbox["id"]}
|
||||
).json()
|
||||
payload = client.get("/api/v1/export").json()
|
||||
|
||||
response = client.post("/api/v1/restore?mode=replace", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == {
|
||||
"code": "legacy_replace_unsupported",
|
||||
"message": "旧版备份仅支持合并恢复",
|
||||
}
|
||||
assert client.get(f"/api/v1/tasks/{task['id']}").status_code == 200
|
||||
|
||||
|
||||
def test_legacy_csv_upload_has_streaming_hard_limit(client, monkeypatch):
|
||||
boot(client)
|
||||
from backend import mvp
|
||||
|
||||
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_BYTES", 32)
|
||||
response = client.post(
|
||||
"/api/v1/restore.csv?mode=merge",
|
||||
files={"file": ("backup.csv", b"entity,data\n" + b"x" * 33, "text/csv")},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == {
|
||||
"code": "legacy_backup_too_large",
|
||||
"message": "旧版备份文件过大",
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_json_rejects_excessive_records(client, monkeypatch):
|
||||
boot(client)
|
||||
from backend import mvp
|
||||
|
||||
monkeypatch.setattr(mvp, "LEGACY_BACKUP_MAX_RECORDS", 1)
|
||||
payload = {"version": 1, "folders": [], "lists": [], "tasks": [
|
||||
{"id": "11111111-1111-4111-8111-111111111111"},
|
||||
{"id": "22222222-2222-4222-8222-222222222222"},
|
||||
]}
|
||||
|
||||
response = client.post("/api/v1/restore?mode=merge", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"] == "legacy_backup_too_many_records"
|
||||
|
||||
|
||||
def test_v2_preflight_rejects_multiple_active_pinned_countdowns(client):
|
||||
boot(client)
|
||||
graph = _base_graph()
|
||||
graph["recurrences"] = []
|
||||
graph["countdowns"] = [
|
||||
{
|
||||
"id": f"{index:08d}-2020-4020-8020-202020202020",
|
||||
"title": f"pinned {index}", "event_date": "2026-09-20",
|
||||
"calendar_mode": "solar", "lunar_month": None, "lunar_day": None,
|
||||
"ignore_year": False, "kind": "countdown", "repeat_rule": "none",
|
||||
"icon": "x", "pinned": True, "archived_at": None,
|
||||
}
|
||||
for index in (1, 2)
|
||||
]
|
||||
|
||||
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||
|
||||
|
||||
def test_reaper_removes_failed_and_stranded_staging_and_repairs_cleanup(client, tmp_path):
|
||||
boot(client)
|
||||
from backend.backup.router import _prune
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_engine
|
||||
from backend.models import BackupPreflight, utcnow
|
||||
|
||||
staging_root = tmp_path / "staging"
|
||||
staging_root.mkdir()
|
||||
get_settings().backup_staging_dir = str(staging_root)
|
||||
failed_file = staging_root / "failed.zip"
|
||||
consuming_file = staging_root / "consuming.zip"
|
||||
failed_file.write_bytes(b"failed")
|
||||
consuming_file.write_bytes(b"consuming")
|
||||
cleanup = tmp_path / "cleanup"
|
||||
cleanup.mkdir()
|
||||
(cleanup / "old").write_bytes(b"old")
|
||||
|
||||
async def exercise():
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
user_id = await db.scalar(select(__import__("backend.models", fromlist=["User"]).User.id))
|
||||
now = utcnow() - timedelta(hours=1)
|
||||
rows = [
|
||||
BackupPreflight(
|
||||
token_hash=str(index) * 64, user_id=user_id,
|
||||
backup_id=UUID(f"00000000-0000-4000-8000-00000000000{index}"),
|
||||
archive_sha256="0" * 64, archive_size=10,
|
||||
staging_path=str(path), mode="merge", status=status,
|
||||
expires_at=now, consumed_at=now,
|
||||
cleanup_path=str(cleanup) if status == "cleanup_pending" else None,
|
||||
)
|
||||
for index, (status, path) in enumerate(
|
||||
[("failed", failed_file), ("consuming", consuming_file),
|
||||
("cleanup_pending", staging_root / "cleanup.zip")], start=1
|
||||
)
|
||||
]
|
||||
db.add_all(rows)
|
||||
await db.flush()
|
||||
row_ids = [row.id for row in rows]
|
||||
await db.commit()
|
||||
await _prune(db)
|
||||
statuses = {
|
||||
str(row_id): await db.scalar(
|
||||
select(BackupPreflight.status).where(BackupPreflight.id == row_id)
|
||||
)
|
||||
for row_id in row_ids
|
||||
}
|
||||
return row_ids, statuses
|
||||
|
||||
row_ids, statuses = asyncio.run(exercise())
|
||||
assert not failed_file.exists()
|
||||
assert not consuming_file.exists()
|
||||
assert not cleanup.exists()
|
||||
assert statuses[str(row_ids[2])] == "consumed"
|
||||
|
||||
|
||||
def test_expired_repair_pending_is_repaired_not_deleted_with_quarantine(client, tmp_path):
|
||||
boot(client)
|
||||
from backend.backup.router import _prune
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_engine
|
||||
from backend.models import BackupPreflight, User, utcnow
|
||||
|
||||
staging_root = tmp_path / "staging"
|
||||
attachment_root = tmp_path / "attachments"
|
||||
quarantine = tmp_path / "quarantine"
|
||||
staging_root.mkdir()
|
||||
attachment_root.mkdir()
|
||||
quarantine.mkdir()
|
||||
staging = staging_root / "repair.zip"
|
||||
staging.write_bytes(b"staged")
|
||||
(quarantine / "restored.bin").write_bytes(b"original")
|
||||
get_settings().backup_staging_dir = str(staging_root)
|
||||
get_settings().attachment_dir = str(attachment_root)
|
||||
|
||||
async def exercise():
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
user_id = await db.scalar(select(User.id))
|
||||
row = BackupPreflight(
|
||||
token_hash="9" * 64,
|
||||
user_id=user_id,
|
||||
backup_id=UUID("99999999-9999-4999-8999-999999999999"),
|
||||
archive_sha256="0" * 64,
|
||||
archive_size=7,
|
||||
staging_path=str(staging),
|
||||
mode="replace",
|
||||
status="repair_pending",
|
||||
expires_at=utcnow() - timedelta(hours=1),
|
||||
cleanup_path=str(quarantine),
|
||||
)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
row_id = row.id
|
||||
await db.commit()
|
||||
await _prune(db)
|
||||
repaired = await db.get(BackupPreflight, row_id)
|
||||
return repaired.status, repaired.cleanup_path
|
||||
|
||||
status, cleanup_path = asyncio.run(exercise())
|
||||
assert (attachment_root / "restored.bin").read_bytes() == b"original"
|
||||
assert not quarantine.exists()
|
||||
assert not staging.exists()
|
||||
assert status == "failed"
|
||||
assert cleanup_path is None
|
||||
|
||||
|
||||
def test_repair_pending_counts_against_preflight_quota(client, tmp_path, monkeypatch):
|
||||
boot(client)
|
||||
import importlib
|
||||
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_engine
|
||||
from backend.models import BackupPreflight, User, utcnow
|
||||
|
||||
router_module = importlib.import_module("backend.backup.router")
|
||||
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||
staging_root = tmp_path / "staging"
|
||||
staging_root.mkdir()
|
||||
get_settings().backup_staging_dir = str(staging_root)
|
||||
staged = staging_root / "pending.zip"
|
||||
staged.write_bytes(b"pending")
|
||||
|
||||
async def seed():
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
user_id = await db.scalar(select(User.id))
|
||||
db.add(BackupPreflight(
|
||||
token_hash="8" * 64,
|
||||
user_id=user_id,
|
||||
backup_id=UUID("88888888-8888-4888-8888-888888888888"),
|
||||
archive_sha256="0" * 64,
|
||||
archive_size=7,
|
||||
staging_path=str(staged),
|
||||
mode="replace",
|
||||
status="repair_pending",
|
||||
expires_at=utcnow() + timedelta(hours=1),
|
||||
cleanup_path=str(tmp_path / "quarantine"),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(seed())
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
response = _preflight(client, content, "merge")
|
||||
assert response.status_code == 429
|
||||
assert response.json()["detail"]["code"] == "backup_preflight_quota"
|
||||
|
||||
|
||||
def test_repair_pending_exception_survives_router_and_same_token_only_repairs(
|
||||
client, tmp_path, monkeypatch
|
||||
):
|
||||
inbox = boot(client)
|
||||
import importlib
|
||||
|
||||
backup_router = importlib.import_module("backend.backup.router")
|
||||
from backend.backup import service
|
||||
from backend.config import get_settings
|
||||
from backend.db import get_engine
|
||||
from backend.models import Attachment, BackupPreflight
|
||||
|
||||
root = tmp_path / "attachments"
|
||||
get_settings().attachment_dir = str(root)
|
||||
task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json()
|
||||
uploaded = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("old.txt", b"old", "text/plain")},
|
||||
).json()
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||
|
||||
real_restore = service.restore_quarantine
|
||||
monkeypatch.setattr(service, "contained_file", lambda *_: (_ for _ in ()).throw(OSError("write")))
|
||||
monkeypatch.setattr(service, "restore_quarantine", lambda *_: (_ for _ in ()).throw(OSError("repair")))
|
||||
response = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"]["code"] == "backup_repair_pending"
|
||||
|
||||
async def state():
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.status == "repair_pending"))
|
||||
attachment = await db.get(Attachment, UUID(uploaded["id"]))
|
||||
return row.status, row.cleanup_path, root / attachment.storage_name
|
||||
|
||||
status, cleanup_path, old_path = asyncio.run(state())
|
||||
assert status == "repair_pending"
|
||||
assert cleanup_path
|
||||
assert not old_path.exists()
|
||||
|
||||
monkeypatch.setattr(service, "restore_quarantine", real_restore)
|
||||
monkeypatch.setattr(
|
||||
backup_router,
|
||||
"restore_v2",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("DB restore repeated")),
|
||||
)
|
||||
retried = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||
)
|
||||
assert retried.status_code == 409
|
||||
assert retried.json()["detail"]["code"] == "backup_restore_retry"
|
||||
assert old_path.read_bytes() == b"old"
|
||||
|
||||
async def repaired_state():
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
|
||||
return row.status, row.cleanup_path
|
||||
|
||||
assert asyncio.run(repaired_state()) == ("failed", None)
|
||||
|
||||
|
||||
def test_merge_preflight_rejects_archive_pin_when_user_has_different_active_pin(client):
|
||||
boot(client)
|
||||
existing = client.post(
|
||||
"/api/v1/countdowns",
|
||||
json={"title": "existing pin", "event_date": "2026-09-20", "pinned": True},
|
||||
).json()
|
||||
graph = _base_graph()
|
||||
graph["recurrences"] = []
|
||||
graph["countdowns"] = [{
|
||||
"id": "77777777-7777-4777-8777-777777777777",
|
||||
"title": "incoming pin",
|
||||
"event_date": "2026-09-21",
|
||||
"calendar_mode": "solar",
|
||||
"lunar_month": None,
|
||||
"lunar_day": None,
|
||||
"ignore_year": False,
|
||||
"kind": "countdown",
|
||||
"repeat_rule": "none",
|
||||
"icon": "x",
|
||||
"pinned": True,
|
||||
"archived_at": None,
|
||||
}]
|
||||
|
||||
response = _preflight(client, _make_archive_from_graph(graph), "merge")
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||
active = client.get("/api/v1/countdowns").json()
|
||||
assert [row["id"] for row in active if row["pinned"]] == [existing["id"]]
|
||||
|
||||
|
||||
def test_restore_target_ids_are_unique_across_entity_tables(client):
|
||||
boot(client)
|
||||
shared = "66666666-6666-4666-8666-666666666666"
|
||||
|
||||
async def seed_folder():
|
||||
from backend.db import get_engine
|
||||
from backend.models import Folder, User
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
user_id = await db.scalar(select(User.id))
|
||||
db.add(Folder(
|
||||
id=UUID(shared), user_id=user_id, name="existing entity id", position=0
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(seed_folder())
|
||||
current = client.get("/api/v1/backup/export.zip").content
|
||||
from tests.test_backup_v2 import _archive_rows, _replace_entities
|
||||
|
||||
lists = _archive_rows(current, "lists")
|
||||
lists.append({
|
||||
"id": shared,
|
||||
"name": "cross-table collision",
|
||||
"is_inbox": False,
|
||||
"position": 1,
|
||||
})
|
||||
second_content = _replace_entities(current, {"lists": lists})
|
||||
entries = __import__("tests.test_backup_v2", fromlist=["_zip_entries"])._zip_entries(second_content)
|
||||
entities = {
|
||||
name.removeprefix("data/").removesuffix(".json"): __import__("json").loads(value)
|
||||
for name, value in entries.items()
|
||||
if name.startswith("data/") and name.endswith(".json")
|
||||
}
|
||||
second = _preflight(
|
||||
client,
|
||||
_make_archive_from_graph(
|
||||
entities,
|
||||
{
|
||||
name: value
|
||||
for name, value in entries.items()
|
||||
if name.startswith("attachments/")
|
||||
},
|
||||
backup_id="55555555-5555-4555-8555-555555555555",
|
||||
),
|
||||
"merge",
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": second.json()["preflight_token"], "mode": "merge"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
|
||||
async def ids():
|
||||
from backend.db import get_engine
|
||||
from backend.models import Folder, TaskList
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
folder_id = await db.scalar(select(Folder.id).where(Folder.name == "existing entity id"))
|
||||
list_id = await db.scalar(select(TaskList.id).where(TaskList.name == "cross-table collision"))
|
||||
return folder_id, list_id
|
||||
|
||||
folder_id, list_id = asyncio.run(ids())
|
||||
assert folder_id == UUID(shared)
|
||||
assert list_id != folder_id
|
||||
@@ -0,0 +1,760 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.auth import COOKIE_NAME, hash_token
|
||||
from backend.models import (
|
||||
BackupImport,
|
||||
BackupPreflight,
|
||||
Habit,
|
||||
HabitLog,
|
||||
HabitPause,
|
||||
RecurrenceException,
|
||||
Session,
|
||||
Task,
|
||||
User,
|
||||
)
|
||||
from tests.test_mvp_backend import boot
|
||||
|
||||
|
||||
def _zip_entries(content: bytes) -> dict[str, bytes]:
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def _make_zip(entries: dict[str, bytes], *, backup_id: str = "11111111-1111-4111-8111-111111111111") -> bytes:
|
||||
entity_names = {
|
||||
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions", "habits",
|
||||
"habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
|
||||
}
|
||||
complete_entries = {f"data/{name}.json": b"[]" for name in entity_names}
|
||||
complete_entries["data/lists.json"] = json.dumps([{
|
||||
"id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"name": "Inbox", "is_inbox": True, "position": 0,
|
||||
}]).encode()
|
||||
complete_entries.update(entries)
|
||||
entries = complete_entries
|
||||
checksums = {name: hashlib.sha256(value).hexdigest() for name, value in entries.items()}
|
||||
manifest = {
|
||||
"format": "dodo-backup",
|
||||
"version": 2,
|
||||
"backup_id": backup_id,
|
||||
"entities": {
|
||||
name: len(json.loads(entries[f"data/{name}.json"])) for name in entity_names
|
||||
},
|
||||
"checksums": checksums,
|
||||
}
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
for name, value in entries.items():
|
||||
archive.writestr(name, value)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _preflight(client, content: bytes, mode: str = "merge"):
|
||||
return client.post(
|
||||
"/api/v1/backup/preflight",
|
||||
params={"mode": mode},
|
||||
files={"file": ("backup.zip", content, "application/zip")},
|
||||
)
|
||||
|
||||
|
||||
def _replace_entities(content: bytes, replacements: dict[str, list[dict]]) -> bytes:
|
||||
entries = _zip_entries(content)
|
||||
manifest = json.loads(entries.pop("manifest.json"))
|
||||
for entity, rows in replacements.items():
|
||||
name = f"data/{entity}.json"
|
||||
entries[name] = json.dumps(rows, allow_nan=True).encode()
|
||||
manifest["entities"][entity] = len(rows)
|
||||
manifest["checksums"][name] = hashlib.sha256(entries[name]).hexdigest()
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
for name, value in entries.items():
|
||||
archive.writestr(name, value)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _archive_rows(content: bytes, entity: str) -> list[dict]:
|
||||
return json.loads(_zip_entries(content)[f"data/{entity}.json"])
|
||||
|
||||
|
||||
def test_zip_v2_round_trip_includes_history_exception_and_attachment_bytes(client, tmp_path):
|
||||
inbox = boot(client)
|
||||
from backend.config import get_settings
|
||||
|
||||
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||
task = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "完整备份任务", "list_id": inbox["id"], "due_at": "2026-09-07T09:00:00Z"},
|
||||
).json()
|
||||
recurrence = client.post(
|
||||
"/api/v1/recurrences", json={"task_id": task["id"], "rrule": "FREQ=WEEKLY"}
|
||||
).json()
|
||||
habit = client.post(
|
||||
"/api/v1/habits", json={"name": "备份习惯", "kind": "numeric", "target": 2, "schedule_type": "daily"}
|
||||
).json()
|
||||
today = datetime.now(UTC).date().isoformat()
|
||||
assert client.put(f"/api/v1/habits/{habit['id']}/logs/{today}", json={"value": 2}).status_code == 200
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{habit['id']}/pauses",
|
||||
json={"start_date": "2026-09-18", "end_date": "2026-09-19"},
|
||||
).status_code == 201
|
||||
attachment_bytes = b"\x00real attachment bytes\xff"
|
||||
uploaded = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("proof.bin", attachment_bytes, "application/zip")},
|
||||
)
|
||||
assert uploaded.status_code == 201
|
||||
|
||||
async def seed_exception():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
db.add(RecurrenceException(
|
||||
template_id=UUID(recurrence["id"]), occurrence_at=datetime(2026, 9, 14, 9, tzinfo=UTC),
|
||||
title="例外标题", completed=True,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(seed_exception())
|
||||
exported = client.get("/api/v1/backup/export.zip")
|
||||
assert exported.status_code == 200
|
||||
assert exported.headers["content-type"] == "application/zip"
|
||||
assert "dodo-backup-v2.zip" in exported.headers["content-disposition"]
|
||||
entries = _zip_entries(exported.content)
|
||||
manifest = json.loads(entries["manifest.json"])
|
||||
assert manifest["version"] == 2
|
||||
assert set(manifest["entities"]) >= {
|
||||
"folders", "lists", "tasks", "recurrences", "recurrence_exceptions",
|
||||
"habits", "habit_logs", "habit_pauses", "countdowns", "memos", "attachments",
|
||||
}
|
||||
attachment_row = json.loads(entries["data/attachments.json"])[0]
|
||||
assert entries[attachment_row["archive_path"]] == attachment_bytes
|
||||
assert "password_hash" not in exported.content.decode("utf-8", errors="ignore")
|
||||
assert "token_hash" not in exported.content.decode("utf-8", errors="ignore")
|
||||
|
||||
merge_preflight = _preflight(client, exported.content)
|
||||
merged = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": merge_preflight.json()["preflight_token"], "mode": "merge"},
|
||||
)
|
||||
assert merged.status_code == 200, merged.text
|
||||
|
||||
preflight = _preflight(client, exported.content, "replace")
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.json()["valid"] is True
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
restored_task = client.get("/api/v1/tasks", params={"q": "完整备份任务"}).json()["items"][0]
|
||||
restored_attachment = client.get(f"/api/v1/tasks/{restored_task['id']}/attachments").json()[0]
|
||||
assert client.get(f"/api/v1/attachments/{restored_attachment['id']}").content == attachment_bytes
|
||||
|
||||
async def assert_history():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
assert await db.scalar(select(func.count()).select_from(HabitLog)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(HabitPause)) == 1
|
||||
assert await db.scalar(select(func.count()).select_from(RecurrenceException)) == 1
|
||||
|
||||
asyncio.run(assert_history())
|
||||
|
||||
|
||||
def test_merge_same_backup_is_idempotent_via_import_ledger(client):
|
||||
inbox = boot(client)
|
||||
client.post("/api/v1/tasks", json={"title": "只导入一次", "list_id": inbox["id"]})
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
first_token = _preflight(client, content).json()["preflight_token"]
|
||||
second_token = _preflight(client, content).json()["preflight_token"]
|
||||
assert client.post("/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}).status_code == 200
|
||||
second = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": second_token, "mode": "merge"},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["already_imported"] is True
|
||||
|
||||
async def counts():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
return (
|
||||
await db.scalar(select(func.count()).select_from(Task).where(Task.title == "只导入一次")),
|
||||
await db.scalar(select(func.count()).select_from(BackupImport)),
|
||||
)
|
||||
|
||||
assert asyncio.run(counts()) == (1, 1)
|
||||
|
||||
|
||||
def test_invalid_zip_variants_are_rejected_before_any_write(client):
|
||||
inbox = boot(client)
|
||||
before = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||
valid_data = json.dumps([]).encode()
|
||||
cases = []
|
||||
cases.append(_make_zip({"../escape": b"x", "data/tasks.json": valid_data}))
|
||||
|
||||
duplicate = io.BytesIO()
|
||||
with zipfile.ZipFile(duplicate, "w") as archive:
|
||||
archive.writestr("manifest.json", "{}")
|
||||
archive.writestr("data/tasks.json", "[]")
|
||||
archive.writestr("data/tasks.json", "[]")
|
||||
cases.append(duplicate.getvalue())
|
||||
|
||||
bad_checksum = _make_zip({"data/tasks.json": valid_data})
|
||||
entries = _zip_entries(bad_checksum)
|
||||
manifest = json.loads(entries["manifest.json"])
|
||||
manifest["checksums"]["data/tasks.json"] = "0" * 64
|
||||
cases.append(_make_zip({"data/tasks.json": valid_data}, backup_id=manifest["backup_id"]))
|
||||
# Replace the checksum after helper generation.
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("data/tasks.json", valid_data)
|
||||
cases[-1] = output.getvalue()
|
||||
|
||||
dangling = {"data/tasks.json": json.dumps([{
|
||||
"id": "22222222-2222-4222-8222-222222222222", "list_id": "missing", "title": "bad"
|
||||
}]).encode()}
|
||||
cases.append(_make_zip(dangling))
|
||||
|
||||
missing_attachment = {"data/attachments.json": json.dumps([{
|
||||
"id": "33333333-3333-4333-8333-333333333333",
|
||||
"task_id": "22222222-2222-4222-8222-222222222222",
|
||||
"filename": "x", "mime_type": "text/plain", "size": 1,
|
||||
"archive_path": "attachments/33333333-3333-4333-8333-333333333333/content",
|
||||
}]).encode()}
|
||||
cases.append(_make_zip(missing_attachment))
|
||||
|
||||
for content in cases:
|
||||
response = _preflight(client, content)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"].startswith("backup_")
|
||||
after = len(client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"])
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_legacy_json_and_csv_restore_remain_supported(client):
|
||||
inbox = boot(client)
|
||||
client.post("/api/v1/tasks", json={"title": "legacy", "list_id": inbox["id"]})
|
||||
exported_json = client.get("/api/v1/export")
|
||||
exported_csv = client.get("/api/v1/export.csv")
|
||||
assert client.post("/api/v1/restore?mode=merge", json=exported_json.json()).status_code == 200
|
||||
assert client.post(
|
||||
"/api/v1/restore.csv?mode=merge",
|
||||
files={"file": ("backup.csv", exported_csv.content, "text/csv")},
|
||||
).status_code == 200
|
||||
|
||||
|
||||
def test_backup_routes_require_auth_csrf_bind_tokens_and_consume_once(client):
|
||||
boot(client)
|
||||
exported = client.get("/api/v1/backup/export.zip")
|
||||
token = _preflight(client, exported.content).json()["preflight_token"]
|
||||
|
||||
anonymous = client.__class__(client.app)
|
||||
with anonymous:
|
||||
assert anonymous.get("/api/v1/backup/export.zip").status_code == 401
|
||||
assert _preflight(anonymous, exported.content).status_code == 401
|
||||
assert anonymous.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
).status_code == 401
|
||||
|
||||
csrf = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": token, "mode": "merge"},
|
||||
headers={"origin": "https://dodo.example", "x-csrf-token": "wrong"},
|
||||
)
|
||||
assert csrf.status_code == 403
|
||||
|
||||
async def add_other_user_session():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
other = User(username="other", password_hash="unused")
|
||||
db.add(other)
|
||||
await db.flush()
|
||||
session_token = "other-user-session-token"
|
||||
db.add(Session(
|
||||
token_hash=hash_token(session_token), user_id=other.id,
|
||||
expires_at=datetime(2099, 1, 1, tzinfo=UTC),
|
||||
))
|
||||
await db.commit()
|
||||
return session_token
|
||||
|
||||
other_session = asyncio.run(add_other_user_session())
|
||||
other = client.__class__(client.app)
|
||||
with other:
|
||||
other.cookies.set(COOKIE_NAME, other_session)
|
||||
wrong_user = other.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
)
|
||||
assert wrong_user.status_code == 409
|
||||
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
)
|
||||
assert restored.status_code == 200
|
||||
reused = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
)
|
||||
assert reused.status_code == 409
|
||||
|
||||
|
||||
def test_preflight_binds_mode_and_persists_only_staged_metadata(client):
|
||||
boot(client)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||
wrong_mode = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
)
|
||||
assert wrong_mode.status_code == 409
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||
)
|
||||
assert restored.status_code == 200
|
||||
|
||||
async def assert_persisted_consumption():
|
||||
from backend.db import get_engine
|
||||
from backend.models import BackupPreflight
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
row = await db.scalar(select(BackupPreflight).where(BackupPreflight.token_hash.is_not(None)))
|
||||
assert row is not None
|
||||
assert row.consumed_at is not None
|
||||
assert row.archive_sha256 == hashlib.sha256(content).hexdigest()
|
||||
|
||||
asyncio.run(assert_persisted_consumption())
|
||||
|
||||
|
||||
def test_preflight_rejects_upload_over_compressed_limit_without_unbounded_read(client, monkeypatch):
|
||||
boot(client)
|
||||
import importlib
|
||||
router_module = importlib.import_module("backend.backup.router")
|
||||
monkeypatch.setattr(router_module, "MAX_ARCHIVE_BYTES", 32)
|
||||
response = _preflight(client, b"x" * 33)
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"] == "backup_size_invalid"
|
||||
|
||||
|
||||
def test_quarantine_compensates_value_error_after_first_move(tmp_path, monkeypatch):
|
||||
from backend.backup import storage
|
||||
root = tmp_path / "attachments"
|
||||
root.mkdir()
|
||||
first = root / "first"
|
||||
first.write_bytes(b"first")
|
||||
original = storage.contained_file
|
||||
|
||||
def fail_second(storage_root, name):
|
||||
if name == "bad":
|
||||
raise ValueError("bad path")
|
||||
return original(storage_root, name)
|
||||
|
||||
monkeypatch.setattr(storage, "contained_file", fail_second)
|
||||
with pytest.raises(ValueError):
|
||||
storage.quarantine_files(root, ["first", "bad"], tmp_path / "quarantine")
|
||||
assert first.read_bytes() == b"first"
|
||||
|
||||
|
||||
def test_preflight_rejects_unique_and_invalid_task_graph_constraints(client):
|
||||
boot(client)
|
||||
list_id = "11111111-aaaa-4111-8111-111111111111"
|
||||
parent_id = "22222222-aaaa-4222-8222-222222222222"
|
||||
child_id = "33333333-aaaa-4333-8333-333333333333"
|
||||
base_list = {"id": list_id, "name": "Inbox", "is_inbox": True, "position": 0}
|
||||
cases = [
|
||||
{"data/lists.json": json.dumps([base_list, {**base_list, "id": "44444444-aaaa-4444-8444-444444444444"}]).encode()},
|
||||
{"data/lists.json": json.dumps([base_list]).encode(), "data/tasks.json": json.dumps([
|
||||
{"id": parent_id, "list_id": list_id, "parent_id": child_id, "title": "p", "external_id": "same"},
|
||||
{"id": child_id, "list_id": list_id, "parent_id": parent_id, "title": "c", "external_id": "same"},
|
||||
]).encode()},
|
||||
]
|
||||
for entries in cases:
|
||||
response = _preflight(client, _make_zip(entries))
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"]["code"] == "backup_constraint_invalid"
|
||||
|
||||
|
||||
def test_preflight_rejects_per_user_pending_quota(client, monkeypatch):
|
||||
boot(client)
|
||||
import importlib
|
||||
router_module = importlib.import_module("backend.backup.router")
|
||||
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
assert _preflight(client, content).status_code == 200
|
||||
response = _preflight(client, content)
|
||||
assert response.status_code == 429
|
||||
assert response.json()["detail"]["code"] == "backup_preflight_quota"
|
||||
|
||||
|
||||
def test_replace_cleanup_failure_is_retryable_and_not_reported_as_success(client, tmp_path, monkeypatch):
|
||||
boot(client)
|
||||
from backend.backup import service
|
||||
from backend.config import get_settings
|
||||
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||
real_remove = service.remove_quarantine
|
||||
monkeypatch.setattr(service, "remove_quarantine", lambda _: (_ for _ in ()).throw(OSError("busy")))
|
||||
response = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"]["code"] == "backup_cleanup_pending"
|
||||
monkeypatch.setattr(service, "remove_quarantine", real_remove)
|
||||
retried = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"})
|
||||
assert retried.status_code == 200
|
||||
assert retried.json()["cleanup_retried"] is True
|
||||
|
||||
|
||||
def test_preflight_rejects_malformed_rows_without_writing_or_leaking_details(client):
|
||||
boot(client)
|
||||
malformed = _make_zip({
|
||||
"data/folders.json": json.dumps([{
|
||||
"id": "44444444-4444-4444-8444-444444444444",
|
||||
"name": "bad position",
|
||||
"position": "not-an-integer",
|
||||
}]).encode(),
|
||||
})
|
||||
preflight = _preflight(client, malformed)
|
||||
assert preflight.status_code == 422
|
||||
assert preflight.json()["detail"]["code"] == "backup_entity_invalid"
|
||||
|
||||
|
||||
def test_preflight_rejects_invalid_habit_graph_without_any_write(client):
|
||||
boot(client)
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={"name": "基准", "kind": "numeric", "target": 2, "max_value": 4,
|
||||
"schedule_type": "weekly", "weekdays": [1, 3], "start_date": "2026-09-01"},
|
||||
).json()
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
base_habit = _archive_rows(content, "habits")[0]
|
||||
base_log = {
|
||||
"id": "91919191-9191-4191-8191-919191919191", "habit_id": habit["id"],
|
||||
"day": "2026-09-01", "value": 2, "updated_at": "2026-09-01T08:00:00+00:00",
|
||||
}
|
||||
base_pause = {
|
||||
"id": "92929292-9292-4292-8292-929292929292", "habit_id": habit["id"],
|
||||
"start_date": "2026-09-10", "end_date": "2026-09-12",
|
||||
}
|
||||
invalid_graphs = [
|
||||
({"habits": [{**base_habit, "kind": "counter"}]}, "kind enum"),
|
||||
({"habits": [{**base_habit, "kind": "boolean", "target": 2, "max_value": 1}]}, "boolean target"),
|
||||
({"habits": [{**base_habit, "target": float("inf")}]}, "finite target"),
|
||||
({"habits": [{**base_habit, "target": 5, "max_value": 4}]}, "numeric range"),
|
||||
({"habits": [{**base_habit, "schedule_type": "sometimes"}]}, "schedule enum"),
|
||||
({"habits": [{**base_habit, "weekdays": "99"}]}, "weekday range"),
|
||||
({"habits": [{**base_habit, "weekdays": "1,1"}]}, "weekday uniqueness"),
|
||||
({"habits": [{**base_habit, "weekdays": "1, 3"}]}, "weekday storage"),
|
||||
({"habits": [{**base_habit, "schedule_type": "monthly", "weekdays": None,
|
||||
"month_days": "0", "interval_days": None}]}, "month range"),
|
||||
({"habits": [{**base_habit, "schedule_type": "interval", "weekdays": None,
|
||||
"month_days": None, "interval_days": 0}]}, "interval range"),
|
||||
({"habits": [{**base_habit, "start_date": "2026-02-30"}]}, "start date"),
|
||||
({"habit_logs": [{**base_log, "value": float("inf")}]}, "finite log"),
|
||||
({"habits": [{**base_habit, "kind": "boolean", "target": 1, "max_value": 1}],
|
||||
"habit_logs": [{**base_log, "value": 2}]}, "boolean log"),
|
||||
({"habit_logs": [{**base_log, "value": 5}]}, "numeric log max"),
|
||||
({"habit_logs": [{**base_log, "value": -1}]}, "numeric log minimum"),
|
||||
({"habit_pauses": [{**base_pause, "start_date": "2026-09-13"}]}, "pause order"),
|
||||
({"habit_pauses": [base_pause, {
|
||||
**base_pause, "id": "93939393-9393-4393-8393-939393939393",
|
||||
"start_date": "2026-09-12", "end_date": "2026-09-14",
|
||||
}]}, "pause overlap"),
|
||||
]
|
||||
|
||||
async def counts():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
values = []
|
||||
for model in (Habit, HabitLog, HabitPause, BackupPreflight):
|
||||
values.append(await db.scalar(select(func.count()).select_from(model)))
|
||||
return tuple(values)
|
||||
|
||||
before = asyncio.run(counts())
|
||||
for replacements, label in invalid_graphs:
|
||||
response = _preflight(client, _replace_entities(content, replacements), "replace")
|
||||
assert response.status_code == 422, (label, response.text)
|
||||
assert response.json()["detail"]["code"] in {
|
||||
"backup_entity_invalid", "backup_habit_invalid",
|
||||
}, label
|
||||
assert asyncio.run(counts()) == before, label
|
||||
|
||||
|
||||
def test_four_habit_schedules_and_history_round_trip(client):
|
||||
boot(client)
|
||||
definitions = [
|
||||
{"name": "每天", "kind": "boolean", "schedule_type": "daily"},
|
||||
{"name": "每周", "kind": "numeric", "target": 2, "max_value": 4,
|
||||
"schedule_type": "weekly", "weekdays": [1, 3]},
|
||||
{"name": "每月", "kind": "numeric", "target": 3, "max_value": 5,
|
||||
"schedule_type": "monthly", "month_days": [1, 15, 31]},
|
||||
{"name": "间隔", "kind": "numeric", "target": 1.5, "max_value": 2.5,
|
||||
"schedule_type": "interval", "interval_days": 3},
|
||||
]
|
||||
habits = [client.post("/api/v1/habits", json={**item, "start_date": "2026-09-01"}).json()
|
||||
for item in definitions]
|
||||
|
||||
async def add_history():
|
||||
from backend.db import get_engine
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
for index, habit in enumerate(habits):
|
||||
db.add(HabitLog(
|
||||
habit_id=UUID(habit["id"]), day=date(2026, 9, index + 1),
|
||||
value=1 if habit["kind"] == "boolean" else habit["target"],
|
||||
))
|
||||
db.add_all([
|
||||
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 20),
|
||||
end_date=date(2026, 9, 21)),
|
||||
HabitPause(habit_id=UUID(habits[1]["id"]), start_date=date(2026, 9, 23),
|
||||
end_date=date(2026, 9, 24)),
|
||||
])
|
||||
row = await db.get(Habit, UUID(habits[3]["id"]))
|
||||
row.archived_at = datetime(2026, 9, 30, tzinfo=UTC)
|
||||
await db.commit()
|
||||
|
||||
asyncio.run(add_history())
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
expected_habits = _archive_rows(content, "habits")
|
||||
expected_logs = _archive_rows(content, "habit_logs")
|
||||
expected_pauses = _archive_rows(content, "habit_pauses")
|
||||
preflight = _preflight(client, content, "replace")
|
||||
assert preflight.status_code == 200, preflight.text
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": preflight.json()["preflight_token"], "mode": "replace"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
after = client.get("/api/v1/backup/export.zip").content
|
||||
|
||||
def stable(rows):
|
||||
return sorted(
|
||||
[{key: value for key, value in row.items() if key not in {"created_at", "updated_at"}}
|
||||
for row in rows], key=lambda row: row["id"]
|
||||
)
|
||||
|
||||
assert stable(_archive_rows(after, "habits")) == stable(expected_habits)
|
||||
assert stable(_archive_rows(after, "habit_logs")) == stable(expected_logs)
|
||||
assert stable(_archive_rows(after, "habit_pauses")) == stable(expected_pauses)
|
||||
|
||||
|
||||
def test_merge_rejects_backup_id_reuse_with_different_archive(client):
|
||||
boot(client)
|
||||
backup_id = "55555555-5555-4555-8555-555555555555"
|
||||
first = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
|
||||
first_token = _preflight(client, first).json()["preflight_token"]
|
||||
assert client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": first_token, "mode": "merge"}
|
||||
).status_code == 200
|
||||
|
||||
changed = _make_zip({"data/folders.json": json.dumps([{
|
||||
"id": "66666666-6666-4666-8666-666666666666", "name": "different", "position": 0,
|
||||
}]).encode()}, backup_id=backup_id)
|
||||
second_token = _preflight(client, changed).json()["preflight_token"]
|
||||
second = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": second_token, "mode": "merge"}
|
||||
)
|
||||
assert second.status_code == 409
|
||||
assert second.json()["detail"]["code"] == "backup_id_conflict"
|
||||
|
||||
|
||||
def test_replace_rebuilds_backup_identity_ledger_after_prior_merge(client):
|
||||
boot(client)
|
||||
backup_id = "77777777-7777-4777-8777-777777777777"
|
||||
original = _make_zip({"data/folders.json": b"[]"}, backup_id=backup_id)
|
||||
|
||||
merge_token = _preflight(client, original, "merge").json()["preflight_token"]
|
||||
assert client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": merge_token, "mode": "merge"}
|
||||
).status_code == 200
|
||||
replace_token = _preflight(client, original, "replace").json()["preflight_token"]
|
||||
assert client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": replace_token, "mode": "replace"}
|
||||
).status_code == 200
|
||||
|
||||
changed = _make_zip({"data/folders.json": json.dumps([{
|
||||
"id": "88888888-8888-4888-8888-888888888888", "name": "new entity", "position": 0,
|
||||
}]).encode()}, backup_id=backup_id)
|
||||
changed_token = _preflight(client, changed, "merge").json()["preflight_token"]
|
||||
conflict = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": changed_token, "mode": "merge"}
|
||||
)
|
||||
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["detail"]["code"] == "backup_id_conflict"
|
||||
|
||||
|
||||
def test_restore_cleans_parsed_staging_dir_when_preflight_identity_changed(client, monkeypatch):
|
||||
boot(client)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content, "merge").json()["preflight_token"]
|
||||
import importlib
|
||||
|
||||
router_module = importlib.import_module("backend.backup.router")
|
||||
real_parse = router_module.parse_archive_path
|
||||
parsed_dirs: list[Path] = []
|
||||
|
||||
def parse_with_changed_identity(path, **kwargs):
|
||||
archive = real_parse(path, **kwargs)
|
||||
parsed_dirs.append(archive.staging_dir)
|
||||
return replace(archive, archive_sha256="0" * 64)
|
||||
|
||||
monkeypatch.setattr(router_module, "parse_archive_path", parse_with_changed_identity)
|
||||
response = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"}
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"]["code"] == "backup_preflight_invalid"
|
||||
assert parsed_dirs
|
||||
assert not parsed_dirs[0].exists()
|
||||
|
||||
|
||||
def test_restore_handles_child_before_parent_task_order(client):
|
||||
inbox = boot(client)
|
||||
parent = client.post(
|
||||
"/api/v1/tasks", json={"title": "parent", "list_id": inbox["id"]}
|
||||
).json()
|
||||
client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "child", "list_id": inbox["id"], "parent_id": parent["id"]},
|
||||
)
|
||||
entries = _zip_entries(client.get("/api/v1/backup/export.zip").content)
|
||||
manifest = json.loads(entries.pop("manifest.json"))
|
||||
tasks = json.loads(entries["data/tasks.json"])
|
||||
entries["data/tasks.json"] = json.dumps(list(reversed(tasks))).encode()
|
||||
manifest["checksums"]["data/tasks.json"] = hashlib.sha256(entries["data/tasks.json"]).hexdigest()
|
||||
rebuilt = io.BytesIO()
|
||||
with zipfile.ZipFile(rebuilt, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
for name, value in entries.items():
|
||||
archive.writestr(name, value)
|
||||
|
||||
token = _preflight(client, rebuilt.getvalue(), "replace").json()["preflight_token"]
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
items = client.get("/api/v1/tasks", params={"q": "parent"}).json()["items"]
|
||||
assert items[0]["subtasks"][0]["title"] == "child"
|
||||
|
||||
|
||||
def test_replace_restores_quarantined_files_when_database_write_fails(client, tmp_path, monkeypatch):
|
||||
inbox = boot(client)
|
||||
from backend.backup import service
|
||||
from backend.config import get_settings
|
||||
|
||||
root = tmp_path / "attachments"
|
||||
get_settings().attachment_dir = str(root)
|
||||
old_bytes = b"keep me"
|
||||
task = client.post("/api/v1/tasks", json={"title": "old", "list_id": inbox["id"]}).json()
|
||||
uploaded = client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("old.txt", old_bytes, "text/plain")},
|
||||
).json()
|
||||
|
||||
async def old_storage_path():
|
||||
from backend.db import get_engine
|
||||
from backend.models import Attachment
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
row = await db.get(Attachment, UUID(uploaded["id"]))
|
||||
return root / row.storage_name
|
||||
|
||||
old_path = asyncio.run(old_storage_path())
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content, "replace").json()["preflight_token"]
|
||||
|
||||
def fail_new_attachment_write(storage_root: Path, storage_name: str):
|
||||
raise OSError("simulated write failure")
|
||||
|
||||
monkeypatch.setattr(service, "contained_file", fail_new_attachment_write)
|
||||
with pytest.raises(OSError, match="simulated write failure"):
|
||||
client.post(
|
||||
"/api/v1/backup/restore", json={"preflight_token": token, "mode": "replace"}
|
||||
)
|
||||
|
||||
assert old_path.read_bytes() == old_bytes
|
||||
assert client.get("/api/v1/tasks", params={"q": "old"}).json()["items"]
|
||||
|
||||
|
||||
def test_archive_blob_is_never_read_whole(client, tmp_path, monkeypatch):
|
||||
inbox = boot(client)
|
||||
from backend.config import get_settings
|
||||
|
||||
get_settings().attachment_dir = str(tmp_path / "attachments")
|
||||
task = client.post("/api/v1/tasks", json={"title": "stream", "list_id": inbox["id"]}).json()
|
||||
blob = bytes(range(256)) * 64
|
||||
assert client.post(
|
||||
f"/api/v1/tasks/{task['id']}/attachments",
|
||||
files={"file": ("blob.bin", blob, "application/zip")},
|
||||
).status_code == 201
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
real_read = zipfile.ZipFile.read
|
||||
|
||||
def reject_blob_read(self, name, *args, **kwargs):
|
||||
filename = name.filename if isinstance(name, zipfile.ZipInfo) else name
|
||||
if str(filename).startswith("attachments/"):
|
||||
raise AssertionError("blob entry was loaded with ZipFile.read")
|
||||
return real_read(self, name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "read", reject_blob_read)
|
||||
preflight = _preflight(client, content)
|
||||
assert preflight.status_code == 200, preflight.text
|
||||
restored = client.post(
|
||||
"/api/v1/backup/restore",
|
||||
json={"preflight_token": preflight.json()["preflight_token"], "mode": "merge"},
|
||||
)
|
||||
assert restored.status_code == 200, restored.text
|
||||
|
||||
|
||||
def test_pending_quota_reservation_is_atomic_across_workers(client, monkeypatch):
|
||||
boot(client)
|
||||
import importlib
|
||||
|
||||
router_module = importlib.import_module("backend.backup.router")
|
||||
monkeypatch.setattr(router_module, "MAX_PENDING_PREFLIGHTS_PER_USER", 1)
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
|
||||
def upload(_):
|
||||
with client.__class__(client.app) as worker:
|
||||
worker.cookies.update(client.cookies)
|
||||
return _preflight(worker, content).status_code
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
statuses = list(executor.map(upload, range(2)))
|
||||
assert sorted(statuses) == [200, 429]
|
||||
|
||||
|
||||
def test_entity_ledger_survives_partial_retry_and_normalizes_relationships(client):
|
||||
inbox = boot(client)
|
||||
task = client.post("/api/v1/tasks", json={"title": "ledger-parent", "list_id": inbox["id"]}).json()
|
||||
child = client.post(
|
||||
"/api/v1/tasks",
|
||||
json={"title": "ledger-child", "list_id": inbox["id"], "parent_id": task["id"]},
|
||||
).json()
|
||||
content = client.get("/api/v1/backup/export.zip").content
|
||||
token = _preflight(client, content).json()["preflight_token"]
|
||||
restored = client.post("/api/v1/backup/restore", json={"preflight_token": token, "mode": "merge"})
|
||||
assert restored.status_code == 200, restored.text
|
||||
|
||||
async def verify_ledger():
|
||||
from backend.db import get_engine
|
||||
from backend.models import BackupImportEntity
|
||||
async with AsyncSession(get_engine()) as db:
|
||||
rows = list((await db.scalars(select(BackupImportEntity).where(
|
||||
BackupImportEntity.entity_type == "tasks"
|
||||
))).all())
|
||||
by_source = {str(row.source_id): row for row in rows}
|
||||
assert by_source[task["id"]].target_id
|
||||
assert by_source[child["id"]].target_id
|
||||
assert len(by_source[child["id"]].content_digest) == 64
|
||||
|
||||
asyncio.run(verify_ledger())
|
||||
@@ -204,7 +204,7 @@ def test_countdowns_backup_replace_and_merge_round_trip(client):
|
||||
assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
|
||||
|
||||
create_countdown(client, title="干扰数据")
|
||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
||||
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
|
||||
assert restored.status_code == 200
|
||||
restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年")
|
||||
assert restored_active["calendar_mode"] == "lunar"
|
||||
@@ -261,7 +261,7 @@ def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client):
|
||||
assert len(client.get("/api/v1/countdowns").json()) == 1
|
||||
|
||||
exported_by_other = client.get("/api/v1/export").json()
|
||||
restored_again = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported_by_other)
|
||||
restored_again = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported_by_other)
|
||||
assert restored_again.status_code == 200
|
||||
replaced = client.get("/api/v1/countdowns").json()
|
||||
assert len(replaced) == 1
|
||||
@@ -335,20 +335,20 @@ def test_restore_rejects_malformed_countdowns_atomically(client):
|
||||
|
||||
malformed = deepcopy(exported)
|
||||
malformed["countdowns"][0]["archived_at"] = "not-a-date"
|
||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
||||
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed)
|
||||
assert response.status_code == 422
|
||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||
|
||||
for invalid in (None, 7):
|
||||
malformed = deepcopy(exported)
|
||||
malformed["countdowns"] = invalid
|
||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
||||
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=malformed)
|
||||
assert response.status_code == 422
|
||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||
|
||||
duplicate = deepcopy(exported)
|
||||
duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0]))
|
||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=duplicate)
|
||||
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=duplicate)
|
||||
assert response.status_code == 422
|
||||
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||
|
||||
@@ -359,7 +359,7 @@ def test_restore_rejects_malformed_countdowns_atomically(client):
|
||||
two_pinned["countdowns"][0]["pinned"] = True
|
||||
extra["pinned"] = True
|
||||
two_pinned["countdowns"].append(extra)
|
||||
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=two_pinned)
|
||||
response = client.post("/api/v1/restore", params={"mode": "merge"}, json=two_pinned)
|
||||
assert response.status_code == 200
|
||||
assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1
|
||||
|
||||
@@ -407,7 +407,7 @@ def test_restore_accepts_lunar_dates_whose_solar_anchor_is_in_next_year(client):
|
||||
assert created.json()["event_date"] == "2002-02-01"
|
||||
exported = client.get("/api/v1/export").json()
|
||||
|
||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
||||
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=exported)
|
||||
assert restored.status_code == 200
|
||||
item = client.get("/api/v1/countdowns").json()[0]
|
||||
assert item["event_date"] == "2002-02-01"
|
||||
|
||||
+2
-2
@@ -218,7 +218,7 @@ def test_json_and_csv_backup_round_trip_memos_with_all_fields(client):
|
||||
csv_backup = client.get("/api/v1/export.csv")
|
||||
assert b"memos" in csv_backup.content
|
||||
restored_csv = client.post(
|
||||
"/api/v1/restore.csv?mode=replace",
|
||||
"/api/v1/restore.csv?mode=merge",
|
||||
files={"file": ("dodo-export.csv", csv_backup.content, "text/csv")},
|
||||
)
|
||||
assert restored_csv.status_code == 200
|
||||
@@ -278,4 +278,4 @@ def test_legacy_backup_without_memos_still_restores(client):
|
||||
boot(client)
|
||||
backup = client.get("/api/v1/export").json()
|
||||
backup.pop("memos", None)
|
||||
assert client.post("/api/v1/restore?mode=replace", json=backup).status_code == 200
|
||||
assert client.post("/api/v1/restore?mode=merge", json=backup).status_code == 200
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
|
||||
return subprocess.run(
|
||||
["uv", "run", "alembic", *args], cwd=repo, env=env,
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_backup_migration_upgrade_downgrade_and_reupgrade(tmp_path):
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
database = tmp_path / "migration.sqlite3"
|
||||
assert run_alembic(repo, database, "upgrade", "0018_task_completed_at").returncode == 0
|
||||
upgraded = run_alembic(repo, database, "upgrade", "0019_backup_imports")
|
||||
assert upgraded.returncode == 0, upgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||
assert {"backup_imports", "backup_import_entities", "backup_preflights"} <= tables
|
||||
downgraded = run_alembic(repo, database, "downgrade", "0018_task_completed_at")
|
||||
assert downgraded.returncode == 0, downgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||
assert "backup_imports" not in tables
|
||||
assert "backup_import_entities" not in tables
|
||||
assert "backup_preflights" not in tables
|
||||
reupgraded = run_alembic(repo, database, "upgrade", "head")
|
||||
assert reupgraded.returncode == 0, reupgraded.stderr
|
||||
|
||||
|
||||
def test_fresh_upgrade_has_single_head_and_backup_tables(tmp_path):
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
heads = run_alembic(repo, tmp_path / "unused.sqlite3", "heads")
|
||||
assert heads.returncode == 0, heads.stderr
|
||||
assert heads.stdout.count("(head)") == 1
|
||||
database = tmp_path / "fresh.sqlite3"
|
||||
upgraded = run_alembic(repo, database, "upgrade", "head")
|
||||
assert upgraded.returncode == 0, upgraded.stderr
|
||||
with sqlite3.connect(database) as connection:
|
||||
tables = {row[0] for row in connection.execute("select name from sqlite_master where type='table'")}
|
||||
assert {"backup_imports", "backup_import_entities", "backup_preflights"} <= tables
|
||||
@@ -121,7 +121,7 @@ def test_export_and_restore_preserve_task_recurrence(client):
|
||||
exported = client.get("/api/v1/export").json()
|
||||
assert exported["recurrences"][0]["task_id"] == task["id"]
|
||||
|
||||
restored = client.post("/api/v1/restore?mode=replace", json=exported)
|
||||
restored = client.post("/api/v1/restore?mode=merge", json=exported)
|
||||
assert restored.status_code == 200
|
||||
restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0]
|
||||
recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json()
|
||||
@@ -599,9 +599,11 @@ def test_ticktick_preview_import_dedupe_and_json_restore(client):
|
||||
export = client.get("/api/v1/export").json()
|
||||
assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1"
|
||||
client.delete(f"/api/v1/tasks/{export['tasks'][0]['id']}")
|
||||
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=export)
|
||||
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=export)
|
||||
assert restored.status_code == 200
|
||||
assert len(client.get("/api/v1/tasks").json()["items"]) == 1
|
||||
# Legacy merge is non-destructive and does not resurrect a soft-deleted
|
||||
# task whose external ID already exists.
|
||||
assert len(client.get("/api/v1/tasks").json()["items"]) == 0
|
||||
assert client.post("/api/v1/restore", json={"version": 999}).status_code == 422
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user