feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+12
View File
@@ -2,3 +2,15 @@ DODO_DATABASE_URL=postgresql+asyncpg://postgres:[email protected]:5432/
DODO_COOKIE_SECURE=true DODO_COOKIE_SECURE=true
DODO_SESSION_DAYS=30 DODO_SESSION_DAYS=30
DODO_TRUSTED_PROXIES=127.0.0.1 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
+1
View File
@@ -7,6 +7,7 @@ dist/
coverage/ coverage/
playwright-report/ playwright-report/
test-results/ test-results/
frontend/playwright-runtime/
.DS_Store .DS_Store
*.db *.db
uploads/ uploads/
+28 -22
View File
@@ -1,24 +1,32 @@
# dodo # dodo
一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用 一个自托管、移动端友好的任务与生活管理 PWA,数据由自己掌控
## 第一阶段能力 ## 当前主要能力
- 首次初始化管理员 - 文件夹、收集箱与自定义清单;清单归档后保留任务归属,恢复后原样可见
- 用户名密码登录,Cookie Session - 任务与一层子任务、优先级、Markdown 备注、日期/时间、回收站、拖拽排序
- 文件夹、清单、任务基础 CRUD - RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖
- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年和自定义重复(间隔、星期、月日期、次数或截止日期) - 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息
- 收集箱系统清单 - 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档
- 习惯打卡与倒数纪念日 - 倒数日、纪念日、生日及公历/农历重复
- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重 - Markdown 备忘录及软删除/恢
- Vue 3 + PWA 应用外壳 - 任务附件、登录设备管理与审计日志
- 手账生活感浅色 UI - 完整 ZIP v2 备份(含附件字节、manifest 与 SHA-256)及预检后合并/替换恢复
- 兼容旧版 CSV / JSON v1 恢复
## 界面约定
- 弹层统一使用 `AppSheet` / `AppDialog`,共享遮罩、焦点陷阱、Escape、背景 inert 和嵌套栈行为
- 设置页按“数据、账户与安全、登录设备、活动、危险操作”连续分组
- 手机底栏直接进入今天、习惯、倒数日、设置,不再使用“更多”中转
- 新增入口是普通的圆形 Plus FAB,共用于任务、习惯、倒数日和备忘录
## 技术栈 ## 技术栈
- Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS - Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS
- Backend: FastAPI + SQLAlchemy 2 Async - Backend: FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic
- DB: PostgreSQL(测试环境使用 SQLite - DB: PostgreSQL(测试使用 SQLite
- Package: uv + pnpm - Package: uv + pnpm
## 本地开发 ## 本地开发
@@ -33,18 +41,16 @@ pnpm install
pnpm run dev pnpm run dev
``` ```
## 环境变量 配置项见 [`.env.example`](.env.example)API 与数据合同见 [`docs/api.md`](docs/api.md) 和 [`docs/data-model.md`](docs/data-model.md)。
```bash
DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo
DODO_COOKIE_SECURE=false
DODO_SESSION_DAYS=30
```
## 验证 ## 验证
```bash ```bash
uv run pytest -q
uv run ruff check backend tests 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
``` ```
+3
View File
@@ -0,0 +1,3 @@
from .router import router
__all__ = ["router"]
+195
View File
@@ -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))
+5
View File
@@ -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.
"""
+274
View File
@@ -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
+6
View File
@@ -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)$")
+771
View File
@@ -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}
+52
View File
@@ -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)
+5
View File
@@ -12,6 +12,11 @@ class Settings(BaseSettings):
auto_create_schema: bool = False auto_create_schema: bool = False
attachment_dir: str = "./data/attachments" attachment_dir: str = "./data/attachments"
attachment_max_mb: int = 20 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_attempts: int = 5
login_window_seconds: int = 300 login_window_seconds: int = 300
+2
View File
@@ -29,6 +29,7 @@ from .auth import (
session_token, session_token,
verify_password, verify_password,
) )
from .backup import router as backup_router
from .db import create_schema, get_db from .db import create_schema, get_db
from .models import ( from .models import (
AppState, AppState,
@@ -118,6 +119,7 @@ async def openapi(_: User = Depends(current_user)):
app.include_router(mvp_router) app.include_router(mvp_router)
app.include_router(backup_router)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
+46
View File
@@ -255,6 +255,52 @@ class Memo(Base):
deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) 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): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+65 -12
View File
@@ -11,7 +11,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator 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 sqlalchemy.ext.asyncio import AsyncSession
from .auth import current_user from .auth import current_user
@@ -44,6 +44,42 @@ from .models import (
router = APIRouter(prefix="/api/v1") 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): 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)) 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") @model_validator(mode="after")
def validate_mode(self): def validate_mode(self):
if self.trigger_mode == "scheduled" and self.rrule is None: if self.trigger_mode == "scheduled" and (
raise ValueError("scheduled recurrence requires rrule") 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 ( if self.trigger_mode == "after_completion" and (
self.after_completion_days is None or self.rrule is not None 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"} _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) @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)): 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) await owned_task(db, user.id, task_id)
@@ -1349,7 +1397,11 @@ async def restore_csv(
user: User = Depends(current_user), user: User = Depends(current_user),
db: AsyncSession = Depends(get_db), 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 = { payload = {
"version": 1, "version": 1,
"folders": [], "folders": [],
@@ -1363,9 +1415,14 @@ async def restore_csv(
try: try:
for row in csv.DictReader(io.StringIO(text)): for row in csv.DictReader(io.StringIO(text)):
entity = row.get("entity", "") entity = row.get("entity", "")
data = row.get("data")
if entity not in payload or entity == "version": if entity not in payload or entity == "version":
raise ValueError("unknown entity") 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: except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise HTTPException(422, "无效的 Dodo CSV 备份") from exc raise HTTPException(422, "无效的 Dodo CSV 备份") from exc
return await restore_json(payload, mode, user, db) return await restore_json(payload, mode, user, db)
@@ -1436,16 +1493,12 @@ def _validate_countdown_backups(payload: dict) -> list[dict]:
@router.post("/restore") @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)): 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: if payload.get("version") != 1:
raise HTTPException(422, "不支持的备份版本") raise HTTPException(422, "不支持的备份版本")
_validate_legacy_payload_limits(payload)
parsed_countdowns = _validate_countdown_backups(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 = {} id_map = {}
task_id_map = {} task_id_map = {}
for raw in payload.get("folders", []): for raw in payload.get("folders", []):
+55 -39
View File
@@ -1,59 +1,75 @@
# API # 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` - `GET /setup/status``POST /setup/initialize`
- `POST /setup/initialize` - `POST /auth/login``POST /auth/logout``POST /auth/change-password`
- `POST /auth/login` - `GET/PATCH /me`
- `POST /auth/logout` - `GET /sessions``DELETE /sessions/{session_id}``DELETE /sessions/others`
- `GET /me` - `GET /audit-logs`
## Folders ## 文件夹、清单与任务
- `GET /folders` — 仅返回未删除文件夹 - 文件夹:`GET/POST /folders``PATCH/DELETE /folders/{id}`,以及排序接口
- `POST /folders` — 创建文件夹 - 清单:`GET/POST /lists``PATCH/DELETE /lists/{id}`,归档恢复、永久删除、移动与排序接口
- `PATCH /folders/{folder_id}` — 重命名 - 归档清单只标记 `deleted_at`,不会把任务移到收集箱;清单归档期间,其任务在普通列表和详情中不可见,恢复清单后重新出现
- `DELETE /folders/{folder_id}` — 软删除;其清单移到根层级 - 任务:`GET/POST /tasks``GET/PATCH/DELETE /tasks/{id}``POST /tasks/{id}/restore``DELETE /trash/{id}`
- `GET /tasks``GET /trash` 使用不透明游标;`limit` 为 1100
- `POST /tasks/batch` 支持批量完成、移动、截止时间与软删除;任务写入使用 `version` 乐观锁
- 一层子任务必须与父任务同清单;任务可带日期型或具体时间型截止时间
## Lists ## 重复任务
- `GET /lists` — 仅返回未删除清单,系统收集箱排首位 - `GET /tasks/{task_id}/recurrence`
- `POST /lists` — 创建清单,可指定 `folder_id` - `POST /recurrences``PATCH/DELETE /recurrences/{id}`
- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409 - `POST /recurrences/{id}/complete`
- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409 - 支持 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` — 查询倒数日;置顶项优先,其余按下一次发生日期排序 ## 完整备份 ZIP v2
- `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` — 永久删除已归档项
## Recycle bin ### `GET /backup/export.zip`
- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页 导出 `dodo-backup` version 2 ZIP。归档包含 `manifest.json`、每类实体的 `data/*.json`、附件元数据和附件原始字节。manifest 声明实体数量及每个条目的 SHA-256。
- `DELETE /trash/{task_id}` — 永久删除任务及其子任务
游标是不透明字符串。无效游标返回 422;`limit` 范围为 1100 实体范围:`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/live`
- `GET /health/ready` - `GET /health/ready`
+29 -80
View File
@@ -1,97 +1,46 @@
# dodo 数据模型 # dodo 数据模型
所有业务实体使用 UUIDv7 主键并按 `user_id` 隔离。生产使用 PostgreSQL,测试使用 SQLite;模型保持两者兼容 业务主键使用 UUIDv7;带 `user_id` 的实体按用户隔离。时间点按 UTC 保存,用户时区用于日期语义与展示。生产使用 PostgreSQL,测试使用 SQLite。
## app_state ## 账户
- `key` 主键 - `app_state`:初始化状态
- `created_at` - `users`:用户名、密码散列、时区
- `sessions`:会话令牌散列、过期/最近访问时间、IP 与 User-Agent
- `audit_logs`:用户、动作、实体、非敏感摘要和时间
## users ## 任务域
- `id` - `folders`:名称、位置、软删除时间;删除文件夹只解除清单分组
- `username` 唯一 - `task_lists`:文件夹、名称、收集箱标记、位置、软删除时间
- `password_hash` - `tasks`:清单、可空父任务、标题、Markdown 描述、优先级、完成/完成时间、截止时间、`due_has_time`、位置、版本、软删除与外部 ID
- `timezone` - `recurrence_templates`:任务的一对一重复规则、开始/结束、计划或完成后触发模式、完成后间隔与最近完成时间
- `created_at` - `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` ## 完整备份 v2 内部状态
- `user_id` → users
- `name`
- `position`
- `created_at`
- `deleted_at`,非空表示软删除
删除文件夹不会删除清单;清单的 `folder_id` 被置空。 - `backup_preflights`:令牌散列、用户、备份 ID/摘要/大小、暂存路径、模式、状态、过期/消费时间和待清理路径
- `backup_imports`:每用户已导入备份 ID、归档摘要、模式和时间;用于幂等判断
- `backup_import_entities`:源实体 ID 到目标 ID 的映射及内容摘要;用于 merge 冲突检测与可重试导入
## task_lists 这些表和账户/会话/审计信息不属于用户可迁移业务实体。ZIP v2 只导出:
- `id` `folders``lists``tasks``recurrences``recurrence_exceptions``habits``habit_logs``habit_pauses``countdowns``memos``attachments`
- `user_id` → users
- `folder_id` → folders,可空
- `name`
- `is_inbox`,每个用户初始化时创建一个受保护的系统收集箱
- `position`
- `created_at`
- `deleted_at`,非空表示软删除
删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除 附件导出时去掉内部 `storage_name`,改用归档内安全路径并携带真实字节;恢复时生成目标存储名。每个实体文件和附件字节均由 manifest SHA-256 覆盖
## tasks
- `id`
- `user_id` → users
- `list_id` → task_lists
- `parent_id` → tasks,可空;仅允许一层子任务且必须与父任务同清单
- `title`
- `description`
- `priority`03
- `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`
## 迁移 ## 迁移
- `0001_initial.py`:已部署的初始模式,不修改 迁移按 `0001``0019` 顺序应用;当前最新 `0019_backup_imports.py` 增加完整备份预检、导入及实体映射账本。历史迁移还覆盖任务管理、会话元数据、查询索引、习惯排序、倒数日/农历、日期型截止语义、重复触发模式、备忘录与 `completed_at` 等演进。
- `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
+31 -102
View File
@@ -1,117 +1,46 @@
# dodo 产品与技术决策记录 # dodo 产品与技术决策记录
## 定位 ## 定位与当前范围
dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标不是一比一复刻 TickTick,而是做一个数据归自己、界面温暖紧凑、适合个人长期使用的任务系统 dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道
## 当前边界 ## 技术与数据
- 首版只做本地开发验证,不部署。 - MonorepoVue 3 + TypeScript + Vite 前端,FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic 后端
- 首版不做通知渠道:Web Push、Telegram、SMTP 暂不实现。 - PostgreSQL 生产、SQLite 测试;UUIDv7 主键,时间点使用 UTC,日历语义使用用户时区
- 首版附件只做本地存储,不实现 S3。 - 单 Docker 镜像,外部 PostgreSQLAGPL-3.0
- 首版不做番茄钟。 - 用户业务读写必须按归属过滤;更新任务/备忘录使用乐观锁
- 首版不做自然语言创建任务。
## 技术栈
- 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 表已清理。
## 产品模型 ## 产品模型
### 任务 - 文件夹 → 清单 → 一层任务树;系统收集箱受保护
- 清单删除定义为归档:保留任务成员关系,归档期间隐藏,恢复后原样出现;永久删除仅允许作用于已归档清单
- 截止日期区分全天日期和具体时间;重复任务支持 RFC 5545 计划重复及“完成后重复”
- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档
- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档
- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除
- 文件夹 → 清单 → 任务 ## UI 决策
- 系统内置收集箱,不允许删除
- 任务支持一层子任务
- 状态:未完成 / 已完成
- 优先级:无 / 低 / 中 / 高
- Markdown 描述
- 截止日期 + 具体时间
- 多提醒设计预留
- 删除为软删除,回收站手动清空
- 支持创建、修改、完成、恢复、删除操作历史
- 并发编辑使用原子乐观锁
### 重复任务 - 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航
- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转
- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物
- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作
- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复
- 桌面任务/备忘录详情可保持非模态,移动端由同一组件切为底部模态弹层
- RFC 5545 RRULE ## 备份决策
- 模板 + 实例
- 修改范围:仅本次 / 本次及以后 / 全部
- 删除单次保存为例外
- 每月 31 日在无 31 日月份跳过
- 逾期完成不影响下次计划日期
### 习惯 - “完整备份”专指 `dodo-backup` ZIP version 2,而不是旧 JSON/CSV
- v2 覆盖全部用户业务实体、历史/例外、附件元数据与附件字节;manifest 记录实体数量和每个条目的 SHA-256
- 完成型 + 数值型 - 恢复必须先预检,再用绑定用户、文件摘要和模式的短期单次令牌执行
- 每天 / 每周 / 每月 / 间隔天数 - 预检拒绝未知/缺失实体、不安全 ZIP 路径、重复条目、异常压缩比/容量、校验和错误、非法字段、破坏关系拓扑或一层任务树的数据
- 数值型当日累计,达标后封顶 - `merge` 通过持久化 ID/摘要账本保证幂等与冲突可见;`replace` 仅替换当前用户业务数据
- 允许补打和修改历史 - 附件恢复采用同文件系统暂存/隔离与补偿;数据库提交后的清理失败可用同一令牌重试清理,不会再次导入
- 支持暂停区间,暂停期不破坏连续记录 - 保留 JSON/CSV v1 恢复兼容,但明确其不包含日志、暂停、重复例外和附件字节,仅用于旧数据迁移
- 归档后保留历史统计
### 倒数纪念日
- 支持倒数日、纪念日、生日
- 支持不重复、每周、每月、每年重复
- 未来显示“还有 N 天”,当天显示“就是今天”,过去显示“已经 N 天”
- 支持单一置顶、归档恢复、编辑和删除
## UI 方向
- 手账生活感
- 中高信息密度
- 1012px 中等圆角
- 细分割线为主,少量浅底色
- 强调色:`#F15A29`
- 只做浅色模式
- 系统字体栈
- 不使用猫猫元素
- 轻微动效
## 页面结构
- 桌面三栏:左导航 / 中任务列表 / 右任务详情
- 手机底部导航
- 顶部快速输入,手机悬浮新增按钮
- 桌面右侧详情栏,手机底部弹层
- 习惯首页:今日习惯列表 + 一周打卡格
- 搜索:顶部搜索框 + 全局搜索快捷键
## 工程质量 ## 工程质量
- `/api/v1` API 路径 - API 基路径 `/api/v1`Cookie Session、同源 CSRF、安全响应头、登录限流
- 统一错误码、可读提示和字段详情 - 附件与备份均有容量限制、路径包含检查和用户归属校验
- 页面内诊断信息 + Toast - 后端使用 pytest + ruff,前端使用 Vitest + vue-tsc/Vite;变更结束运行全量测试、构建和 `git diff --check`
- readiness 检查数据库
- 首版不提供 Prometheus metrics
- 审计日志记录操作人、实体、动作、时间和变更摘要
- 手动 JSON 全量导出
- 附件默认 20MB,可用环境变量调整
- 默认允许常用文档与图片,拒绝危险文件类型
## 第一阶段验收
- 可初始化管理员
- 可登录
- 可创建清单和任务
- 后端测试通过
- 前端生产构建通过
- PostgreSQL 迁移成功
- 本地应用可以启动并访问
+115
View File
@@ -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)
})
+29
View File
@@ -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 }
+37
View File
@@ -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()
}
}
+112
View File
@@ -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)
})
+118
View File
@@ -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
View File
@@ -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"]}}
+41
View File
@@ -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',
},
})
+36
View File
@@ -39,6 +39,9 @@ importers:
specifier: latest specifier: latest
version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected])) version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.63.0
version: 1.63.0
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: latest specifier: latest
version: 4.3.3([email protected](@types/[email protected])([email protected])) version: 4.3.3([email protected](@types/[email protected])([email protected]))
@@ -48,6 +51,9 @@ importers:
'@types/node': '@types/node':
specifier: latest specifier: latest
version: 26.4.1 version: 26.4.1
fflate:
specifier: ^0.8.3
version: 0.8.3
jsdom: jsdom:
specifier: ^30.0.1 specifier: ^30.0.1
version: 30.0.1 version: 30.0.1
@@ -180,6 +186,11 @@ packages:
'@oxc-project/[email protected]': '@oxc-project/[email protected]':
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} 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]': '@rolldown/[email protected]':
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -614,6 +625,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -878,6 +892,16 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==} 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]: [email protected]:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1303,6 +1327,10 @@ snapshots:
'@oxc-project/[email protected]': {} '@oxc-project/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.63.0
'@rolldown/[email protected]': '@rolldown/[email protected]':
optional: true optional: true
@@ -1674,6 +1702,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
[email protected]: {}
[email protected]: [email protected]:
optional: true optional: true
@@ -1901,6 +1931,12 @@ snapshots:
exsolve: 1.1.1 exsolve: 1.1.1
pathe: 2.0.3 pathe: 2.0.3
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.63.0
[email protected]: [email protected]:
dependencies: dependencies:
nanoid: 3.3.18 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}`)))
})))
+15
View File
@@ -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)
}
+31 -75
View File
@@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search' import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
import { nextDialogFocusIndex } from './lib/list-purge'
import { deriveMemoShellState } from './lib/app-shell-state' import { deriveMemoShellState } from './lib/app-shell-state'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
@@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue' import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue' import TaskDueDisplay from './components/TaskDueDisplay.vue'
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.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 { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse' 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 purgeListTarget = ref<TaskList | null>(null)
const purgeListSubmitting = ref(false) const purgeListSubmitting = ref(false)
const purgeListError = ref('') const purgeListError = ref('')
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
const purgeListDialog = ref<HTMLElement | null>(null)
let purgeListTrigger: HTMLElement | null = null let purgeListTrigger: HTMLElement | null = null
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([]) const overdueTasks = ref<Task[]>([])
@@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock()
const mobileSidebar = ref(false) const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const mobileDetail = ref(false) const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false) const moreSettingsOpen = ref(false)
const markdownPreview = ref(false) const markdownPreview = ref(false)
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null) const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
@@ -296,39 +294,16 @@ function toggleSidebar() {
} }
} }
const modalVisible = ref(false) const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
const modalTitle = ref('') async function confirmAction(title: string, description?: string, danger = false) {
const modalLabel = ref('') return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
const modalValue = ref('') }
const modalError = ref('') async function askText(title: string, label = '', initial = '', confirmText = '确定') {
const modalConfirmText = ref('确定') const result = await appDialog.value?.show({
const modalResolve = ref<((value: string | null) => void) | null>(null) title, label, initial, confirmText,
function askText(title: string, label = '', initial = '', confirmText = '确定') { validate: label ? (value) => normalizeRequiredName(value).error : undefined,
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
}) })
} return typeof result === 'string' ? result.trim() : null
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 }
} }
const activeName = computed(() => { const activeName = computed(() => {
@@ -679,7 +654,7 @@ async function loadTrash() {
}) })
} }
async function switchView(view: View, listId?: string) { 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 taskMutationNavigation.value += 1
taskReorderMode.value = false taskReorderMode.value = false
cancelTaskReorder() cancelTaskReorder()
@@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) {
if (listId) activeList.value = listId if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1 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 !== 'memos') memoDetailOpen.value = false
if (view === 'trash') await loadTrash() if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView() else if (view === 'today') await loadTodayView()
@@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() {
} }
} }
async function removeTask(task: Task) { async function removeTask(task: Task) {
if (!window.confirm(`把“${task.title}”移到回收站?`)) return if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
try { try {
await api(`/tasks/${task.id}`, { method: 'DELETE' }) await api(`/tasks/${task.id}`, { method: 'DELETE' })
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id) 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' }), '任务已恢复') await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
} }
async function purgeTask(task: Task) { 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' }), '任务已永久删除') await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
} }
async function addSubtask() { async function addSubtask() {
@@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) {
purgeListError.value = '' purgeListError.value = ''
archivedListAction.value = null archivedListAction.value = null
archivedListActionTrigger = null archivedListActionTrigger = null
nextTick(() => purgeCancelButton.value?.focus())
} }
function focusPurgeListTrigger() { function focusPurgeListTrigger() {
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
@@ -1164,15 +1138,6 @@ function closePurgeList() {
purgeListError.value = '' purgeListError.value = ''
focusPurgeListTrigger() 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() { async function confirmPurgeList() {
if (!purgeListTarget.value || purgeListSubmitting.value) return if (!purgeListTarget.value || purgeListSubmitting.value) return
purgeListSubmitting.value = true purgeListSubmitting.value = true
@@ -1467,8 +1432,8 @@ onUnmounted(() => {
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button> <button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button> <button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</nav> </nav>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"> <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">
<section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"> <template v-if="sidebarAction">
<template v-if="!listMoveMenuOpen"> <template v-if="!listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-header"> <header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div> <div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
@@ -1505,8 +1470,8 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</template> </template>
</section> </template>
</div> </AppSheet>
</aside> </aside>
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull"> <main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
@@ -1573,8 +1538,8 @@ onUnmounted(() => {
</template> </template>
</main> </main>
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}"> <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>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div> <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-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> <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> <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></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 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> </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> <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" /> <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"> <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">
<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">
<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> <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"> <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> <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> <label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
</div> </div>
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer> <footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
</form> </AppSheet>
</div>
</Transition>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition> <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> <div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
<Teleport to="body"> <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> <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> </Teleport>
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList"> <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">
<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"> <template v-if="purgeListTarget">
<div class="app-sheet__body">
<h3 id="purge-list-title">永久删除清单{{ purgeListTarget.name }}</h3> <h3 id="purge-list-title">永久删除清单{{ purgeListTarget.name }}</h3>
<p id="purge-list-description">将永久删除其中的全部任务子任务重复规则附件及实体文件此操作无法撤销</p> <p id="purge-list-description">将永久删除其中的全部任务子任务重复规则附件及实体文件此操作无法撤销</p>
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</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> </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> </div>
</template> </template>
+127 -12
View File
@@ -1,15 +1,42 @@
import { readFileSync } from 'node:fs' 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 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', () => { describe('countdown modal accessibility', () => {
it('names the dialog and supports focus and Escape close', () => { it('names the shared dialog contract and delegates focus and Escape handling', () => {
expect(source).toContain('aria-labelledby="countdown-dialog-title"') expect(source).toContain('title-id="countdown-dialog-title"')
expect(source).toContain('id="countdown-dialog-title"') expect(source).toContain('id="countdown-dialog-title"')
expect(source).toContain('@keydown.esc="closeDialog"') expect(source).toContain('initial-focus="input[aria-label=\'倒数日名称\']"')
expect(source).toContain('ref="titleInput"') expect(source).not.toContain('trapDialogFocus')
expect(source).toContain('titleInput.value?.focus()') expect(source).not.toContain('ref="titleInput"')
expect(source).not.toContain('ref="detailCloseButton"')
expect(source).toContain(':inert="open || Boolean(detailItem)"') 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('item.id !== focusItem.value?.id')
expect(source).toContain('`kind-${item.kind}`') expect(source).toContain('`kind-${item.kind}`')
expect(source).toContain(':class="`kind-${focusItem.kind}`"') expect(source).toContain(':class="`kind-${focusItem.kind}`"')
expect(source).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"') expect(source).toContain('panel-class="countdown-detail-sheet"')
expect(source).toContain('ref="detailCloseButton"') expect(source).not.toContain('ref="detailCloseButton"')
expect(source).toContain('detailCloseButton.value?.focus()') expect(source).toContain('initial-focus="button[aria-label=\'关闭详情\']"')
expect(source).toContain('@keydown="trapDetailFocus"') expect(source).not.toContain('trapDetailFocus')
expect(source).not.toContain('class="countdown-actions"') 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("request('/countdowns?archived=true') as Promise<Countdown[]>")
expect(source).toContain('const generation = getCountdownCacheGeneration()') expect(source).toContain('const generation = getCountdownCacheGeneration()')
expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return') expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return')
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) error.value=') expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=')
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) busy.value=false') expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false')
}) })
it('prevents duplicate submits and sends the edit precondition', () => { it('prevents duplicate submits and sends the edit precondition', () => {
@@ -85,4 +112,92 @@ describe('countdown modal accessibility', () => {
expect(source).toContain('添加第一个重要日子') expect(source).toContain('添加第一个重要日子')
expect(source).toContain('@click="openFromEmpty"') 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')
})
}) })
+69 -63
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <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 { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils' 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 = { type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' 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 showArchived = ref(false), open = ref(false), busy = ref(false)
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('') const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false) 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 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 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 form = ref<Form>(freshForm())
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) 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 composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
const titleInput = ref<HTMLInputElement | null>(null) const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
const detailCloseButton = ref<HTMLButtonElement | null>(null)
let previousFocus: HTMLElement | null = null
function focusDialog() {
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
void nextTick(() => titleInput.value?.focus())
}
function closeDialog() { function closeDialog() {
open.value = false open.value = false
showAdvanced.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) { function primaryDate(item: Countdown) {
@@ -99,7 +88,25 @@ async function request(path:string, options:RequestInit={}) {
} }
return response.status === 204 ? null : response.json() 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() { async function fetchCountdowns() {
const [active, archivedItems] = await Promise.all([ const [active, archivedItems] = await Promise.all([
request('/countdowns') as Promise<Countdown[]>, request('/countdowns') as Promise<Countdown[]>,
@@ -107,31 +114,36 @@ async function fetchCountdowns() {
]) ])
return { items: active, archived: archivedItems } return { items: active, archived: archivedItems }
} }
async function load(force = false) { async function load(force = false, manageBusy = true) {
const generation = getCountdownCacheGeneration() const generation = getCountdownCacheGeneration()
const cached = readCountdownCache<Countdown>() const cached = readCountdownCache<Countdown>()
if (cached) { items.value=cached.items; archived.value=cached.archived } if (cached) { items.value=cached.items; archived.value=cached.archived }
if (!cached) busy.value=true if (!cached && manageBusy) busy.value=true
error.value='' if (manageBusy) error.value=''
try { try {
const data = await loadCountdownCache(fetchCountdowns, { force }) const data = await loadCountdownCache(fetchCountdowns, { force })
if (!isCountdownCacheGenerationCurrent(generation)) return if (!isCountdownCacheGenerationCurrent(generation)) return
items.value=data.items items.value=data.items
archived.value=data.archived archived.value=data.archived
} catch(reason) { } catch(reason) {
if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败' if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
} finally { } 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) { function edit(item:Countdown) {
detailItem.value=null selectDetail(null)
editingId.value=item.id editingId.value=item.id
editingItem.value=item editingItem.value=item
showAdvanced.value=false 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 } 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 open.value=true
focusDialog()
} }
function applyKindDefaults() { function applyKindDefaults() {
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly' if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
@@ -140,40 +152,35 @@ function applyKindDefaults() {
async function save() { async function save() {
if (busy.value) return if (busy.value) return
if (!form.value.title.trim()) 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 } 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 (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 } 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' const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) }) 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 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(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})} 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(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})} 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(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);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 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 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 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 openDetail(item:Countdown){selectDetail(item)}
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())} function closeDetail(){selectDetail(null)}
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 openFromEmpty(){openCountdownComposer()} 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 }) defineExpose({ openCountdownComposer })
onMounted(() => { void load() }) onMounted(() => { void load() })
onBeforeUnmount(() => { previousFocus = null }) onBeforeUnmount(() => {
mounted = false
operationGeneration.value += 1
})
</script> </script>
<template> <template>
@@ -199,21 +206,20 @@ onBeforeUnmount(() => { previousFocus = null })
</div> </div>
</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> <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> <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 @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div> <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> </div>
<Transition name="countdown-detail"> <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">
<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"> <template v-if="detailItem">
<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> <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> <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> <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> <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>
</article></div> </template>
</Transition> </AppSheet>
<Transition name="countdown-compose"> <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">
<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><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
<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 v-model="form.title" aria-label="倒数日名称" maxlength="200" required placeholder="例如:去北海道旅行"></label>
<div class="app-sheet__body"><label>名称<input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></label>
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></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> <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> <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> <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> </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> <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> </AppSheet>
</Transition> <AppDialog ref="appDialog" />
</section> </section>
</template> </template>
+11 -2
View File
@@ -31,7 +31,7 @@ describe('memo shell integration', () => {
expect(app).toContain("import MemoPanel from './MemoPanel.vue'") expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
expect(panel).toContain("import MemoRow") expect(panel).toContain("import MemoRow")
expect(panel).toContain("import MemoEditor") 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>') 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') 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', () => { 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('const memoDetailOpen = ref(false)')
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen") 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('height:min(92dvh,820px)')
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor') expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
expect(editor).toContain("window.addEventListener('beforeunload'") expect(editor).toContain("window.addEventListener('beforeunload'")
expect(editor).toContain("event.key !== 'Tab'")
}) })
}) })
+40 -21
View File
@@ -14,6 +14,12 @@ function deferred<T>() {
return { promise, resolve, reject } return { promise, resolve, reject }
} }
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() } 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) { async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
const host = document.createElement('div'); document.body.append(host) const host = document.createElement('div'); document.body.append(host)
@@ -85,7 +91,6 @@ describe('MemoPanel', () => {
['success', null], ['success', null],
['error', new Error('迟到保存失败')], ['error', new Error('迟到保存失败')],
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => { ])('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 pending = deferred<unknown>()
const notices: string[] = [] const notices: string[] = []
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH' 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="备忘录标题"]')! const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick() title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click() 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) if (failure) pending.reject(failure)
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 }) else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
await flush() await flush()
@@ -119,8 +124,7 @@ describe('MemoPanel', () => {
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick() title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush() host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
host.querySelector<HTMLButtonElement>('.memo-reload')!.click() host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
vi.spyOn(window, 'confirm').mockReturnValue(true) host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush() reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
expect(host.querySelector('.memo-editor')).toBeNull() expect(host.querySelector('.memo-editor')).toBeNull()
}) })
@@ -130,7 +134,6 @@ describe('MemoPanel', () => {
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'], ['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'], ['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => { ])('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 scoped = { ...item, deleted_at: deletedAt }
const notices: string[] = [] const notices: string[] = []
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => { 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)) const { host } = await mount(request, (message) => notices.push(message))
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() } if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
host.querySelector<HTMLButtonElement>('.memo-row')!.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(notices).toEqual([message])
expect(host.querySelector('.memo-editor')).toBeNull() expect(host.querySelector('.memo-editor')).toBeNull()
}) })
it('does not move focus or close detail when dirty close is cancelled', async () => { it('does not move focus or close detail when dirty close is cancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false)
const details: boolean[] = [] const details: boolean[] = []
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1' const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
? { ...item, content: '正文' } ? { ...item, content: '正文' }
@@ -157,7 +161,7 @@ describe('MemoPanel', () => {
row.click(); await flush() row.click(); await flush()
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')! const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick() 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(host.querySelector('.memo-editor')).not.toBeNull()
expect(details).toEqual([true]) expect(details).toEqual([true])
expect(document.activeElement).toBe(content) expect(document.activeElement).toBe(content)
@@ -174,7 +178,7 @@ describe('MemoPanel', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
window.dispatchEvent(new Event('resize')); await nextTick() window.dispatchEvent(new Event('resize')); await nextTick()
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true) 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 }) 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(); await flush()
host.querySelector<HTMLButtonElement>('.memo-row')!.click() host.querySelector<HTMLButtonElement>('.memo-row')!.click()
if (lifecycle === 'delete') { if (lifecycle === 'delete') {
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
host.querySelector<HTMLButtonElement>('.danger-text')!.click() host.querySelector<HTMLButtonElement>('.danger-text')!.click()
await answerDialog(true)
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click() } else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
await flush() await flush()
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); 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() title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click() host.querySelector<HTMLButtonElement>('.memo-save')!.click()
vi.spyOn(window, 'confirm').mockReturnValueOnce(true) host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条') expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 }) 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() firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click() host.querySelector<HTMLButtonElement>('.memo-save')!.click()
vi.spyOn(window, 'confirm').mockReturnValueOnce(true) host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush() host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')! const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick() 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() firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click() host.querySelector<HTMLButtonElement>('.memo-save')!.click()
vi.spyOn(window, 'confirm').mockReturnValueOnce(true) host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush() host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')! const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick() 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() firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click() host.querySelector<HTMLButtonElement>('.memo-save')!.click()
vi.spyOn(window, 'confirm').mockReturnValueOnce(true) host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush() host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')! const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick() 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 () => { 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 remove = deferred<unknown>()
const other = { ...item, id: 'm2', title: '第二条' } const other = { ...item, id: 'm2', title: '第二条' }
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => { const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
@@ -754,6 +753,7 @@ describe('MemoPanel', () => {
const { host } = await mount(request) const { host } = await mount(request)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush() host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
host.querySelector<HTMLButtonElement>('.danger-text')!.click() host.querySelector<HTMLButtonElement>('.danger-text')!.click()
await answerDialog(true)
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush() host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
remove.resolve(undefined); 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 () => { 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> => { const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
if (path === '/memos/m1') return { ...item, content: '正文' } if (path === '/memos/m1') return { ...item, content: '正文' }
@@ -770,7 +769,7 @@ describe('MemoPanel', () => {
}) })
const { host } = await mount(request) const { host } = await mount(request)
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush() 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="搜索备忘录"]')) expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
}) })
@@ -791,6 +790,26 @@ describe('MemoPanel', () => {
expect(host.querySelector('.memo-editor')).toBeNull() 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 () => { 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 request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
const { host, vm } = await mount(request) const { host, vm } = await mount(request)
+18 -6
View File
@@ -3,6 +3,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search } from 'lucide-vue-next' import { Archive, FileText, Search } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue' import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.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> type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>() const props = defineProps<{ request: RequestFn }>()
@@ -18,6 +20,7 @@ const error = ref('')
const selected = ref<MemoEditorValue | null>(null) const selected = ref<MemoEditorValue | null>(null)
const selectedToken = ref(0) const selectedToken = ref(0)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null) 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 searchInput = ref<HTMLInputElement | null>(null)
const mobileDetail = ref(window.innerWidth <= 930) const mobileDetail = ref(window.innerWidth <= 930)
let detailOpener: HTMLElement | null = null let detailOpener: HTMLElement | null = null
@@ -87,13 +90,20 @@ function closeDetail() {
detailOpener = null detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus()) 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') { async function setScope(next: 'active' | 'trash') {
if (next === scope.value) return 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() closeDetail(); scope.value = next; emit('scope', next); await load()
} }
async function selectMemo(id: string, opener?: EventTarget | null) { 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 if (opener instanceof HTMLElement) detailOpener = opener
const token = ++detailGeneration const token = ++detailGeneration
try { try {
@@ -105,7 +115,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
} }
async function createMemo() { async function createMemo() {
if (scope.value === 'trash') return if (scope.value === 'trash') return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return if (editor.value?.dirty && !(await confirmDiscard('新建后,当前草稿不会保存。'))) return
const token = ++detailGeneration const token = ++detailGeneration
selectedToken.value = token selectedToken.value = token
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null } 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> </div>
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p> <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-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"> <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"/> <MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
</div> </div>
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载':'加载更多'}}</button> <button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载':'加载更多'}}</button>
</div> </div>
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/> <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" @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)"/> <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> </section>
</template> </template>
+113 -49
View File
@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 { 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 { 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' 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 View = 'habits' | 'today-habits' | 'settings'
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean } type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
@@ -51,12 +55,30 @@ const habitHistoryNextTo = ref('')
const habitHistoryHasMore = ref(false) const habitHistoryHasMore = ref(false)
let habitHistoryRequest = 0 let habitHistoryRequest = 0
const habitDetailClickSuppressed = ref(false) const habitDetailClickSuppressed = ref(false)
const habitDetailSheet = ref<HTMLElement | null>(null)
let habitDetailOpener: HTMLElement | null = null let habitDetailOpener: HTMLElement | null = null
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) 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 habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
const habitNameInput = ref<HTMLInputElement | null>(null) 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 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 currentPassword = ref('')
const newPassword = ref('') const newPassword = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
@@ -101,18 +123,16 @@ watch(habitReorderAvailable, (available) => {
}) })
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
async function request(path: string, options: RequestInit = {}) { async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) } if (options.body instanceof FormData) {
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json' const file = options.body.get('file')
const csrf = csrfHeader(options.method) if (file instanceof File) return uploadJson<T>(path, file, options)
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))
} }
const type = response.headers.get('content-type') || '' let body: unknown = undefined
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob() 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>) { async function safe(work: () => Promise<void>) {
busy.value = true; error.value = '' busy.value = true; error.value = ''
@@ -485,7 +505,6 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
selectedHabit.value = h selectedHabit.value = h
void loadHabitHistory(true) void loadHabitHistory(true)
void nextTick(() => habitDetailSheet.value?.focus())
} }
function closeHabitDetail() { function closeHabitDetail() {
habitHistoryRequest += 1 habitHistoryRequest += 1
@@ -503,7 +522,7 @@ function closeHabitDetail() {
} }
defineExpose({ openHabitComposer, refreshHabits: loadHabits }) defineExpose({ openHabitComposer, refreshHabits: loadHabits })
async function archiveHabit(h: Habit) { async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
await safe(async () => { await safe(async () => {
await request(`/habits/${h.id}`, { method: 'DELETE' }) await request(`/habits/${h.id}`, { method: 'DELETE' })
selectedHabit.value = null selectedHabit.value = null
@@ -532,7 +551,7 @@ async function restoreHabit(h: Habit) {
} }
} }
async function deleteHabit(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 = '' error.value = ''
try { try {
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' }) await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
@@ -587,7 +606,7 @@ async function loadHabits() {
} }
async function loadSettings() { async function loadSettings() {
await safe(async () => { 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 sessions.value = mergePage<Session>(s).items
audit.value = mergePage<any>(a).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', '会话已撤销') }) await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
} }
async function revokeOtherSessions() { async function revokeOtherSessions() {
if (!confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')) return if (!(await confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。'))) return
await safe(async () => { await safe(async () => {
await request('/sessions/others', { method: 'DELETE' }) await request('/sessions/others', { method: 'DELETE' })
await loadSettings() 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) 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() { async function exportData() {
await safe(async () => { backupBusy.value = true; backupError.value = ''
const response = await fetch('/api/v1/export.csv', { credentials: 'include' }) try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
if (!response.ok) throw new Error('导出失败') catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
downloadBlob(await response.blob(), 'dodo-export.csv') 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() { async function restore() {
if (!restoreFile.value) return const file = restoreFile.value
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return const mode = restoreMode.value
await safe(async () => { const preview = restorePreflight.value
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) { const legacy = Boolean(file && isLegacyBackup(file))
const form = new FormData() if (!file || mode !== restoreMode.value || (legacy ? mode !== 'merge' : !preview?.valid || !acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode))) return
form.append('file', restoreFile.value!) const destructive = legacy ? '旧格式将在恢复时由服务端校验,仅支持合并恢复。' : mode === 'replace' ? '现有数据将被备份内容替换,此操作不可撤销。' : '同名或相同标识的数据将按合并规则处理。'
await request('/restore.csv?mode=merge', { method: 'POST', body: form }) 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 { } else {
const text = await restoreFile.value!.text() await requestJson('/restore?mode=merge', { method: 'POST', body: JSON.parse(await file.text()) })
await request('/restore?mode=merge', { method: 'POST', body: text })
} }
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
if (restoreInput.value) restoreInput.value.value = ''
emit('changed'); emit('notice', '数据已恢复') emit('changed'); emit('notice', '数据已恢复')
}) } catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
finally { backupBusy.value = false }
} }
async function changePassword() { async function changePassword() {
passwordError.value = '' passwordError.value = ''
@@ -665,6 +732,7 @@ onMounted(() => {
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelPreflight()
if (dayRolloverTimer) clearInterval(dayRolloverTimer) if (dayRolloverTimer) clearInterval(dayRolloverTimer)
}) })
</script> </script>
@@ -694,9 +762,7 @@ onBeforeUnmount(() => {
</div> </div>
<!-- 完整习惯列表 --> <!-- 完整习惯列表 -->
<Transition name="task-compose"> <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">
<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">
<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> <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"> <div class="app-sheet__body">
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p> <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> <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> </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> <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> </AppSheet>
</div>
</Transition>
<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> <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> <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> </div>
</section> </section>
<Transition name="countdown-detail"> <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">
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail"> <template v-if="selectedHabit">
<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">
<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> <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="app-sheet__body">
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div> <div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
@@ -767,9 +830,8 @@ onBeforeUnmount(() => {
</div> </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__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> <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> </template>
</div> </AppSheet>
</Transition>
</template> </template>
<!-- 设置与数据 --> <!-- 设置与数据 -->
@@ -777,12 +839,14 @@ onBeforeUnmount(() => {
<header class="view-intro"> <header class="view-intro">
<div><small>备份迁移与安全</small></div> <div><small>备份迁移与安全</small></div>
</header> </header>
<div class="settings-grid"> <div class="settings-sections">
<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> <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>
<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> <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>
<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> <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>
<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> <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> </div>
</template> </template>
<AppDialog ref="appDialog" />
</section> </section>
</template> </template>
+70
View File
@@ -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' }))
})
})
+28
View File
@@ -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 })
}
+31
View File
@@ -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
}
}
+48
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
export * from './errors'
export * from './http'
export * from './backups'
+58
View File
@@ -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>
+262
View File
@@ -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'])
})
})
+83
View File
@@ -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="clear"')
expect(picker).toContain('data-action="cancel"') expect(picker).toContain('data-action="cancel"')
expect(picker).toContain('data-action="done"') expect(picker).toContain('data-action="done"')
expect(picker).toContain("event.preventDefault(); event.stopPropagation(); close()")
}) })
}) })
+1 -1
View File
@@ -46,7 +46,7 @@ function onGridKey(event: KeyboardEvent) {
} }
} }
function onDialogKey(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 if (event.key !== 'Tab' || !dialog.value) return
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')] const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
if (!focusables.length) return if (!focusables.length) return
+21 -27
View File
@@ -17,7 +17,8 @@ async function mount(overrides: Record<string, unknown> = {}) {
const events: Record<string, unknown[]> = { saved: [], close: [] } const events: Record<string, unknown[]> = { saved: [], close: [] }
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 })) 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 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() app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
return { host, request: request as ReturnType<typeof vi.fn>, events } 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 () => { 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 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() clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
expect(confirm).not.toHaveBeenCalled() expect(confirmAction).not.toHaveBeenCalled()
expect(request).not.toHaveBeenCalled() expect(request).not.toHaveBeenCalled()
expect(clean.events.close).toEqual([true]) 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="备忘录正文"]')! const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick() body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click() edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(confirm).toHaveBeenCalledOnce() expect(confirmAction).toHaveBeenCalledOnce()
expect(edited.events.close).toEqual([]) expect(edited.events.close).toEqual([])
}) })
@@ -226,22 +227,15 @@ describe('MemoEditor', () => {
expect(events.close).toEqual([]) expect(events.close).toEqual([])
}) })
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => { it('leaves mobile modal focus trapping and Escape close to AppSheet', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
const { host, events } = await mount({ mobile: true }) const { host, events } = await mount({ mobile: true })
const dialog = host.querySelector<HTMLElement>('.memo-editor')! const editor = host.querySelector<HTMLElement>('.memo-editor')!
expect(dialog.getAttribute('aria-modal')).toBe('true') expect(editor.getAttribute('aria-modal')).toBeNull()
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)! const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
last.focus() editor.dispatchEvent(escape)
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 }))
await nextTick() await nextTick()
expect(events.close).toEqual([true]) expect(escape.defaultPrevented).toBe(false)
expect(document.activeElement).not.toBe(opener) expect(events.close).toEqual([])
opener.remove()
}) })
it('ignores a stale reload after its editor selection changes', async () => { 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 () => { it('guards dirty close and allows clean close', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) const confirmAction = vi.fn(async () => false)
const { host, events } = await mount() const { host, events } = await mount({ confirmAction })
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')! const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick() body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click() host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(confirm).toHaveBeenCalled() expect(confirmAction).toHaveBeenCalled()
expect(events.close).toEqual([]) expect(events.close).toEqual([])
confirm.mockReturnValue(true) confirmAction.mockResolvedValue(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click() host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
expect(events.close).toEqual([true]) expect(events.close).toEqual([true])
}) })
}) })
+8 -16
View File
@@ -8,7 +8,7 @@ export type MemoDraft = { id: null; title: string; content: string; version: nul
export type Memo = MemoRecord export type Memo = MemoRecord
export type MemoEditorValue = MemoRecord | MemoDraft export type MemoEditorValue = MemoRecord | MemoDraft
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown> 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 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 title = ref('')
const content = ref('') const content = ref('')
@@ -18,7 +18,6 @@ let lifecycleGeneration = 0
const error = ref('') const error = ref('')
const conflict = ref(false) const conflict = ref(false)
const titleInput = ref<HTMLInputElement | null>(null) const titleInput = ref<HTMLInputElement | null>(null)
const root = ref<HTMLElement | null>(null)
const initial = ref({ title: '', content: '' }) const initial = ref({ title: '', content: '' })
const memoPreview = ref(false) const memoPreview = ref(false)
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null) const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
@@ -35,8 +34,8 @@ watch(() => props.selectionToken, () => {
lifecycleGeneration += 1 lifecycleGeneration += 1
lifecycleBusy.value = false lifecycleBusy.value = false
}) })
function close() { async function close() {
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
emit('close') emit('close')
} }
function validate() { function validate() {
@@ -91,7 +90,7 @@ async function reload() {
async function remove() { async function remove() {
const memoId = props.memo.id const memoId = props.memo.id
if (memoId === null || lifecycleBusy.value) return 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 = '' error.value = ''
lifecycleBusy.value = true lifecycleBusy.value = true
const operationToken = ++lifecycleGeneration const operationToken = ++lifecycleGeneration
@@ -130,7 +129,7 @@ async function restore() {
async function purge() { async function purge() {
const memoId = props.memo.id const memoId = props.memo.id
if (memoId === null || lifecycleBusy.value) return 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 = '' error.value = ''
lifecycleBusy.value = true lifecycleBusy.value = true
const operationToken = ++lifecycleGeneration const operationToken = ++lifecycleGeneration
@@ -170,14 +169,7 @@ function handleMemoBodyShortcut(event: KeyboardEvent) {
formatMemoBody(format) formatMemoBody(format)
} }
function keydown(event: KeyboardEvent) { function keydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save() }
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() }
} }
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() } function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) }) onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
@@ -186,7 +178,7 @@ defineExpose({ dirty, requestClose: close })
</script> </script>
<template> <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> <header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
<div class="memo-editor__fields"> <div class="memo-editor__fields">
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label> <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> </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-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> <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> </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')
}
-15
View File
@@ -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()
})
})
-6
View File
@@ -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
}
+2 -2
View File
@@ -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} .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(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}} @media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
+11 -14
View File
File diff suppressed because one or more lines are too long
+75 -48
View File
@@ -99,10 +99,19 @@ describe('mobile navigation styles', () => {
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'") expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
}) })
it('keeps the mobile More sheet visible when it is rendered', () => { it('has no unreachable mobile More state, template, or styles', () => {
expect(css).not.toContain('.more-mask{display:none}') expect(app).not.toContain('mobileMore')
expect(css).toContain('@media(max-width:930px){.shell') expect(app).not.toContain('mobile-more-menu')
expect(css).toContain('.more-mask{position:fixed;z-index:45;') 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', () => { 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('class="session-card-actions"')
expect(mvpPanel).toContain('撤销其他所有会话') expect(mvpPanel).toContain('撤销其他所有会话')
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })") expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')") expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
expect(css).toContain('.session-card-actions{') 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('.sidebar{background:var(--surface-canvas)')
expect(css).toContain('main{background:var(--surface-base)}') expect(css).toContain('main{background:var(--surface-base)}')
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)') 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('.toast{background:#3b342c')
expect(css).toContain('.error-toast{background:var(--danger)') 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', () => { it('keeps compact continuous lists without per-row outer shadows', () => {
@@ -409,14 +419,19 @@ describe('archived task-list disclosure', () => {
}) })
describe('mobile sheet contract', () => { describe('mobile sheet contract', () => {
it('uses shared roles for details, creation and secondary actions', () => { it('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
expect(app).toContain('class="task-compose-mask app-sheet-mask"') expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"') expect(css).toContain('.app-overlay>.detail{transform:none}')
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"') it('uses shared AppSheet variants for details and creation', () => {
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"') expect(app).toContain('panel-class="task-compose-sheet"')
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"') 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('--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-mask.app-sheet-mask{background:var(--sheet-scrim)')
expect(css).toContain('.app-sheet__header{min-height:64px;') 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', () => { 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).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"') expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"') expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"') expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"') 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('syncHabitHistoryToday(h)')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"') 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('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"') expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()") expect(mvpPanel).not.toContain('habitDetailSheet')
}) })
it('provides an archived-habit viewing path', () => { 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', () => { 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="新密码"') 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', () => { 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).not.toContain('<h2>设置与数据</h2>') expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>') for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
expect(mvpPanel).toContain('<h2>修改密码</h2>') expect(mvpPanel).toContain('class="settings-sections"')
expect(mvpPanel).toContain('<h2>登录会话</h2>') expect(mvpPanel).not.toContain('class="settings-grid"')
expect(mvpPanel).toContain('<h2>最近活动</h2>') expect(mvpPanel).not.toContain('class="tool-card')
expect(css).toContain('.tool-card>h2{') 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', () => { it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const modalError = ref') expect(app).toContain('const appDialog = ref')
expect(app).toContain('role="alert" class="field-error"') expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
expect(app).toContain('normalizeRequiredName')
expect(mvpPanel).toContain('habitErrors.name') expect(mvpPanel).toContain('habitErrors.name')
expect(mvpPanel).toContain('aria-describedby="habit-name-error"') expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
expect(mvpPanel).toContain('const habitFormInvalid = computed') expect(mvpPanel).toContain('const habitFormInvalid = computed')
@@ -1009,17 +1025,26 @@ describe('approved habit safety and U2 title hierarchy', () => {
}) })
describe('settings data tools', () => { describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => { it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>') expect(mvpPanel).toContain('<h2>数据</h2>')
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'") expect(mvpPanel).toContain("downloadFullBackup()")
expect(mvpPanel).toContain("'dodo-export.csv'") expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
expect(mvpPanel).toContain('导出 CSV') expect(mvpPanel).toContain('导出 ZIP')
expect(mvpPanel).not.toContain('导出 JSON') expect(mvpPanel).toContain('accept=".zip,.csv,.json')
expect(mvpPanel).toContain('@click="restore"') expect(mvpPanel).toContain('v-model="restoreMode"')
expect(mvpPanel).not.toContain('<h3>导入</h3>') 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("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(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging') expect(css).toContain('.unified-fab.dragging')
expect(css).toContain('.unified-fab.snapping') expect(css).toContain('.unified-fab.snapping')
expect(countdownPanel).toContain('<Transition name="countdown-compose">') expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
expect(css).toContain('.countdown-compose-enter-active') expect(countdownPanel).toContain('panel-class="countdown-modal"')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}') 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', () => { describe('desktop task detail disclosure', () => {
it('gives the task list the full remaining width until a task is selected', () => { it('gives the task list the full remaining width until a task is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask)") 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('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()') expect(app).toContain('function closeTaskDetail()')
expect(app).not.toContain('<div v-else class="paper">') 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', () => { 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('aria-label="打开清单操作"') expect(app).toContain('aria-label="打开清单操作"')
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"') expect(app).toContain('panel-class="sidebar-action-sheet"')
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"') expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
expect(app).toContain('class="sidebar-action-kind"') expect(app).toContain('class="sidebar-action-kind"')
expect(app).toContain('class="sidebar-action-group"') expect(app).toContain('class="sidebar-action-group"')
expect(app).toContain('class="sidebar-action-group-title"') 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', () => { it('uses a guarded custom confirmation that keeps failures visible', () => {
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。') expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
expect(app).toContain('ref="purgeCancelButton"') expect(app).toContain('panel-class="purge-list-dialog"')
expect(app).toContain('purgeCancelButton.value?.focus()') expect(app).toContain('initial-focus=".secondary"')
expect(app).toContain('@keydown="handlePurgeDialogKeydown"') expect(app).toContain(':busy="purgeListSubmitting"')
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()") expect(app).toContain('@close="closePurgeList"')
expect(app).not.toContain('handlePurgeDialogKeydown')
expect(app).toContain('if (purgeListSubmitting.value) return') expect(app).toContain('if (purgeListSubmitting.value) return')
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'') expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
expect(app).toContain(':disabled="purgeListSubmitting"') 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
View File
@@ -1,8 +1,10 @@
import asyncio
import os import os
from pathlib import Path from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import event
@pytest.fixture @pytest.fixture
@@ -11,9 +13,18 @@ def client(tmp_path: Path):
os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true" os.environ["DODO_AUTO_CREATE_SCHEMA"] = "true"
from backend.config import get_settings from backend.config import get_settings
get_settings.cache_clear() get_settings.cache_clear()
from backend.db import reset_engine from backend.db import get_engine, reset_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 from backend.main import app
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield test_client yield test_client
asyncio.run(engine.dispose())
reset_engine() reset_engine()
+2 -2
View File
@@ -242,7 +242,7 @@ def test_after_completion_recurrence_survives_json_and_csv_round_trips(client):
assert recurrence["after_completion_days"] == 2 assert recurrence["after_completion_days"] == 2
assert recurrence["last_completed_at"] is None 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 assert restored.status_code == 200
restored_task = client.get("/api/v1/tasks", params={"q": task["title"]}).json()["items"][0] 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() 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") csv_export = client.get("/api/v1/export.csv")
assert csv_export.status_code == 200 assert csv_export.status_code == 200
csv_restore = client.post( 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")}, files={"file": ("dodo-export.csv", csv_export.content, "text/csv")},
) )
assert csv_restore.status_code == 200 assert csv_restore.status_code == 200
+3 -2
View File
@@ -532,12 +532,13 @@ def test_restore_replace_recovers_habits_and_task_links_without_tags(client):
"/api/v1/habits", "/api/v1/habits",
json={"name": "深蹲", "kind": "boolean", "schedule_type": "daily"}, 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 restored.status_code == 200
assert client.get("/api/v1/tags").status_code == 404 assert client.get("/api/v1/tags").status_code == 404
habits = client.get("/api/v1/habits").json() 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"] listed = client.get("/api/v1/tasks", params={"q": "备份任务"}).json()["items"]
assert "tags" not in listed[0] assert "tags" not in listed[0]
+717
View File
@@ -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
+760
View File
@@ -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())
+7 -7
View File
@@ -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"] assert next(item for item in exported["countdowns"] if item["title"] == "旧日")["archived_at"]
create_countdown(client, title="干扰数据") 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 assert restored.status_code == 200
restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年") restored_active = next(item for item in client.get("/api/v1/countdowns").json() if item["title"] == "周年")
assert restored_active["calendar_mode"] == "lunar" 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 assert len(client.get("/api/v1/countdowns").json()) == 1
exported_by_other = client.get("/api/v1/export").json() 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 assert restored_again.status_code == 200
replaced = client.get("/api/v1/countdowns").json() replaced = client.get("/api/v1/countdowns").json()
assert len(replaced) == 1 assert len(replaced) == 1
@@ -335,20 +335,20 @@ def test_restore_rejects_malformed_countdowns_atomically(client):
malformed = deepcopy(exported) malformed = deepcopy(exported)
malformed["countdowns"][0]["archived_at"] = "not-a-date" 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 response.status_code == 422
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
for invalid in (None, 7): for invalid in (None, 7):
malformed = deepcopy(exported) malformed = deepcopy(exported)
malformed["countdowns"] = invalid 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 response.status_code == 422
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
duplicate = deepcopy(exported) duplicate = deepcopy(exported)
duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0])) 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 response.status_code == 422
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]] 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 two_pinned["countdowns"][0]["pinned"] = True
extra["pinned"] = True extra["pinned"] = True
two_pinned["countdowns"].append(extra) 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 response.status_code == 200
assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1 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" assert created.json()["event_date"] == "2002-02-01"
exported = client.get("/api/v1/export").json() 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 assert restored.status_code == 200
item = client.get("/api/v1/countdowns").json()[0] item = client.get("/api/v1/countdowns").json()[0]
assert item["event_date"] == "2002-02-01" assert item["event_date"] == "2002-02-01"
+2 -2
View File
@@ -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") csv_backup = client.get("/api/v1/export.csv")
assert b"memos" in csv_backup.content assert b"memos" in csv_backup.content
restored_csv = client.post( 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")}, files={"file": ("dodo-export.csv", csv_backup.content, "text/csv")},
) )
assert restored_csv.status_code == 200 assert restored_csv.status_code == 200
@@ -278,4 +278,4 @@ def test_legacy_backup_without_memos_still_restores(client):
boot(client) boot(client)
backup = client.get("/api/v1/export").json() backup = client.get("/api/v1/export").json()
backup.pop("memos", None) 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
+46
View File
@@ -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
+5 -3
View File
@@ -121,7 +121,7 @@ def test_export_and_restore_preserve_task_recurrence(client):
exported = client.get("/api/v1/export").json() exported = client.get("/api/v1/export").json()
assert exported["recurrences"][0]["task_id"] == task["id"] 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 assert restored.status_code == 200
restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0] restored_task = client.get("/api/v1/tasks", params={"q": "每周整理"}).json()["items"][0]
recurrence = client.get(f"/api/v1/tasks/{restored_task['id']}/recurrence").json() 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() export = client.get("/api/v1/export").json()
assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1" assert export["version"] == 1 and export["tasks"][0]["external_id"] == "ext-1"
client.delete(f"/api/v1/tasks/{export['tasks'][0]['id']}") 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 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 assert client.post("/api/v1/restore", json={"version": 999}).status_code == 422