feat: scaffold personal progress monitor MVP

This commit is contained in:
MobKBK
2026-08-24 06:18:21 +08:00
commit 094e03f341
21 changed files with 1480 additions and 0 deletions

2
app/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Personal progress monitor application."""

67
app/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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
View 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
View 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; }
}