From 094e03f341dec55bf7be0a51238f20497748feeb Mon Sep 17 00:00:00 2001 From: MobKBK <15059009+mobkbk@user.noreply.gitee.com> Date: Mon, 24 Aug 2026 06:18:21 +0800 Subject: [PATCH] feat: scaffold personal progress monitor MVP --- .env.example | 7 + .gitignore | 7 + Dockerfile | 13 ++ README.md | 42 ++++ app/__init__.py | 2 + app/auth.py | 67 ++++++ app/layouts.py | 78 +++++++ app/main.py | 226 ++++++++++++++++++ app/models.py | 115 +++++++++ app/repository.py | 401 ++++++++++++++++++++++++++++++++ app/static/app.js | 143 ++++++++++++ app/static/index.html | 69 ++++++ app/static/styles.css | 127 ++++++++++ compose.yaml | 24 ++ config/layouts/home.json | 13 ++ config/layouts/task-detail.json | 16 ++ requirements.txt | 6 + tests/conftest.py | 23 ++ tests/test_api.py | 31 +++ tests/test_repository.py | 48 ++++ 需求记录.md | 22 ++ 21 files changed, 1480 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/layouts.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/repository.py create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/styles.css create mode 100644 compose.yaml create mode 100644 config/layouts/home.json create mode 100644 config/layouts/task-detail.json create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py create mode 100644 tests/test_repository.py create mode 100644 需求记录.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23eaed5 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# 推荐生产环境使用 PBKDF2 哈希:python -c "from app.auth import make_password_hash; print(make_password_hash('change-me'))" +PPM_PASSWORD_HASH= +# 本地快速启动可用明文环境变量,生产环境请改用上面的哈希 +PPM_PASSWORD=change-me +PPM_PORT=8000 +PPM_COOKIE_SECURE=false + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c71b26a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +data/* +!data/.gitkeep +__pycache__/ +.pytest_cache/ +.env +*.pyc + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c0321ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PPM_DATA_DIR=/app/data PPM_LAYOUT_DIR=/app/config/layouts +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +COPY config ./config +COPY data ./data +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')" +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8aadf29 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# 进度台:个人进度监视系统 + +一个面向单用户、单 worker 的中文个人进度面板。任务、每日事实、修订快照和可重建月归档均以 JSON 保存,适合 Docker 部署在服务器上,并通过反向代理公开 HTTPS 访问。 + +## 快速开始 + +本机需要 Python 3.11+。在当前目录执行: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +浏览器打开 。不设置密码时为本机免登录模式;生产环境请在 `.env` 中设置 `PPM_PASSWORD_HASH`,或者临时设置 `PPM_PASSWORD`。 + +## Docker + +```bash +cp .env.example .env +docker compose up -d --build +``` + +默认只绑定 `127.0.0.1:8000`,建议由 Nginx/Caddy 终止 HTTPS 后反代到该端口。只有单副本、单 worker 受支持;JSON 仓储使用进程锁和同目录原子替换,数据卷请纳入主机备份。 + +## 数据与契约 + +`data/tasks/.json` 保存当前任务,`data/revisions//.json` 保存每次属性修订,`data/daily//.json` 保存不可移动的每日事实,`data/archive//.json` 是可重建汇总。任务 ID 和指标 ID 稳定,改名不会破坏历史。 + +`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。`config/layouts/home.json` 与 `task-detail.json` 只允许白名单组件,非法布局会回退到内置默认值。 + +每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。 + +## 测试 + +```bash +pytest -q +``` + +路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。 + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..153ad62 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Personal progress monitor application.""" + diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..27d0a66 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +from dataclasses import dataclass + + +def make_password_hash(password: str, iterations: int = 310_000) -> str: + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), iterations).hex() + return f"pbkdf2_sha256${iterations}${salt}${digest}" + + +def verify_password(password: str, encoded: str) -> bool: + try: + algorithm, iterations_text, salt, expected = encoded.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), int(iterations_text) + ).hex() + return hmac.compare_digest(digest, expected) + except (ValueError, TypeError): + return False + + +@dataclass +class Session: + csrf: str + + +class SessionManager: + def __init__(self, password_hash: str | None): + self.password_hash = password_hash + self.sessions: dict[str, Session] = {} + + @property + def enabled(self) -> bool: + return bool(self.password_hash) + + @classmethod + def from_environment(cls) -> "SessionManager": + encoded = os.getenv("PPM_PASSWORD_HASH", "").strip() + plain = os.getenv("PPM_PASSWORD", "") + if not encoded and plain: + encoded = make_password_hash(plain) + return cls(encoded or None) + + def login(self, password: str) -> tuple[str, Session] | None: + if not self.password_hash or not verify_password(password, self.password_hash): + return None + session_id = secrets.token_urlsafe(32) + session = Session(csrf=secrets.token_urlsafe(32)) + self.sessions[session_id] = session + return session_id, session + + def get(self, session_id: str | None) -> Session | None: + if not session_id: + return None + return self.sessions.get(session_id) + + def logout(self, session_id: str | None) -> None: + if session_id: + self.sessions.pop(session_id, None) + diff --git a/app/layouts.py b/app/layouts.py new file mode 100644 index 0000000..5954aa1 --- /dev/null +++ b/app/layouts.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + + +ALLOWED_COMPONENTS = { + "task-list", + "overview-summary", + "task-progress-grid", + "task-header", + "today-record", + "metric-progress", + "activity-heatmap", + "task-settings", +} + +FALLBACKS = { + "home": { + "schemaVersion": 1, + "page": "home", + "regions": { + "sidebar": [{"component": "task-list"}], + "main": [{"component": "overview-summary"}, {"component": "task-progress-grid"}], + }, + }, + "task-detail": { + "schemaVersion": 1, + "page": "task-detail", + "regions": { + "sidebar": [{"component": "task-list"}], + "main": [ + {"component": "task-header"}, + {"component": "today-record"}, + {"component": "metric-progress"}, + {"component": "activity-heatmap"}, + {"component": "task-settings"}, + ], + }, + }, +} + + +def validate_layout(layout: Any, expected_page: str) -> bool: + if not isinstance(layout, dict): + return False + if layout.get("schemaVersion") != 1 or layout.get("page") != expected_page: + return False + regions = layout.get("regions") + if not isinstance(regions, dict) or set(regions) - {"sidebar", "main"}: + return False + for items in regions.values(): + if not isinstance(items, list): + return False + for item in items: + if not isinstance(item, dict) or set(item) - {"component", "options"}: + return False + if item.get("component") not in ALLOWED_COMPONENTS: + return False + if "options" in item and not isinstance(item["options"], dict): + return False + return True + + +def load_layout(layout_dir: Path, page: str) -> dict[str, Any]: + if page not in FALLBACKS: + raise KeyError(page) + try: + with (layout_dir / f"{page}.json").open("r", encoding="utf-8") as handle: + layout = json.load(handle) + if validate_layout(layout, page): + return layout + except (OSError, json.JSONDecodeError): + pass + return deepcopy(FALLBACKS[page]) + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..53897f4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import asyncio +import os +import secrets +from contextlib import asynccontextmanager +from datetime import date, datetime, time, timedelta +from pathlib import Path +from typing import Annotated + +from fastapi import FastAPI, Query, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from .auth import SessionManager +from .layouts import load_layout +from .models import DailyRecordInput, LoginInput, PurgeInput, ReorderInput, TaskInput, TaskUpdate +from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} + + +def _cookie_secure() -> bool: + return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} + + +def _set_session_cookie(response: JSONResponse, session_id: str) -> None: + response.set_cookie( + "ppm_session", + session_id, + httponly=True, + secure=_cookie_secure(), + samesite="strict", + max_age=60 * 60 * 24 * 30, + ) + + +def _set_csrf_cookie(response: JSONResponse, token: str) -> None: + response.set_cookie( + "ppm_csrf", + token, + httponly=False, + secure=_cookie_secure(), + samesite="strict", + max_age=60 * 60 * 24 * 30, + ) + + +async def _archive_worker(repository: JsonRepository) -> None: + while True: + now = datetime.now(SHANGHAI) + tomorrow = now.date() + timedelta(days=1) + next_run = datetime.combine(tomorrow, time(hour=0, minute=0, second=2), SHANGHAI) + await asyncio.sleep(max((next_run - now).total_seconds(), 1)) + repository.close_missed_days() + + +def create_app( + data_dir: Path | None = None, + layout_dir: Path | None = None, + sessions: SessionManager | None = None, +) -> FastAPI: + repository = JsonRepository(data_dir or Path(os.getenv("PPM_DATA_DIR", PROJECT_ROOT / "data"))) + layouts = layout_dir or Path(os.getenv("PPM_LAYOUT_DIR", PROJECT_ROOT / "config" / "layouts")) + session_manager = sessions or SessionManager.from_environment() + + @asynccontextmanager + async def lifespan(_: FastAPI): + repository.close_missed_days() + worker = asyncio.create_task(_archive_worker(repository)) + yield + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + + app = FastAPI(title="个人进度监视系统", version="0.1.0", lifespan=lifespan) + app.state.repository = repository + app.state.sessions = session_manager + app.mount("/static", StaticFiles(directory=PROJECT_ROOT / "app" / "static"), name="static") + + @app.exception_handler(NotFoundError) + async def handle_not_found(_: Request, exc: NotFoundError) -> JSONResponse: + return JSONResponse({"detail": str(exc)}, status_code=404) + + @app.exception_handler(ConflictError) + async def handle_conflict(_: Request, exc: ConflictError) -> JSONResponse: + return JSONResponse({"detail": str(exc)}, status_code=409) + + @app.exception_handler(ValidationError) + async def handle_validation(_: Request, exc: ValidationError) -> JSONResponse: + return JSONResponse({"detail": str(exc)}, status_code=422) + + @app.middleware("http") + async def security_middleware(request: Request, call_next): + path = request.url.path + public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} + if path.startswith("/api/") and not public: + if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")): + return JSONResponse({"detail": "需要登录"}, status_code=401) + if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login": + submitted = request.headers.get("X-CSRF-Token", "") + if session_manager.enabled: + session = session_manager.get(request.cookies.get("ppm_session")) + expected = session.csrf if session else "" + else: + expected = "" + if session_manager.enabled and (not expected or not secrets.compare_digest(submitted, expected)): + return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403) + return await call_next(request) + + @app.get("/", include_in_schema=False) + async def index() -> FileResponse: + return FileResponse(PROJECT_ROOT / "app" / "static" / "index.html") + + @app.get("/api/health") + async def health() -> dict: + return {"status": "ok", "singleWorker": True} + + @app.get("/api/auth/status") + async def auth_status(request: Request) -> JSONResponse: + if session_manager.enabled: + session = session_manager.get(request.cookies.get("ppm_session")) + body = {"enabled": True, "authenticated": bool(session), "csrfToken": session.csrf if session else None} + return JSONResponse(body) + token = request.cookies.get("ppm_csrf") or secrets.token_urlsafe(32) + response = JSONResponse({"enabled": False, "authenticated": True, "csrfToken": token}) + _set_csrf_cookie(response, token) + return response + + @app.post("/api/auth/login") + async def login(payload: LoginInput) -> JSONResponse: + result = session_manager.login(payload.password) + if result is None: + return JSONResponse({"detail": "密码错误"}, status_code=401) + session_id, session = result + response = JSONResponse({"authenticated": True, "csrfToken": session.csrf}) + _set_session_cookie(response, session_id) + _set_csrf_cookie(response, session.csrf) + return response + + @app.post("/api/auth/logout") + async def logout(request: Request) -> JSONResponse: + session_manager.logout(request.cookies.get("ppm_session")) + response = JSONResponse({"authenticated": False}) + response.delete_cookie("ppm_session") + response.delete_cookie("ppm_csrf") + return response + + @app.get("/api/layouts/{page}") + async def get_layout(page: str) -> dict: + if page not in {"home", "task-detail"}: + raise NotFoundError("布局不存在") + return load_layout(layouts, page) + + @app.get("/api/tasks") + async def list_tasks(includeDeleted: bool = False) -> dict: + return repository.list_tasks(includeDeleted) + + @app.post("/api/tasks", status_code=201) + async def create_task(payload: TaskInput) -> dict: + return repository.create_task(payload) + + @app.get("/api/tasks/{task_id}") + async def get_task(task_id: str, includeDeleted: bool = False) -> dict: + return repository.get_task(task_id, includeDeleted) + + @app.put("/api/tasks/{task_id}") + async def update_task(task_id: str, payload: TaskUpdate) -> dict: + task_input = TaskInput.model_validate(payload.model_dump(exclude={"expectedVersion"})) + return repository.update_task(task_id, task_input, payload.expectedVersion) + + @app.delete("/api/tasks/{task_id}") + async def delete_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict: + return repository.soft_delete_task(task_id, expectedVersion) + + @app.post("/api/tasks/{task_id}/restore") + async def restore_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict: + return repository.restore_task(task_id, expectedVersion) + + @app.post("/api/tasks/{task_id}/purge") + async def purge_task(task_id: str, payload: PurgeInput) -> dict: + repository.purge_task(task_id, payload.confirmation) + return {"purged": True, "taskId": task_id} + + @app.put("/api/tasks/reorder/all") + async def reorder_tasks(payload: ReorderInput) -> dict: + return repository.reorder(payload.taskIds, payload.expectedVersion) + + @app.get("/api/tasks/{task_id}/daily/{logical_date}") + async def get_daily(task_id: str, logical_date: date) -> dict: + return repository.get_daily(task_id, logical_date) + + @app.put("/api/tasks/{task_id}/daily/{logical_date}") + async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict: + return repository.upsert_daily(task_id, logical_date, payload) + + @app.get("/api/tasks/{task_id}/history") + async def history( + task_id: str, + start: Annotated[date | None, Query()] = None, + end: Annotated[date | None, Query()] = None, + ) -> dict: + logical_today = datetime.now(SHANGHAI).date() + history_end = end or logical_today + history_start = start or history_end - timedelta(days=364) + return repository.history(task_id, history_start, history_end) + + @app.get("/api/export") + async def export_data() -> JSONResponse: + return JSONResponse( + repository.export_all(), + headers={"Content-Disposition": "attachment; filename=progress-export.json"}, + ) + + @app.post("/api/admin/archive") + async def run_archive() -> dict: + return {"closedDays": repository.close_missed_days()} + + return app + + +app = create_app() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..33804a8 --- /dev/null +++ b/app/models.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class MetricKind(str, Enum): + checkin = "checkin" + quantity = "quantity" + duration = "duration" + + +class MetricPeriod(str, Enum): + day = "day" + week = "week" + month = "month" + lifetime = "lifetime" + + +class MetricInput(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + metricId: str | None = None + name: Annotated[str, Field(min_length=1, max_length=60)] + kind: MetricKind + period: MetricPeriod = MetricPeriod.day + target: Annotated[int | None, Field(ge=1)] = None + unit: Annotated[str, Field(max_length=20)] = "" + + @model_validator(mode="after") + def normalize_metric(self) -> "MetricInput": + if self.kind == MetricKind.checkin: + self.target = 1 if self.target is None else self.target + self.unit = self.unit or "次" + elif self.kind == MetricKind.duration: + self.unit = "秒" + return self + + +class TaskInput(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + name: Annotated[str, Field(min_length=1, max_length=120)] + description: Annotated[str, Field(max_length=2000)] = "" + startDate: date + endDate: date | None = None + priority: Annotated[int, Field(ge=1, le=5)] = 3 + state: Literal["active", "paused"] = "active" + metrics: Annotated[list[MetricInput], Field(min_length=1, max_length=20)] + + @model_validator(mode="after") + def validate_dates_and_metrics(self) -> "TaskInput": + if self.endDate and self.endDate < self.startDate: + raise ValueError("结束日期不能早于开始日期") + ids = [metric.metricId for metric in self.metrics if metric.metricId] + if len(ids) != len(set(ids)): + raise ValueError("metricId 不能重复") + return self + + +class TaskUpdate(TaskInput): + expectedVersion: Annotated[int, Field(ge=1)] + + +class ReorderInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + taskIds: list[str] + expectedVersion: Annotated[int, Field(ge=1)] + + @field_validator("taskIds") + @classmethod + def unique_task_ids(cls, value: list[str]) -> list[str]: + if len(value) != len(set(value)): + raise ValueError("taskIds 不能重复") + return value + + +class MetricValue(BaseModel): + model_config = ConfigDict(extra="forbid") + + metricId: str + value: Annotated[int, Field(ge=0)] + + +class DailyRecordInput(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + expectedVersion: Annotated[int | None, Field(ge=1)] = None + note: Annotated[str, Field(max_length=4000)] = "" + completedAt: datetime | None = None + values: list[MetricValue] = Field(default_factory=list) + + @field_validator("values") + @classmethod + def unique_metric_values(cls, value: list[MetricValue]) -> list[MetricValue]: + ids = [item.metricId for item in value] + if len(ids) != len(set(ids)): + raise ValueError("同一指标只能提交一次") + return value + + +class LoginInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + password: Annotated[str, Field(min_length=1, max_length=500)] + + +class PurgeInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + confirmation: str diff --git a/app/repository.py b/app/repository.py new file mode 100644 index 0000000..1cd0458 --- /dev/null +++ b/app/repository.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import json +import os +import tempfile +import threading +from copy import deepcopy +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from .models import DailyRecordInput, TaskInput + + +SCHEMA_VERSION = 1 +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +class NotFoundError(Exception): + pass + + +class ConflictError(Exception): + pass + + +class ValidationError(Exception): + pass + + +def now_iso() -> str: + return datetime.now(SHANGHAI).isoformat(timespec="seconds") + + +class JsonRepository: + """Single-process JSON repository with atomic same-directory replacement.""" + + def __init__(self, root: Path): + self.root = Path(root) + self.tasks_dir = self.root / "tasks" + self.revisions_dir = self.root / "revisions" + self.daily_dir = self.root / "daily" + self.archive_dir = self.root / "archive" + self.meta_path = self.root / "meta.json" + self._lock = threading.RLock() + for folder in (self.root, self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir): + folder.mkdir(parents=True, exist_ok=True) + if not self.meta_path.exists(): + yesterday = date.today() - timedelta(days=1) + self._atomic_write(self.meta_path, { + "schemaVersion": SCHEMA_VERSION, + "version": 1, + "taskOrder": [], + "lastClosedDate": yesterday.isoformat(), + }) + + def _read(self, path: Path, default: Any = None) -> Any: + if not path.exists(): + return deepcopy(default) + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + def _atomic_write(self, path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + if os.name != "nt": + dir_fd = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + finally: + if os.path.exists(temp_name): + os.unlink(temp_name) + + def _meta(self) -> dict[str, Any]: + return self._read(self.meta_path) + + def _task_path(self, task_id: str) -> Path: + return self.tasks_dir / f"{task_id}.json" + + def _daily_path(self, logical_date: date) -> Path: + return self.daily_dir / str(logical_date.year) / f"{logical_date.isoformat()}.json" + + def _load_task(self, task_id: str) -> dict[str, Any]: + task = self._read(self._task_path(task_id)) + if task is None: + raise NotFoundError("任务不存在") + return task + + def list_tasks(self, include_deleted: bool = False) -> dict[str, Any]: + with self._lock: + meta = self._meta() + tasks = [] + for path in self.tasks_dir.glob("*.json"): + task = self._read(path) + if include_deleted or not task.get("deletedAt"): + tasks.append(task) + positions = {task_id: index for index, task_id in enumerate(meta["taskOrder"])} + tasks.sort(key=lambda item: (positions.get(item["taskId"], 10**9), item["createdAt"])) + return {"version": meta["version"], "items": tasks} + + def get_task(self, task_id: str, include_deleted: bool = False) -> dict[str, Any]: + with self._lock: + task = self._load_task(task_id) + if task.get("deletedAt") and not include_deleted: + raise NotFoundError("任务不存在") + return task + + def _metric_dicts(self, payload: TaskInput, old: dict[str, Any] | None = None) -> list[dict[str, Any]]: + old_ids = {item["metricId"] for item in (old or {}).get("metrics", [])} + result = [] + seen = set() + for metric in payload.metrics: + metric_data = metric.model_dump(mode="json") + metric_id = metric_data.get("metricId") + if not metric_id: + metric_id = str(uuid4()) + elif old is not None and metric_id not in old_ids: + raise ValidationError("不能指定不属于该任务的 metricId") + if metric_id in seen: + raise ValidationError("metricId 不能重复") + seen.add(metric_id) + metric_data["metricId"] = metric_id + result.append(metric_data) + return result + + def _write_revision(self, task: dict[str, Any]) -> None: + path = self.revisions_dir / task["taskId"] / f"{task['revision']}.json" + self._atomic_write(path, task) + + def create_task(self, payload: TaskInput) -> dict[str, Any]: + with self._lock: + task_id = str(uuid4()) + timestamp = now_iso() + task = { + "schemaVersion": SCHEMA_VERSION, + "taskId": task_id, + "version": 1, + "revision": 1, + **payload.model_dump(mode="json", exclude={"metrics"}), + "metrics": self._metric_dicts(payload), + "createdAt": timestamp, + "updatedAt": timestamp, + "deletedAt": None, + } + self._atomic_write(self._task_path(task_id), task) + self._write_revision(task) + meta = self._meta() + meta["taskOrder"].append(task_id) + meta["version"] += 1 + self._atomic_write(self.meta_path, meta) + return task + + def update_task(self, task_id: str, payload: TaskInput, expected_version: int) -> dict[str, Any]: + with self._lock: + old = self._load_task(task_id) + if old.get("deletedAt"): + raise NotFoundError("任务已删除") + if old["version"] != expected_version: + raise ConflictError("任务已被更新,请刷新后重试") + timestamp = now_iso() + task = { + **old, + **payload.model_dump(mode="json", exclude={"metrics"}), + "metrics": self._metric_dicts(payload, old), + "version": old["version"] + 1, + "revision": old["revision"] + 1, + "updatedAt": timestamp, + } + self._atomic_write(self._task_path(task_id), task) + self._write_revision(task) + return task + + def _change_deleted(self, task_id: str, expected_version: int, restore: bool) -> dict[str, Any]: + task = self._load_task(task_id) + if task["version"] != expected_version: + raise ConflictError("任务已被更新,请刷新后重试") + if restore and not task.get("deletedAt"): + return task + if not restore and task.get("deletedAt"): + return task + task["deletedAt"] = None if restore else now_iso() + task["version"] += 1 + task["revision"] += 1 + task["updatedAt"] = now_iso() + self._atomic_write(self._task_path(task_id), task) + self._write_revision(task) + return task + + def soft_delete_task(self, task_id: str, expected_version: int) -> dict[str, Any]: + with self._lock: + return self._change_deleted(task_id, expected_version, False) + + def restore_task(self, task_id: str, expected_version: int) -> dict[str, Any]: + with self._lock: + return self._change_deleted(task_id, expected_version, True) + + def purge_task(self, task_id: str, confirmation: str) -> None: + with self._lock: + task = self._load_task(task_id) + if not task.get("deletedAt"): + raise ValidationError("永久删除前必须先放入回收站") + if confirmation != task_id: + raise ValidationError("确认内容必须是 taskId") + self._task_path(task_id).unlink() + revision_folder = self.revisions_dir / task_id + for path in revision_folder.glob("*.json"): + path.unlink() + if revision_folder.exists(): + revision_folder.rmdir() + touched_months: set[tuple[int, int]] = set() + for path in self.daily_dir.glob("*/*.json"): + daily = self._read(path) + if task_id in daily.get("records", {}): + del daily["records"][task_id] + daily["version"] += 1 + self._atomic_write(path, daily) + day = date.fromisoformat(daily["date"]) + touched_months.add((day.year, day.month)) + meta = self._meta() + meta["taskOrder"] = [item for item in meta["taskOrder"] if item != task_id] + meta["version"] += 1 + self._atomic_write(self.meta_path, meta) + for year, month in touched_months: + self._rebuild_month(year, month) + + def reorder(self, task_ids: list[str], expected_version: int) -> dict[str, Any]: + with self._lock: + meta = self._meta() + if meta["version"] != expected_version: + raise ConflictError("排序已被更新,请刷新后重试") + active_ids = {item["taskId"] for item in self.list_tasks()["items"]} + if set(task_ids) != active_ids: + raise ValidationError("排序列表必须包含全部未删除任务") + deleted_ids = [item for item in meta["taskOrder"] if item not in active_ids] + meta["taskOrder"] = task_ids + deleted_ids + meta["version"] += 1 + self._atomic_write(self.meta_path, meta) + return {"version": meta["version"], "taskIds": task_ids} + + def get_daily(self, task_id: str, logical_date: date) -> dict[str, Any]: + with self._lock: + self._load_task(task_id) + daily = self._read(self._daily_path(logical_date), { + "schemaVersion": SCHEMA_VERSION, + "date": logical_date.isoformat(), + "version": 1, + "closedAt": None, + "records": {}, + }) + return daily["records"].get(task_id, { + "taskId": task_id, + "taskRevision": self._load_task(task_id)["revision"], + "version": 0, + "note": "", + "completedAt": None, + "values": {}, + }) + + def upsert_daily(self, task_id: str, logical_date: date, payload: DailyRecordInput) -> dict[str, Any]: + with self._lock: + task = self._load_task(task_id) + if task.get("deletedAt"): + raise NotFoundError("任务已删除") + path = self._daily_path(logical_date) + daily = self._read(path, { + "schemaVersion": SCHEMA_VERSION, + "date": logical_date.isoformat(), + "version": 1, + "closedAt": None, + "records": {}, + }) + old = daily["records"].get(task_id) + current_version = old["version"] if old else 0 + if payload.expectedVersion is not None and payload.expectedVersion != current_version: + raise ConflictError("当日记录已被更新,请刷新后重试") + metric_map = {metric["metricId"]: metric for metric in task["metrics"]} + values: dict[str, int] = {} + for item in payload.values: + metric = metric_map.get(item.metricId) + if metric is None: + raise ValidationError("记录包含未知指标") + if metric["kind"] == "checkin" and item.value not in (0, 1): + raise ValidationError("打卡指标只能是 0 或 1") + values[item.metricId] = item.value + record = { + "taskId": task_id, + "taskRevision": task["revision"], + "version": current_version + 1, + "note": payload.note, + "completedAt": payload.completedAt.isoformat() if payload.completedAt else None, + "values": values, + "updatedAt": now_iso(), + } + daily["records"][task_id] = record + daily["version"] += 1 + self._atomic_write(path, daily) + if logical_date < datetime.now(SHANGHAI).date(): + if not daily.get("closedAt"): + daily["closedAt"] = now_iso() + self._atomic_write(path, daily) + self._rebuild_month(logical_date.year, logical_date.month) + return record + + def history(self, task_id: str, start: date, end: date) -> dict[str, Any]: + with self._lock: + self._load_task(task_id) + if end < start or (end - start).days > 730: + raise ValidationError("历史查询范围无效或超过 730 天") + records = [] + cursor = start + while cursor <= end: + daily = self._read(self._daily_path(cursor)) + if daily and task_id in daily.get("records", {}): + records.append({"date": cursor.isoformat(), **daily["records"][task_id]}) + cursor += timedelta(days=1) + revisions = [self._read(path) for path in sorted((self.revisions_dir / task_id).glob("*.json"), key=lambda p: int(p.stem))] + return {"taskId": task_id, "records": records, "revisions": revisions} + + def _rebuild_month(self, year: int, month: int) -> dict[str, Any]: + summaries: dict[str, Any] = {} + folder = self.daily_dir / str(year) + for path in sorted(folder.glob(f"{year:04d}-{month:02d}-*.json")): + daily = self._read(path) + if not daily.get("closedAt"): + continue + for task_id, record in daily.get("records", {}).items(): + task_summary = summaries.setdefault(task_id, {"days": 0, "completedDays": 0, "metricTotals": {}}) + task_summary["days"] += 1 + if record.get("completedAt"): + task_summary["completedDays"] += 1 + for metric_id, value in record.get("values", {}).items(): + task_summary["metricTotals"][metric_id] = task_summary["metricTotals"].get(metric_id, 0) + value + archive = { + "schemaVersion": SCHEMA_VERSION, + "month": f"{year:04d}-{month:02d}", + "rebuiltAt": now_iso(), + "tasks": summaries, + } + self._atomic_write(self.archive_dir / str(year) / f"{year:04d}-{month:02d}.json", archive) + return archive + + def close_day(self, logical_date: date) -> None: + with self._lock: + path = self._daily_path(logical_date) + daily = self._read(path, { + "schemaVersion": SCHEMA_VERSION, + "date": logical_date.isoformat(), + "version": 1, + "closedAt": None, + "records": {}, + }) + if not daily.get("closedAt"): + daily["closedAt"] = now_iso() + daily["version"] += 1 + self._atomic_write(path, daily) + self._rebuild_month(logical_date.year, logical_date.month) + + def close_missed_days(self, today: date | None = None) -> int: + with self._lock: + today = today or datetime.now(SHANGHAI).date() + meta = self._meta() + cursor = date.fromisoformat(meta["lastClosedDate"]) + timedelta(days=1) + last = today - timedelta(days=1) + count = 0 + while cursor <= last: + self.close_day(cursor) + meta["lastClosedDate"] = cursor.isoformat() + cursor += timedelta(days=1) + count += 1 + if count: + meta["version"] += 1 + self._atomic_write(self.meta_path, meta) + return count + + def export_all(self) -> dict[str, Any]: + with self._lock: + tasks = [self._read(path) for path in self.tasks_dir.glob("*.json")] + revisions = [self._read(path) for path in self.revisions_dir.glob("*/*.json")] + daily = [self._read(path) for path in self.daily_dir.glob("*/*.json")] + archives = [self._read(path) for path in self.archive_dir.glob("*/*.json")] + return { + "schemaVersion": SCHEMA_VERSION, + "exportedAt": now_iso(), + "meta": self._meta(), + "tasks": tasks, + "revisions": revisions, + "daily": daily, + "archives": archives, + } + diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..07038c6 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,143 @@ +const state = { + tasks: [], version: 1, selectedId: null, selectedTask: null, + daily: null, history: null, layout: null, csrf: "", authEnabled: false, + filter: "", editingId: null, dragId: null, +}; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()); +const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char])); +const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" }; +const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" }; + +async function api(path, options = {}) { + const opts = { ...options, headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) } }; + if (!opts.method || !["GET", "HEAD"].includes(opts.method)) opts.headers["X-CSRF-Token"] = state.csrf; + const response = await fetch(path, opts); + let body = null; + try { body = await response.json(); } catch { body = {}; } + if (response.status === 401 && state.authEnabled) { showLogin(); throw new Error(body.detail || "需要登录"); } + if (!response.ok) throw new Error(body.detail || `请求失败 (${response.status})`); + return body; +} + +function toast(message) { + const node = $("#toast"); node.textContent = message; node.classList.add("show"); + window.clearTimeout(toast.timer); toast.timer = window.setTimeout(() => node.classList.remove("show"), 2600); +} + +function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute("aria-busy", "true"); } +function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); } + +async function bootstrap() { + try { + const auth = await api("/api/auth/status"); + state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || ""; + if (auth.enabled && !auth.authenticated) { showLogin(); return; } + hideLogin(); + await loadLayout(); await loadTasks(); + } catch (error) { toast(error.message); } +} + +async function loadLayout() { state.layout = await api("/api/layouts/home"); } +async function loadTasks() { + const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version; + if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null; + await render(); +} + +function renderSidebar() { + const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase())); + const items = filtered.map((task) => `
+ ${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标⠿
`).join(""); + $("#sidebar").innerHTML = `

WORKSPACE

我的任务

${state.tasks.length} 项
${items || `
还没有任务
从右上角新建一项计划
`}
`; + $("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); }); + $("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar(); +} + +function bindSidebar() { + $$(".task-item").forEach((node) => { + node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); }); + node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); }); + node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; }); + node.addEventListener("dragover", (event) => event.preventDefault()); + node.addEventListener("drop", async (event) => { event.preventDefault(); if (!state.dragId || state.dragId === node.dataset.taskId) return; await reorder(state.dragId, node.dataset.taskId); }); + }); +} + +async function reorder(fromId, toId) { + const ids = state.tasks.map((task) => task.taskId); const from = ids.indexOf(fromId); const to = ids.indexOf(toId); + ids.splice(from, 1); ids.splice(to, 0, fromId); + try { await api("/api/tasks/reorder/all", { method: "PUT", body: JSON.stringify({ taskIds: ids, expectedVersion: state.version }) }); toast("排序已保存"); await loadTasks(); } + catch (error) { toast(error.message); } +} + +function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); } +function percent(value, target) { return target ? Math.min(100, Math.round(value / target * 100)) : value ? 100 : 0; } +function metricTotal(metricId) { return (state.history?.records || []).reduce((sum, record) => sum + Number(record.values?.[metricId] || 0), 0); } + +async function render() { + renderSidebar(); + if (!state.selectedId) { renderHome(); return; } + state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId); + try { [state.daily, state.history] = await Promise.all([api(`/api/tasks/${state.selectedId}/daily/${today()}`), api(`/api/tasks/${state.selectedId}/history`)]); } + catch (error) { toast(error.message); return; } + renderDetail(); +} + +function renderHome() { + const active = state.tasks.filter((task) => !task.deletedAt); const completed = active.filter((task) => task.state === "active").length; + $("#main").innerHTML = `

OVERVIEW · ${today()}

把注意力放回正在发生的事

所有计划、记录和节奏,都在这里保持可见。

${active.length}进行中的任务
${completed}活跃计划
${active.filter((task) => task.priority <= 2).length}高优先级

任务进度

点击任务查看详情
${active.map((task) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}
`).join("") || `
还没有计划,从右上角开始添加。
`}
`; + $$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); })); +} + +function taskProgress(task) { + const values = task.metrics.map((metric) => ({ value: metricTotal(metric.metricId), target: metric.target })).filter((item) => item.target); + if (!values.length) return 0; return Math.round(values.reduce((sum, item) => sum + percent(item.value, item.target), 0) / values.length); +} + +function renderDetail() { + const task = state.selectedTask; const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0); + $("#main").innerHTML = `

TASK DETAIL · 优先级 ${task.priority}

${escapeHtml(task.name)}

${escapeHtml(task.description || "还没有写下说明")}

${task.state === "paused" ? "已挂起" : "进行中"}
${renderTodayRecord(task, completedToday)}
${renderInsight(task)}
${renderSettings(task)}
`; + $$(".tab").forEach((tab) => tab.addEventListener("click", () => { $$(".tab").forEach((item) => item.classList.toggle("active", item === tab)); $$(".tab-panel").forEach((panel) => panel.hidden = panel.dataset.panel !== tab.dataset.tab); })); + $("#editTaskButton").addEventListener("click", () => openTaskDialog(task)); bindRecordForm(task); bindSettings(task); +} + +function renderTodayRecord(task, completedToday) { + const fields = task.metrics.map((metric) => { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return ``; return ``; }).join(""); + return `

今天,${today()}

只记录今天真实完成的部分
${completedToday ? "已有记录" : "尚未记录"}
${fields}
修改会保留任务历史版本
`; +} + +function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; } +function bindRecordForm(task) { + $("#saveDailyButton").addEventListener("click", async () => { + const values = $$(`[data-metric-id]`).map((node) => ({ metricId: node.dataset.metricId, value: node.type === "checkbox" ? (node.checked ? 1 : 0) : Math.max(0, Number(node.value || 0)) })); + const completedAt = $("#completedAt").value; try { await api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#dailyNote").value, completedAt: completedAt ? new Date(completedAt).toISOString() : null, values }) }); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); } + }); +} + +function renderInsight(task) { + const cards = task.metrics.map((metric) => { const total = metricTotal(metric.metricId); const target = metric.target; return `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${total}${target ? ` / ${target}` : ""}
${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}
`; }).join(""); + const heat = Array.from({ length: 364 }, (_, index) => { const day = new Date(); day.setDate(day.getDate() - (363 - index)); const iso = day.toISOString().slice(0, 10); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return ``; }).join(""); + return `

指标总览

基于每日事实计算
${cards}

活动热力图

过去 12 个月
${heat}
少多
`; +} + +function renderSettings(task) { return `

危险操作

默认保留历史,可从后台彻底清理
`; } +function bindSettings(task) { $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); } + +function openTaskDialog(task = null) { + state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务"; + form.startDate.value = task?.startDate || today(); form.endDate.value = task?.endDate || ""; form.name.value = task?.name || ""; form.description.value = task?.description || ""; form.priority.value = task?.priority || 3; form.state.value = task?.state || "active"; + renderMetricEditor(task?.metrics || [{ name: "完成", kind: "checkin", period: "day", target: 1, unit: "次" }]); $("#taskDialog").showModal(); +} +function renderMetricEditor(metrics) { $("#metricEditor").innerHTML = metrics.map((metric, index) => `
`).join(""); $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); } + +$("#addMetricButton").addEventListener("click", () => { const metrics = $$(".metric-row").map((row) => Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value]))); metrics.push({ name: "新指标", kind: "quantity", period: "day", target: "", unit: "次" }); renderMetricEditor(metrics); }); +$("#taskForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const metrics = $$(".metric-row").map((row) => { const result = Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value])); result.metricId = row.dataset.metricId || null; result.target = result.target ? Number(result.target) : null; return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } }); + +$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); }); +$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `
${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `

回收站为空

`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); }); +$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); }); +$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadLayout(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } }); +bootstrap(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..1d70ef3 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,69 @@ + + + + + + + 进度台 + + + +
+
+ +
+ + +
+
+
+
+
+
+
+ + +
+
+

任务定义

新建任务

+ +
+
+ + + + + + +
+

衡量标准

多个指标可同时记录

+
+
+
+
+ + +

后台

数据管理

+
+

回收站

+
+
+ +
+
+ P +

PERSONAL PROGRESS

+

登录进度台

+ +

+ +
+
+ +
+ + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..4745749 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,127 @@ +:root { + --ink: #1d2520; + --muted: #667169; + --line: #dce3dc; + --paper: #f6f8f5; + --surface: #ffffff; + --green: #176b4b; + --green-soft: #e1f0e7; + --yellow: #d9a526; + --red: #b4473d; + --blue: #386d9d; + font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; + color: var(--ink); + background: var(--paper); + letter-spacing: 0; +} +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; background: var(--paper); } +button, input, textarea, select { font: inherit; letter-spacing: 0; } +button { cursor: pointer; } +.app-shell { min-height: 100vh; } +.topbar { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.96); position: sticky; top: 0; z-index: 10; } +.brand-button { display: flex; align-items: center; gap: 10px; border: 0; background: transparent; color: var(--ink); text-align: left; padding: 4px; } +.brand-button strong { display: block; font-size: 17px; } +.brand-button small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; } +.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; background: var(--ink); color: white; border-radius: 6px; font-family: Georgia, serif; font-weight: 700; } +.brand-mark.large { width: 48px; height: 48px; font-size: 22px; margin-bottom: 26px; } +.top-actions, .admin-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.button, .icon-button { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 8px 13px; } +.button:hover, .icon-button:hover { border-color: #aab5ad; background: #f8faf8; } +.button.primary { background: var(--green); border-color: var(--green); color: white; } +.button.subtle { background: var(--green-soft); border-color: transparent; color: var(--green); } +.button.danger { color: var(--red); border-color: #e8c8c4; } +.icon-button { width: 38px; padding: 0; font-size: 20px; display: grid; place-items: center; } +.workspace { display: grid; grid-template-columns: minmax(300px, 360px) 1fr; min-height: calc(100vh - 68px); } +.sidebar { background: var(--surface); border-right: 1px solid var(--line); padding: 18px 14px; overflow: auto; } +.sidebar-heading { display: flex; justify-content: space-between; align-items: flex-end; padding: 0 7px 12px; } +.sidebar-heading h2 { font-size: 15px; margin: 0; } +.sidebar-heading span { color: var(--muted); font-size: 12px; } +.filter-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin: 0 6px 14px; } +input, textarea, select { width: 100%; color: var(--ink); background: white; border: 1px solid #cfd8d0; border-radius: 5px; padding: 9px 10px; outline: none; } +input:focus, textarea:focus, select:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,75,.12); } +label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #455149; } +.task-list { display: grid; gap: 5px; } +.task-item { min-height: 72px; display: grid; grid-template-columns: 5px 1fr auto; gap: 10px; align-items: center; padding: 10px 9px 10px 5px; border: 1px solid transparent; border-radius: 6px; background: transparent; cursor: pointer; } +.task-item:hover { background: #f4f7f4; } +.task-item.selected { background: var(--green-soft); border-color: #bad6c5; } +.priority-bar { width: 4px; align-self: stretch; border-radius: 3px; background: var(--blue); } +.priority-1 { background: var(--red); }.priority-2 { background: var(--yellow); }.priority-3 { background: var(--green); }.priority-4 { background: var(--blue); }.priority-5 { background: #9ba39d; } +.task-copy { min-width: 0; } +.task-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } +.task-copy small { display: block; margin-top: 6px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.drag-handle { color: #9aa49c; font-size: 18px; cursor: grab; padding: 10px 2px; } +.task-item.dragging { opacity: .45; } +.empty-list { color: var(--muted); padding: 32px 12px; text-align: center; font-size: 13px; } +.main-content { padding: 28px clamp(20px, 4vw, 52px) 60px; min-width: 0; } +.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; margin-bottom: 26px; } +.page-heading h1 { margin: 3px 0 7px; font-size: 28px; font-weight: 680; } +.page-heading p { margin: 0; color: var(--muted); } +.eyebrow { color: var(--green); font-size: 10px; line-height: 1.2; font-weight: 800; margin: 0; text-transform: uppercase; } +.summary-strip { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 18px 0 30px; } +.summary-cell { padding: 19px 20px; border-right: 1px solid var(--line); } +.summary-cell:first-child { padding-left: 0; }.summary-cell:last-child { border: 0; } +.summary-cell strong { display: block; font-size: 25px; }.summary-cell span { color: var(--muted); font-size: 12px; } +.section { padding: 24px 0; border-bottom: 1px solid var(--line); } +.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 16px; } +.section-heading h2 { margin: 0; font-size: 17px; }.section-heading span { font-size: 12px; color: var(--muted); } +.overview-list { display: grid; gap: 12px; } +.overview-row { display: grid; grid-template-columns: minmax(160px, 1.4fr) 2fr auto; gap: 16px; align-items: center; padding: 13px 0; cursor: pointer; } +.overview-row:hover strong { color: var(--green); } +.overview-name strong, .overview-name small { display: block; }.overview-name small { color: var(--muted); margin-top: 4px; } +.progress-track { height: 7px; background: #e5eae5; overflow: hidden; border-radius: 4px; } +.progress-fill { height: 100%; background: var(--green); border-radius: inherit; } +.muted { color: var(--muted); }.nowrap { white-space: nowrap; } +.status-badge { display: inline-flex; align-items: center; min-height: 25px; padding: 3px 8px; border-radius: 4px; background: var(--green-soft); color: var(--green); font-size: 11px; font-weight: 700; } +.status-badge.paused { background: #ecefed; color: #6d756f; } +.tabs { display: flex; gap: 3px; border-bottom: 1px solid var(--line); margin-top: 22px; } +.tab { border: 0; border-bottom: 2px solid transparent; padding: 10px 14px; background: transparent; color: var(--muted); } +.tab.active { color: var(--green); border-bottom-color: var(--green); font-weight: 700; } +.tab-panel[hidden] { display: none; } +.record-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; } +.record-field { padding: 15px; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; } +.record-field.check { display: flex; align-items: center; gap: 11px; } +.record-field.check input { width: 19px; height: 19px; } +.record-field.wide { grid-column: 1 / -1; } +.record-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; } +.metric-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +.metric-card { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 17px; } +.metric-card-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 13px; } +.metric-card strong { font-size: 14px; }.metric-card small { color: var(--muted); } +.metric-value { font-size: 22px; margin: 10px 0 4px; font-weight: 700; } +.heatmap-wrap { overflow-x: auto; padding-bottom: 8px; } +.heatmap { display: grid; grid-template-rows: repeat(7, 11px); grid-auto-flow: column; grid-auto-columns: 11px; gap: 3px; width: max-content; min-height: 95px; } +.heat-cell { width: 11px; height: 11px; background: #e3e8e3; border-radius: 2px; } +.heat-cell.level-1 { background: #b9dbc6; }.heat-cell.level-2 { background: #70b18a; }.heat-cell.level-3 { background: #2d855f; }.heat-cell.level-4 { background: #12583d; } +.heat-legend { display: flex; gap: 4px; align-items: center; justify-content: flex-end; font-size: 11px; color: var(--muted); margin-top: 8px; } +.heat-legend i { width: 11px; height: 11px; border-radius: 2px; background: #e3e8e3; } +.detail-empty { min-height: 60vh; display: grid; place-items: center; text-align: center; color: var(--muted); } +.dialog { width: min(760px, calc(100vw - 28px)); max-height: calc(100vh - 40px); border: 1px solid var(--line); border-radius: 8px; padding: 24px; color: var(--ink); box-shadow: 0 20px 60px rgba(23,33,26,.22); } +.dialog::backdrop { background: rgba(24,31,26,.42); } +.dialog-heading, .metric-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 20px; } +.dialog-heading h2, .metric-heading h3 { margin: 3px 0 0; font-size: 20px; }.metric-heading h3 { font-size: 15px; } +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }.form-grid .wide { grid-column: 1 / -1; } +.metric-heading { margin: 25px 0 10px; align-items: center; } +.metric-editor { display: grid; gap: 8px; } +.metric-row { display: grid; grid-template-columns: 1.5fr 1fr 1fr .8fr .8fr 38px; gap: 7px; align-items: end; padding: 10px; border: 1px solid var(--line); border-radius: 6px; } +.metric-row label { font-size: 10px; }.remove-metric { margin-bottom: 0; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; } +.admin-dialog { width: min(660px, calc(100vw - 28px)); }.admin-actions { margin-bottom: 30px; } +.trash-list { display: grid; gap: 8px; }.trash-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--line); padding: 12px 0; }.trash-item-actions { display: flex; gap: 6px; } +.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; } +.login-overlay[hidden] { display: none; }.login-panel { width: min(380px, 100%); background: white; border: 1px solid var(--line); padding: 38px; border-radius: 8px; }.login-panel h1 { margin: 6px 0 28px; font-size: 25px; }.wide-button { width: 100%; margin-top: 16px; }.form-error { color: var(--red); min-height: 18px; font-size: 12px; } +.toast { position: fixed; right: 22px; bottom: 22px; z-index: 200; max-width: min(360px, calc(100vw - 44px)); background: var(--ink); color: white; border-radius: 6px; padding: 11px 15px; transform: translateY(100px); opacity: 0; transition: .2s ease; }.toast.show { transform: translateY(0); opacity: 1; } +@media (max-width: 820px) { + .topbar { padding: 0 12px; }.button.primary { padding-inline: 10px; } + .workspace { display: block; }.sidebar { border-right: 0; border-bottom: 1px solid var(--line); max-height: 41vh; }.main-content { padding: 22px 16px 50px; } + .summary-strip { grid-template-columns: repeat(3, 1fr); }.summary-cell { padding: 15px 10px; }.summary-cell strong { font-size: 20px; } + .overview-row { grid-template-columns: 1fr auto; }.overview-row .progress-track { grid-column: 1 / -1; grid-row: 2; } + .metric-row { grid-template-columns: repeat(2, 1fr); }.remove-metric { grid-column: 2; justify-self: end; } +} +@media (max-width: 520px) { + .brand-button small { display: none; }.top-actions .primary { font-size: 0; width: 42px; }.top-actions .primary::first-letter { font-size: 18px; } + .page-heading h1 { font-size: 23px; }.summary-strip { grid-template-columns: 1fr; }.summary-cell, .summary-cell:first-child { padding: 12px 0; border-right: 0; border-bottom: 1px solid var(--line); } + .record-grid, .form-grid { grid-template-columns: 1fr; }.form-grid .wide { grid-column: auto; }.record-field.wide { grid-column: auto; } + .dialog { padding: 18px; }.metric-row { grid-template-columns: 1fr; }.remove-metric { grid-column: auto; justify-self: stretch; width: 100%; } + .trash-item { align-items: flex-start; flex-direction: column; } +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e18ec24 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,24 @@ +services: + progress-monitor: + build: . + restart: unless-stopped + ports: + - "127.0.0.1:${PPM_PORT:-8000}:8000" + environment: + PPM_DATA_DIR: /app/data + PPM_LAYOUT_DIR: /app/config/layouts + PPM_PASSWORD_HASH: ${PPM_PASSWORD_HASH:-} + PPM_PASSWORD: ${PPM_PASSWORD:-} + PPM_COOKIE_SECURE: ${PPM_COOKIE_SECURE:-false} + volumes: + - ppm-data:/app/data + - ./config/layouts:/app/config/layouts:ro + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + +volumes: + ppm-data: + diff --git a/config/layouts/home.json b/config/layouts/home.json new file mode 100644 index 0000000..e5d1c18 --- /dev/null +++ b/config/layouts/home.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "page": "home", + "regions": { + "sidebar": [ + { "component": "task-list" } + ], + "main": [ + { "component": "overview-summary" }, + { "component": "task-progress-grid", "options": { "historyDays": 365 } } + ] + } +} diff --git a/config/layouts/task-detail.json b/config/layouts/task-detail.json new file mode 100644 index 0000000..50955c8 --- /dev/null +++ b/config/layouts/task-detail.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "page": "task-detail", + "regions": { + "sidebar": [ + { "component": "task-list" } + ], + "main": [ + { "component": "task-header" }, + { "component": "today-record" }, + { "component": "metric-progress" }, + { "component": "activity-heatmap", "options": { "historyDays": 365 } }, + { "component": "task-settings" } + ] + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6050974 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.136.3 +uvicorn[standard]==0.48.0 +pydantic==2.13.2 +pytest==8.4.2 +httpx==0.28.1 + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a696744 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.auth import SessionManager, make_password_hash +from app.main import create_app + + +@pytest.fixture() +def client(tmp_path: Path): + app = create_app(tmp_path / "data", Path(__file__).parents[1] / "config" / "layouts", SessionManager(None)) + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture() +def auth_client(tmp_path: Path): + manager = SessionManager(make_password_hash("secret")) + app = create_app(tmp_path / "data", Path(__file__).parents[1] / "config" / "layouts", manager) + with TestClient(app) as test_client: + yield test_client + diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e86253a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,31 @@ +from datetime import date + + +def payload(name="API 任务"): + return {"name": name, "startDate": "2026-08-01", "priority": 3, "metrics": [{"name": "完成", "kind": "checkin", "period": "day"}]} + + +def test_api_crud_order_layout_and_daily(client): + response = client.get("/api/health"); assert response.status_code == 200 + assert client.get("/api/layouts/home").json()["page"] == "home" + first = client.post("/api/tasks", json=payload("第一项")); assert first.status_code == 201 + second = client.post("/api/tasks", json=payload("第二项")).json() + task = first.json(); metric_id = task["metrics"][0]["metricId"] + listed = client.get("/api/tasks").json(); assert len(listed["items"]) == 2 + reordered = client.put("/api/tasks/reorder/all", json={"taskIds": [second["taskId"], task["taskId"]], "expectedVersion": listed["version"]}) + assert reordered.status_code == 200 + saved = client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"values": [{"metricId": metric_id, "value": 1}], "note": "完成"}) + assert saved.status_code == 200 + assert client.get(f"/api/tasks/{task['taskId']}/history?start=2026-08-20&end=2026-08-20").json()["records"] + + +def test_invalid_input_and_auth(auth_client): + assert auth_client.get("/api/tasks").status_code == 401 + login = auth_client.post("/api/auth/login", json={"password": "secret"}); assert login.status_code == 200 + assert auth_client.get("/api/tasks").status_code == 200 + csrf = login.json()["csrfToken"] + bad = auth_client.post("/api/tasks", json={"name": "", "startDate": "2026-08-01", "metrics": []}, headers={"X-CSRF-Token": csrf}) + assert bad.status_code == 422 + forbidden = auth_client.post("/api/tasks", json=payload()) + assert forbidden.status_code == 403 + diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..868660e --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,48 @@ +from datetime import date, timedelta + +from app.models import DailyRecordInput, MetricValue, TaskInput +from app.repository import ConflictError, JsonRepository + + +def task_payload(): + return TaskInput.model_validate({ + "name": "学习计划", "startDate": "2026-08-01", "priority": 2, + "metrics": [ + {"name": "打卡", "kind": "checkin", "period": "day"}, + {"name": "刷题", "kind": "quantity", "period": "lifetime", "target": 100, "unit": "题"}, + {"name": "专注", "kind": "duration", "period": "day", "target": 3600}, + ], + }) + + +def test_crud_revision_soft_delete_and_purge(tmp_path): + repo = JsonRepository(tmp_path) + task = repo.create_task(task_payload()) + assert len(task["metrics"]) == 3 + updated = repo.update_task(task["taskId"], task_payload().model_copy(update={"name": "改名后的计划"}), task["version"]) + assert updated["taskId"] == task["taskId"] and updated["revision"] == 2 + assert repo.history(task["taskId"], date(2026, 8, 1), date(2026, 8, 1))["revisions"] + deleted = repo.soft_delete_task(task["taskId"], updated["version"]) + assert repo.list_tasks()["items"] == [] + repo.restore_task(task["taskId"], deleted["version"]) + repo.soft_delete_task(task["taskId"], deleted["version"] + 1) + repo.purge_task(task["taskId"], task["taskId"]) + assert repo.list_tasks(include_deleted=True)["items"] == [] + + +def test_daily_mixed_metrics_history_and_idempotent_archive(tmp_path): + repo = JsonRepository(tmp_path) + task = repo.create_task(task_payload()) + values = [MetricValue(metricId=item["metricId"], value=1 if item["kind"] == "checkin" else 120) for item in task["metrics"]] + record = repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(note="不错", values=values)) + assert record["version"] == 1 + repo.close_day(date(2026, 8, 10)); repo.close_day(date(2026, 8, 10)) + history = repo.history(task["taskId"], date(2026, 8, 10), date(2026, 8, 10)) + assert history["records"][0]["note"] == "不错" + try: + repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(expectedVersion=0, values=[])) + except ConflictError: + pass + else: + raise AssertionError("expected version conflict") + diff --git a/需求记录.md b/需求记录.md new file mode 100644 index 0000000..61003ea --- /dev/null +++ b/需求记录.md @@ -0,0 +1,22 @@ +# 个人进度监视系统需求记录 + +## 已冻结的 MVP + +- 服务器可部署、可远程访问;Docker Compose 默认绑定本机端口,生产使用反向代理和 HTTPS。 +- JSON 是主存储契约,不把运行数据提交到 Git;单 worker/单副本。 +- 分层 JSON:任务当前定义、任务修订、每日事实、可重建月归档;原始事实不因归档被移动或删除。 +- 任务使用稳定 UUID;默认软删除;改名或修改属性保留 `taskRevision`,后台支持恢复和确认后永久删除。 +- `metrics[]` 可并存 `checkin`、`quantity`、`duration`;周期 `day/week/month/lifetime`;`target: null` 表示无上限;duration 使用整数秒。 +- 创建、编辑、删除、恢复、排序;字段包括名称、起止日期、优先级 1-5、状态和描述。 +- 每日记录支持一键打卡、数量、时长、心得和完成时间;修改记录带版本冲突检测。 +- 首页与任务详情分别读取独立布局 JSON;布局组件白名单校验,禁止动态执行。 +- 中文响应式 UI:左侧任务列表和拖放排序,右侧任务详情、热力图、多指标进度和今日记录。 +- 可选单用户密码会话认证、HttpOnly 会话 Cookie、基本 CSRF 校验。 + +## 后续路线图 + +- 备份/恢复向导与定期快照。 +- 多 worker、多副本的分布式锁和更强一致性压力测试。 +- 自定义布局编辑器、更多图表和提醒通知。 +- 细化周/月目标的跨周期统计与时区偏好设置。 +