feat: scaffold personal progress monitor MVP
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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
|
||||
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.env
|
||||
*.pyc
|
||||
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
42
README.md
Normal file
42
README.md
Normal file
@@ -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
|
||||
```
|
||||
|
||||
浏览器打开 <http://127.0.0.1:8000>。不设置密码时为本机免登录模式;生产环境请在 `.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/<taskId>.json` 保存当前任务,`data/revisions/<taskId>/<revision>.json` 保存每次属性修订,`data/daily/<year>/<date>.json` 保存不可移动的每日事实,`data/archive/<year>/<month>.json` 是可重建汇总。任务 ID 和指标 ID 稳定,改名不会破坏历史。
|
||||
|
||||
`metrics[]` 可同时含 `checkin`、`quantity`、`duration`;周期支持 `day`、`week`、`month`、`lifetime`;`target: null` 表示无上限,时长统一用整数秒。`config/layouts/home.json` 与 `task-detail.json` 只允许白名单组件,非法布局会回退到内置默认值。
|
||||
|
||||
每日 0 点只关闭事实并重建归档,不删除原始记录。启动时会补跑遗漏日期;补录历史会触发对应月份重建。所有写接口带版本号冲突检测,任务删除默认为软删,后台可恢复或二次确认永久删除。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
```
|
||||
|
||||
路线图:备份恢复向导、多副本协调、跨进程压力测试和更丰富布局编辑器。
|
||||
|
||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Personal progress monitor application."""
|
||||
|
||||
67
app/auth.py
Normal file
67
app/auth.py
Normal file
@@ -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)
|
||||
|
||||
78
app/layouts.py
Normal file
78
app/layouts.py
Normal file
@@ -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])
|
||||
|
||||
226
app/main.py
Normal file
226
app/main.py
Normal file
@@ -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()
|
||||
115
app/models.py
Normal file
115
app/models.py
Normal file
@@ -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
|
||||
401
app/repository.py
Normal file
401
app/repository.py
Normal file
@@ -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,
|
||||
}
|
||||
|
||||
143
app/static/app.js
Normal file
143
app/static/app.js
Normal file
@@ -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) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
|
||||
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
|
||||
$("#sidebar").innerHTML = `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
|
||||
$("#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 = `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${completed}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div><section class="section"><div class="section-heading"><h2>任务进度</h2><span>点击任务查看详情</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task)}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
||||
$$(".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 = `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div><div class="tabs"><button class="tab active" data-tab="record">今日记录</button><button class="tab" data-tab="insight">完成情况</button><button class="tab" data-tab="settings">任务设置</button></div><div class="tab-panel" data-panel="record">${renderTodayRecord(task, completedToday)}</div><div class="tab-panel" data-panel="insight" hidden>${renderInsight(task)}</div><div class="tab-panel" data-panel="settings" hidden>${renderSettings(task)}</div>`;
|
||||
$$(".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 `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`; return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || (metric.kind === "duration" ? "秒" : "次")} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`; }).join("");
|
||||
return `<section class="section"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions"><span class="muted">修改会保留任务历史版本</span><button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
|
||||
}
|
||||
|
||||
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 `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${total}${target ? ` <small>/ ${target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${percent(total, target)}%"></div></div><small class="muted">${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}</small></div>`; }).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 `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
|
||||
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section><section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 12 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
|
||||
}
|
||||
|
||||
function renderSettings(task) { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
|
||||
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) => `<div class="metric-row" data-index="${index}" data-metric-id="${escapeHtml(metric.metricId || "")}"><label>名称<input data-field="name" value="${escapeHtml(metric.name)}" maxlength="60" required></label><label>类型<select data-field="kind"><option value="checkin" ${metric.kind === "checkin" ? "selected" : ""}>打卡</option><option value="quantity" ${metric.kind === "quantity" ? "selected" : ""}>数量</option><option value="duration" ${metric.kind === "duration" ? "selected" : ""}>时长(秒)</option></select></label><label>周期<select data-field="period"><option value="day" ${metric.period === "day" ? "selected" : ""}>每日</option><option value="week" ${metric.period === "week" ? "selected" : ""}>每周</option><option value="month" ${metric.period === "month" ? "selected" : ""}>每月</option><option value="lifetime" ${metric.period === "lifetime" ? "selected" : ""}>总计</option></select></label><label>目标<input data-field="target" type="number" min="1" placeholder="无上限" value="${metric.target ?? ""}"></label><label>单位<input data-field="unit" maxlength="20" value="${escapeHtml(metric.unit || "")}" placeholder="次"></label><button type="button" class="icon-button remove-metric" data-remove="${index}" title="删除指标" aria-label="删除指标">×</button></div>`).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) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#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();
|
||||
69
app/static/index.html
Normal file
69
app/static/index.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>进度台</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="app-shell" aria-busy="true">
|
||||
<header class="topbar">
|
||||
<button class="brand-button" id="homeButton" type="button" title="返回总览">
|
||||
<span class="brand-mark" aria-hidden="true">P</span>
|
||||
<span><strong>进度台</strong><small id="logicalDate"></small></span>
|
||||
</button>
|
||||
<div class="top-actions">
|
||||
<button class="button primary" id="addTaskButton" type="button">+ 新建任务</button>
|
||||
<button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="workspace">
|
||||
<aside id="sidebar" class="sidebar"></aside>
|
||||
<main id="main" class="main-content"></main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="taskDialog" class="dialog task-dialog">
|
||||
<form id="taskForm" method="dialog">
|
||||
<div class="dialog-heading">
|
||||
<div><p class="eyebrow">任务定义</p><h2 id="taskDialogTitle">新建任务</h2></div>
|
||||
<button class="icon-button" value="cancel" formmethod="dialog" title="关闭" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="wide">任务名称<input name="name" required maxlength="120"></label>
|
||||
<label>开始日期<input name="startDate" type="date" required></label>
|
||||
<label>结束日期<input name="endDate" type="date"></label>
|
||||
<label>优先级<select name="priority"><option value="1">1 · 紧急</option><option value="2">2 · 高</option><option value="3" selected>3 · 正常</option><option value="4">4 · 低</option><option value="5">5 · 挂起候选</option></select></label>
|
||||
<label>状态<select name="state"><option value="active">进行中</option><option value="paused">已挂起</option></select></label>
|
||||
<label class="wide">说明<textarea name="description" maxlength="2000" rows="3"></textarea></label>
|
||||
</div>
|
||||
<div class="metric-heading"><div><p class="eyebrow">衡量标准</p><h3>多个指标可同时记录</h3></div><button class="button subtle" id="addMetricButton" type="button">+ 添加指标</button></div>
|
||||
<div id="metricEditor" class="metric-editor"></div>
|
||||
<div class="dialog-actions"><button class="button" value="cancel" formmethod="dialog">取消</button><button class="button primary" id="saveTaskButton" type="submit">保存任务</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="adminDialog" class="dialog admin-dialog">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">后台</p><h2>数据管理</h2></div><button class="icon-button" id="closeAdminButton" title="关闭" aria-label="关闭">×</button></div>
|
||||
<div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>
|
||||
<h3>回收站</h3>
|
||||
<div id="trashList" class="trash-list"></div>
|
||||
</dialog>
|
||||
|
||||
<div id="loginOverlay" class="login-overlay" hidden>
|
||||
<form id="loginForm" class="login-panel">
|
||||
<span class="brand-mark large" aria-hidden="true">P</span>
|
||||
<p class="eyebrow">PERSONAL PROGRESS</p>
|
||||
<h1>登录进度台</h1>
|
||||
<label>访问密码<input name="password" type="password" autocomplete="current-password" required autofocus></label>
|
||||
<p id="loginError" class="form-error" role="alert"></p>
|
||||
<button class="button primary wide-button" type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
127
app/static/styles.css
Normal file
127
app/static/styles.css
Normal file
@@ -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; }
|
||||
}
|
||||
24
compose.yaml
Normal file
24
compose.yaml
Normal file
@@ -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:
|
||||
|
||||
13
config/layouts/home.json
Normal file
13
config/layouts/home.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"page": "home",
|
||||
"regions": {
|
||||
"sidebar": [
|
||||
{ "component": "task-list" }
|
||||
],
|
||||
"main": [
|
||||
{ "component": "overview-summary" },
|
||||
{ "component": "task-progress-grid", "options": { "historyDays": 365 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
16
config/layouts/task-detail.json
Normal file
16
config/layouts/task-detail.json
Normal file
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -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
|
||||
|
||||
23
tests/conftest.py
Normal file
23
tests/conftest.py
Normal file
@@ -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
|
||||
|
||||
31
tests/test_api.py
Normal file
31
tests/test_api.py
Normal file
@@ -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
|
||||
|
||||
48
tests/test_repository.py
Normal file
48
tests/test_repository.py
Normal file
@@ -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")
|
||||
|
||||
22
需求记录.md
Normal file
22
需求记录.md
Normal file
@@ -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、多副本的分布式锁和更强一致性压力测试。
|
||||
- 自定义布局编辑器、更多图表和提醒通知。
|
||||
- 细化周/月目标的跨周期统计与时区偏好设置。
|
||||
|
||||
Reference in New Issue
Block a user