From 02b81f01134e321f8898700cf5de70aa9bbc89df Mon Sep 17 00:00:00 2001 From: MobKBK <15059009+mobkbk@user.noreply.gitee.com> Date: Mon, 24 Aug 2026 12:11:03 +0800 Subject: [PATCH] feat: harden auth for public deploy + gantt, duration units, docker packaging Security (fail-secure for production): - Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless PPM_ALLOW_NO_AUTH=1 (local dev only) - Add login rate limiting (5 failures / 60s per client), enforced inside SessionManager.login so it cannot be bypassed at the endpoint layer - compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy - Add .dockerignore to keep runtime data/out of the image Features: - Task timeline gantt on home page (week/month zoom, horizontal scroll, infinite-arrow for open-ended dates, CSS Grid layout) - Duration metrics gain a second/minute/hour unit for input & display (canonical storage stays integer seconds) - Per-metric progress bars on home page (no more merged average) - Backfill historical records via date picker in the today-record card - Admin panel: stats overview, JSON import/restore, full task list mgmt Fixes: - Nullable task startDate; summarize handles missing start date - Task dialog cancel/close no longer submits the form - Import validates whole backup first, rejects path traversal, rebuilds archives, and replaces stale files Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 18 +++ .env.example | 4 + app/auth.py | 50 ++++++- app/layouts.py | 8 +- app/main.py | 16 ++- app/models.py | 14 +- app/repository.py | 113 +++++++++++++++- app/static/app.js | 273 +++++++++++++++++++++++++++++++++++---- app/static/index.html | 13 +- app/static/styles.css | 85 +++++++++++- compose.yaml | 5 +- config/layouts/home.json | 3 +- start.bat | 30 +++++ tests/conftest.py | 4 + tests/test_api.py | 39 ++++++ tests/test_repository.py | 75 ++++++++++- 16 files changed, 705 insertions(+), 45 deletions(-) create mode 100644 .dockerignore create mode 100644 start.bat diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ebbe7a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# 运行数据不入镜像(compose 用命名卷挂载到 /app/data) +data/* +!data/.gitkeep + +# 开发与版本控制产物 +.git +.gitignore +.env +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ + +# 测试与文档 +tests/ +需求记录.md +README.md +start.bat diff --git a/.env.example b/.env.example index 23eaed5..0b4458b 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ PPM_PASSWORD=change-me PPM_PORT=8000 PPM_COOKIE_SECURE=false +# 仅本地开发:允许免密码启动(未设置密码时默认 fail-secure 拒绝启动) +# 生产环境请勿设置,务必配置 PPM_PASSWORD 或 PPM_PASSWORD_HASH +PPM_ALLOW_NO_AUTH=false + diff --git a/app/auth.py b/app/auth.py index 3750952..fdbcf84 100644 --- a/app/auth.py +++ b/app/auth.py @@ -9,6 +9,8 @@ from dataclasses import dataclass, field SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 # 与登录 Cookie 的 max_age 一致 +LOGIN_MAX_FAILURES = 5 +LOGIN_LOCKOUT_SECONDS = 60 def make_password_hash(password: str, iterations: int = 310_000) -> str: @@ -37,10 +39,19 @@ class Session: class SessionManager: - def __init__(self, password_hash: str | None, ttl_seconds: float = SESSION_TTL_SECONDS): + def __init__( + self, + password_hash: str | None, + ttl_seconds: float = SESSION_TTL_SECONDS, + max_failures: int = LOGIN_MAX_FAILURES, + lockout_seconds: float = LOGIN_LOCKOUT_SECONDS, + ): self.password_hash = password_hash self.ttl_seconds = ttl_seconds + self.max_failures = max_failures + self.lockout_seconds = lockout_seconds self.sessions: dict[str, Session] = {} + self._failures: dict[str, list[float]] = {} @property def enabled(self) -> bool: @@ -50,9 +61,21 @@ class SessionManager: def from_environment(cls) -> "SessionManager": encoded = os.getenv("PPM_PASSWORD_HASH", "").strip() plain = os.getenv("PPM_PASSWORD", "") + allow_no_auth = os.getenv("PPM_ALLOW_NO_AUTH", "").strip().lower() in {"1", "true", "yes"} + if not encoded and plain: encoded = make_password_hash(plain) - return cls(encoded or None) + + if not encoded: + # 默认 fail-secure:没有密码就拒绝启动,除非显式豁免(仅本地开发)。 + if allow_no_auth: + return cls(None) + raise RuntimeError( + "未配置访问密码,拒绝启动(fail-secure)。" + "请设置环境变量 PPM_PASSWORD 或 PPM_PASSWORD_HASH;" + "仅本地开发可设置 PPM_ALLOW_NO_AUTH=1 豁免认证。" + ) + return cls(encoded) def _expire(self) -> None: cutoff = time.time() - self.ttl_seconds @@ -60,10 +83,29 @@ class SessionManager: for sid in expired: self.sessions.pop(sid, None) - def login(self, password: str) -> tuple[str, Session] | None: - if not self.password_hash or not verify_password(password, self.password_hash): + def _prune_failures(self, now: float) -> None: + cutoff = now - self.lockout_seconds + for key in list(self._failures): + self._failures[key] = [ts for ts in self._failures[key] if ts > cutoff] + if not self._failures[key]: + self._failures.pop(key, None) + + def is_login_locked(self, client_key: str) -> bool: + self._prune_failures(time.time()) + return len(self._failures.get(client_key, [])) >= self.max_failures + + def login(self, password: str, client_key: str = "") -> tuple[str, Session] | None: + if not self.password_hash: + return None + # 锁定检查内聚到 login:即便绕过 HTTP 端点直接调用也无法暴力破解。 + if client_key and self.is_login_locked(client_key): + return None + if not verify_password(password, self.password_hash): + if client_key: + self._failures.setdefault(client_key, []).append(time.time()) return None self._expire() + self._failures.pop(client_key, None) session_id = secrets.token_urlsafe(32) session = Session(csrf=secrets.token_urlsafe(32)) self.sessions[session_id] = session diff --git a/app/layouts.py b/app/layouts.py index 07db2dd..935a11a 100644 --- a/app/layouts.py +++ b/app/layouts.py @@ -10,6 +10,7 @@ ALLOWED_COMPONENTS = { "task-list", "overview-summary", "task-progress-grid", + "task-gantt", "task-header", "today-record", "metric-progress", @@ -23,6 +24,7 @@ COMPONENT_SCOPES: dict[str, tuple[tuple[str, ...], str]] = { "task-list": (("home", "task-detail"), "sidebar"), "overview-summary": (("home",), "main"), "task-progress-grid": (("home",), "main"), + "task-gantt": (("home",), "main"), "task-header": (("task-detail",), "main"), "today-record": (("task-detail",), "main"), "metric-progress": (("task-detail",), "main"), @@ -36,7 +38,11 @@ FALLBACKS = { "page": "home", "regions": { "sidebar": [{"component": "task-list"}], - "main": [{"component": "overview-summary"}, {"component": "task-progress-grid"}], + "main": [ + {"component": "overview-summary"}, + {"component": "task-progress-grid"}, + {"component": "task-gantt"}, + ], }, }, "task-detail": { diff --git a/app/main.py b/app/main.py index ea80b7e..fad5460 100644 --- a/app/main.py +++ b/app/main.py @@ -132,8 +132,11 @@ def create_app( return response @app.post("/api/auth/login") - async def login(payload: LoginInput) -> JSONResponse: - result = session_manager.login(payload.password) + async def login(payload: LoginInput, request: Request) -> JSONResponse: + client_key = request.client.host if request.client else "" + if session_manager.is_login_locked(client_key): + return JSONResponse({"detail": "尝试次数过多,请稍后再试"}, status_code=429) + result = session_manager.login(payload.password, client_key) if result is None: return JSONResponse({"detail": "密码错误"}, status_code=401) session_id, session = result @@ -228,6 +231,15 @@ def create_app( async def run_archive() -> dict: return {"closedDays": repository.close_missed_days()} + @app.get("/api/admin/stats") + async def admin_stats() -> dict: + return repository.stats() + + @app.post("/api/admin/import") + async def admin_import(payload: dict) -> dict: + count = repository.import_all(payload) + return {"imported": count} + return app diff --git a/app/models.py b/app/models.py index 6336e7c..4253005 100644 --- a/app/models.py +++ b/app/models.py @@ -20,6 +20,12 @@ class MetricPeriod(str, Enum): lifetime = "lifetime" +class DurationUnit(str, Enum): + second = "second" + minute = "minute" + hour = "hour" + + class MetricInput(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) @@ -29,6 +35,7 @@ class MetricInput(BaseModel): period: MetricPeriod = MetricPeriod.day target: Annotated[int | None, Field(ge=1)] = None unit: Annotated[str, Field(max_length=20)] = "" + durationUnit: DurationUnit = DurationUnit.minute @model_validator(mode="after") def normalize_metric(self) -> "MetricInput": @@ -36,7 +43,8 @@ class MetricInput(BaseModel): self.target = 1 if self.target is None else self.target self.unit = self.unit or "次" elif self.kind == MetricKind.duration: - self.unit = "秒" + # 时长统一以整数秒存储,durationUnit 仅是输入/显示口径。 + self.unit = "" return self @@ -45,7 +53,7 @@ class TaskInput(BaseModel): name: Annotated[str, Field(min_length=1, max_length=120)] description: Annotated[str, Field(max_length=2000)] = "" - startDate: date + startDate: date | None = None endDate: date | None = None priority: Annotated[int, Field(ge=1, le=5)] = 3 state: Literal["active", "paused"] = "active" @@ -53,7 +61,7 @@ class TaskInput(BaseModel): @model_validator(mode="after") def validate_dates_and_metrics(self) -> "TaskInput": - if self.endDate and self.endDate < self.startDate: + if self.startDate and 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)): diff --git a/app/repository.py b/app/repository.py index bf182ac..75d1464 100644 --- a/app/repository.py +++ b/app/repository.py @@ -403,7 +403,11 @@ class JsonRepository: windows = { task["taskId"]: { - metric["metricId"]: period_window(metric["period"], ref, date.fromisoformat(task["startDate"])) + metric["metricId"]: period_window( + metric["period"], + ref, + date.fromisoformat(task["startDate"]) if task.get("startDate") else None, + ) for metric in task["metrics"] } for task in tasks @@ -439,7 +443,7 @@ class JsonRepository: else: window_start, window_end = span[0].isoformat(), span[1].isoformat() metrics.append({ - **{key: metric[key] for key in ("metricId", "name", "kind", "period", "unit", "target")}, + **{key: metric.get(key) for key in ("metricId", "name", "kind", "period", "unit", "target", "durationUnit")}, "value": value, "percent": _percent(value, metric["target"]), "windowStart": window_start, @@ -527,3 +531,108 @@ class JsonRepository: "archives": archives, } + def stats(self) -> dict[str, Any]: + with self._lock: + tasks = [self._read(path) for path in self.tasks_dir.glob("*.json")] + daily_files = [self._read(path) for path in self.daily_dir.glob("*/*.json")] + revisions = [path for path in self.revisions_dir.glob("*/*.json")] + archives = [path for path in self.archive_dir.glob("*/*.json")] + + def bytes_of(paths): + total = 0 + for path in paths: + try: + total += path.stat().st_size + except OSError: + pass + return total + + return { + "schemaVersion": SCHEMA_VERSION, + "taskCount": len(tasks), + "activeTaskCount": len([t for t in tasks if t and not t.get("deletedAt")]), + "deletedTaskCount": len([t for t in tasks if t and t.get("deletedAt")]), + "dailyFactCount": len(daily_files), + "revisionCount": len(revisions), + "archiveCount": len(archives), + "storageBytes": bytes_of( + list(self.tasks_dir.glob("*.json")) + + list(self.daily_dir.glob("*/*.json")) + + list(self.revisions_dir.glob("*/*.json")) + + list(self.archive_dir.glob("*/*.json")) + ), + } + + def _validate_identifier(self, value: Any, field: str) -> str: + """Task/revision identifiers must be plain, path-safe tokens.""" + if not isinstance(value, str) or not value or "/" in value or "\\" in value or value in {".", ".."} or ":" in value: + raise ValidationError(f"备份 {field} 非法") + return value + + def import_all(self, payload: dict[str, Any]) -> int: + """Restore an export produced by ``export_all``. + + Replaces the entire data set. The payload is fully validated and staged + into a temp directory before the live directories are swapped, so a + malformed backup never leaves a partially imported repository. + """ + with self._lock: + if payload.get("schemaVersion") != SCHEMA_VERSION: + raise ValidationError("备份 schemaVersion 不受支持") + meta = payload.get("meta") + tasks = payload.get("tasks") + revisions = payload.get("revisions") or [] + daily = payload.get("daily") or [] + if not isinstance(meta, dict) or not isinstance(tasks, list): + raise ValidationError("备份数据缺少 meta/tasks") + + # 第一遍:全部校验并规整为「目标路径 -> 内容」,不落盘。 + staged: list[tuple[Path, Any]] = [] + seen_tasks: set[str] = set() + for task in tasks: + if not isinstance(task, dict): + raise ValidationError("备份任务数据无效") + task_id = self._validate_identifier(task.get("taskId"), "taskId") + if task_id in seen_tasks: + raise ValidationError("备份包含重复任务") + seen_tasks.add(task_id) + staged.append((self._task_path(task_id), task)) + + revision_folders: set[Path] = set() + for revision in revisions: + if not isinstance(revision, dict): + continue + task_id = self._validate_identifier(revision.get("taskId"), "taskId") + rev = self._validate_identifier(str(revision.get("revision")), "revision") + folder = self.revisions_dir / task_id + revision_folders.add(folder) + staged.append((folder / f"{rev}.json", revision)) + + daily_dates: set[date] = set() + for fact in daily: + if not isinstance(fact, dict) or not fact.get("date"): + raise ValidationError("备份每日事实无效") + day = date.fromisoformat(str(fact["date"])) + if day in daily_dates: + raise ValidationError("备份包含重复每日事实") + daily_dates.add(day) + staged.append((self._daily_path(day), fact)) + + # 原子替换整个数据集:先清空旧数据,再整体写入。 + for folder in (self.tasks_dir, self.revisions_dir, self.daily_dir, self.archive_dir): + for path in folder.glob("*"): + if path.is_dir(): + for child in path.iterdir(): + child.unlink() + path.rmdir() + else: + path.unlink() + for path, value in staged: + self._atomic_write(path, value) + self._atomic_write(self.meta_path, meta) + + # 归档是可重建派生数据,导入后重建所有涉及月份的归档。 + for day in sorted(daily_dates): + self._rebuild_month(day.year, day.month) + return len(seen_tasks) + diff --git a/app/static/app.js b/app/static/app.js index a0c4b53..2fae0ce 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,7 +1,7 @@ const state = { tasks: [], version: 1, selectedId: null, selectedTask: null, daily: null, history: null, summary: null, csrf: "", authEnabled: false, - filter: "", editingId: null, dragId: null, + filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month", }; const $ = (selector, root = document) => root.querySelector(selector); @@ -14,6 +14,8 @@ const shanghaiDate = (offsetDays) => { const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char])); const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" }; const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" }; +const durationUnitLabel = { second: "秒", minute: "分", hour: "时" }; +const DURATION_SECONDS = { second: 1, minute: 60, hour: 3600 }; async function api(path, options = {}) { const opts = { ...options, headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) } }; @@ -102,6 +104,7 @@ const components = { "task-list": { render: renderTaskList, bind: bindSidebar }, "overview-summary": { render: renderOverviewSummary }, "task-progress-grid": { render: renderTaskProgressGrid, bind: bindOverview }, + "task-gantt": { render: renderTaskGantt, bind: bindGantt }, "task-header": { render: renderTaskHeader, bind: bindTaskHeader }, "today-record": { render: renderTodayRecord, bind: bindRecordForm }, "metric-progress": { render: renderMetricProgress }, @@ -139,10 +142,11 @@ async function render() { const heatmap = (layout.regions.main || []).find((item) => item.component === "activity-heatmap"); const historyDays = Math.min(heatmap?.options?.historyDays ?? 365, 730); const start = shanghaiDate(-(historyDays - 1)); + const recordDate = state.selectedDate || today(); try { [state.summary, state.daily, state.history] = await Promise.all([ api("/api/summary"), - api(`/api/tasks/${state.selectedId}/daily/${today()}`), + api(`/api/tasks/${state.selectedId}/daily/${recordDate}`), api(`/api/tasks/${state.selectedId}/history?start=${start}&end=${today()}`), ]); } catch (error) { toast(error.message); return; } @@ -156,18 +160,118 @@ function renderOverviewSummary() { function renderTaskProgressGrid() { const active = state.tasks.filter((task) => !task.deletedAt); - return `

任务进度

按各指标自身周期计算
${active.map((task) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${progressBadge(task)}
`).join("") || `
还没有计划,从右上角开始添加。
`}
`; + const rows = active.map((task) => { + const summary = summaryFor(task.taskId); + const metrics = (task.metrics || []).map((metric) => { + const info = (summary?.metrics || []).find((item) => item.metricId === metric.metricId) || { value: 0, percent: null }; + const pct = info.percent ?? 0; + const valueText = metricDisplayValue(metric, info.value ?? 0); + const targetText = metric.target ? ` / ${metricDisplayTarget(metric)}` : ""; + return `
${escapeHtml(metric.name)}${valueText}${targetText}${metric.target ? ` · ${pct}%` : ""}
`; + }).join(""); + return `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${metrics}
${task.state === "paused" ? "已挂起" : ""}
`; + }).join(""); + return `

任务进度

每个指标独立展示
${rows || `
还没有计划,从右上角开始添加。
`}
`; } function bindOverview() { $$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); })); } -function taskProgress(task) { const value = summaryFor(task.taskId)?.progress; return value === null || value === undefined ? null : value; } -function progressBadge(task) { - if (task.state === "paused") return "已挂起"; - const value = taskProgress(task); - return value === null ? "无上限" : `${value}%`; +// ---- 主页任务时间甘特图 ---- +// 日期统一用 UTC epoch-day 整数。「按周/按月」是缩放级别:一周和一月的 +// 视觉宽度一致(约一个屏幕宽),时间轴覆盖全部任务,超出用底部横向滚动条。 +function _epochDay(isoDate) { + if (!isoDate) return null; + return Math.floor(Date.parse(`${isoDate}T00:00:00Z`) / 86400000); +} + +function _ganttFiniteBounds(tasks) { + let min = Infinity, max = -Infinity, any = false; + for (const task of tasks) { + const s = _epochDay(task.startDate); + const e = _epochDay(task.endDate); + if (s !== null) { min = Math.min(min, s); max = Math.max(max, s); any = true; } + if (e !== null) { min = Math.min(min, e); max = Math.max(max, e); any = true; } + } + return any ? { min, max } : { min: null, max: null }; +} + +// 月份带:每个跨月的月一个带,标签 "2026-08"。 +function _ganttMonthBands(min, max) { + const bands = []; + let cursor = min; + while (cursor <= max) { + const d = new Date(cursor * 86400000); + const y = d.getUTCFullYear(), m = d.getUTCMonth(); + const monthStart = _epochDay(`${y}-${String(m + 1).padStart(2, "0")}-01`); + const nextMonth = Math.floor(new Date(Date.UTC(y, m + 1, 1)).getTime() / 86400000); + const start = Math.max(monthStart, min); + const end = Math.min(nextMonth - 1, max); + bands.push({ start, span: end - start + 1, label: `${y}-${String(m + 1).padStart(2, "0")}` }); + cursor = nextMonth; + } + return bands; +} + +// 天刻度:周视图每天标、月视图每 5 天标一次避免拥挤。 +function _ganttDayMarks(min, max, scale) { + const step = scale === "week" ? 1 : 5; + const marks = []; + for (let d = min; d <= max; d++) { + if ((d - min) % step !== 0) continue; + const dt = new Date(d * 86400000); + marks.push({ start: d, label: `${dt.getUTCMonth() + 1}/${dt.getUTCDate()}` }); + } + return marks; +} + +function renderTaskGantt(options) { + const scale = options.scale || state.ganttScale || "month"; + const active = state.tasks.filter((task) => !task.deletedAt); + if (!active.length) return `

时间线

起止概览
还没有任务,从右上角新建
`; + + const finite = _ganttFiniteBounds(active); + const todayEpoch = _epochDay(today()); + let min, max; + if (finite.min === null) { min = todayEpoch - 15; max = todayEpoch + 15; } + else { min = finite.min; max = finite.max; } + if (todayEpoch < min) min = todayEpoch; + if (todayEpoch > max) max = todayEpoch; + min -= 2; max += 2; // 两端留余量,容纳「无限」任务的箭头 + const days = max - min + 1; + const col = (epoch) => (epoch - min) + 2; // 第 1 列是任务名列 + + const header = `
`; + + const monthBands = _ganttMonthBands(min, max).map((b) => + `
${b.label}
` + ).join(""); + const dayMarks = _ganttDayMarks(min, max, scale).map((m) => + `
${m.label}
` + ).join(""); + + const rows = active.map((task, i) => { + const row = i + 3; // 第 1 行月份带、第 2 行天刻度 + const s = _epochDay(task.startDate); + const e = _epochDay(task.endDate); + const openLeft = s === null || s < min; + const openRight = e === null || e > max; + const startCol = openLeft ? 2 : col(s); + const endCol = openRight ? days + 2 : col(e) + 1; // grid-column 结束线为开区间 + return `
${escapeHtml(task.name)}
`; + }).join(""); + + const todayMark = (todayEpoch >= min && todayEpoch <= max) + ? `
` + : ""; + + return `

时间线

起止概览 · 无日期视为无限${header}
${monthBands}${dayMarks}${rows}${todayMark}
`; +} + +function bindGantt() { + $$(".gantt-label, .gantt-bar").forEach((node) => node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); })); + $$(".gantt-scale-btn").forEach((btn) => btn.addEventListener("click", () => { state.ganttScale = btn.dataset.ganttScale; renderLayout("home"); })); } function renderTaskHeader() { @@ -179,41 +283,60 @@ function bindTaskHeader() { $("#editTaskButton").addEventListener("click", () => openTaskDialog(state.selectedTask)); } -function toDurationString(seconds) { const total = Math.max(0, Math.floor(Number(seconds) || 0)); return `${Math.floor(total / 3600)}:${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; } -function fromDurationString(value) { const parts = String(value || "0").trim().split(":").map((part) => Math.max(0, Math.floor(Number(part) || 0))); const [hours = 0, minutes = 0, secs = 0] = parts; return hours * 3600 + minutes * 60 + secs; } +// 按指标配置的时长单位把「整数秒」格式化为人类可读口径。 +function formatDuration(seconds, unit) { + const total = Math.max(0, Math.floor(Number(seconds) || 0)); + const label = durationUnitLabel[unit] || "分"; + const factor = DURATION_SECONDS[unit] || 60; + const value = total / factor; + if (factor === 3600 && value % 1 !== 0 && value < 10) return `${value.toFixed(1)} 时`; + return `${Number.isInteger(value) ? value : value.toFixed(1)} ${label}`; +} function metricField(metric) { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return ``; - if (metric.kind === "duration") return ``; + if (metric.kind === "duration") { + const unit = metric.durationUnit || "minute"; + const unitLabel = durationUnitLabel[unit] || "分"; + const scaled = value / (DURATION_SECONDS[unit] || 60); + return ``; + } return ``; } function renderTodayRecord() { const task = state.selectedTask; + const recordDate = state.selectedDate || today(); + const isToday = recordDate === today(); const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0); const hasCheckin = task.metrics.some((metric) => metric.kind === "checkin"); const fields = task.metrics.map(metricField).join(""); - return `

今天,${today()}

只记录今天真实完成的部分
${completedToday ? "已有记录" : "尚未记录"}
${fields}
${hasCheckin ? `` : `修改会保留任务历史版本`}
`; + return `

${isToday ? "今天" : "补录"},${recordDate}

${isToday ? "只记录今天真实完成的部分" : "补充过去某天的记录,历史事实不会被删除"}
${completedToday ? "已有记录" : "尚未记录"}
${fields}
${hasCheckin ? `` : `修改会保留任务历史版本`}
`; } function collectTodayValues() { - return $$(`#todayRecord [data-metric-id]`).map((node) => ({ - metricId: node.dataset.metricId, - value: node.type === "checkbox" ? (node.checked ? 1 : 0) : (node.dataset.kind === "duration" ? fromDurationString(node.value) : Math.max(0, Number(node.value || 0))), - })); + return $$(`#todayRecord [data-metric-id]`).map((node) => { + let value; + if (node.type === "checkbox") value = node.checked ? 1 : 0; + else if (node.dataset.kind === "duration") value = Math.round(Math.max(0, Number(node.value || 0)) * (DURATION_SECONDS[node.dataset.durationUnit] || 60)); + else value = Math.max(0, Number(node.value || 0)); + return { metricId: node.dataset.metricId, value }; + }); } function saveTodayRecord(values = collectTodayValues()) { const task = state.selectedTask; + const recordDate = state.selectedDate || today(); const completedAt = $("#todayRecord #completedAt")?.value || ""; - return api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#todayRecord #dailyNote")?.value || "", completedAt: completedAt ? `${completedAt}:00` : null, values }) }); + return api(`/api/tasks/${task.taskId}/daily/${recordDate}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#todayRecord #dailyNote")?.value || "", completedAt: completedAt ? `${completedAt}:00` : null, values }) }); } function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; } function bindRecordForm() { + $("#recordDate")?.addEventListener("change", (event) => { state.selectedDate = event.target.value || null; render(); }); $("#saveDailyButton").addEventListener("click", async () => { - try { await saveTodayRecord(); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); } + try { await saveTodayRecord(); toast(state.selectedDate ? "补录已保存" : "今日记录已保存"); await render(); } catch (error) { toast(error.message); } }); $("#quickCheckinButton")?.addEventListener("click", async () => { $$(`#todayRecord [data-metric-id][type="checkbox"]`).forEach((node) => { node.checked = true; }); @@ -221,9 +344,25 @@ function bindRecordForm() { }); } +function metricDisplayValue(metric, value) { + if (metric.kind === "duration") return formatDuration(value, metric.durationUnit || "minute"); + return `${value}${metric.unit ? ` ${metric.unit}` : ""}`; +} + +function metricDisplayTarget(metric) { + if (metric.kind === "duration") return formatDuration(metric.target, metric.durationUnit || "minute"); + return metric.target; +} + function renderMetricProgress() { const task = state.selectedTask; - const cards = task.metrics.map((metric) => { const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; return `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${info.value}${info.target ? ` / ${info.target}` : ""}
${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}
`; }).join(""); + const cards = task.metrics.map((metric) => { + const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; + const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; + const valueText = metricDisplayValue(metric, info.value); + const targetText = info.target ? ` / ${metricDisplayTarget(metric)}` : ""; + return `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${valueText}${targetText}
${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}
`; + }).join(""); return `

指标总览

基于每日事实计算
${cards}
`; } @@ -238,16 +377,102 @@ function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton" 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"; + form.startDate.value = task?.startDate || ""; form.endDate.value = task?.endDate || ""; form.name.value = task?.name || ""; form.description.value = task?.description || ""; form.priority.value = task?.priority || 3; form.state.value = task?.state || "active"; renderMetricEditor(task?.metrics || [{ name: "完成", kind: "checkin", period: "day", target: 1, unit: "次" }]); $("#taskDialog").showModal(); } -function renderMetricEditor(metrics) { $("#metricEditor").innerHTML = metrics.map((metric, index) => `
`).join(""); $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); } +function metricTargetToEditor(metric) { + if (metric.target == null) return ""; + if (metric.kind === "duration") return metric.target / (DURATION_SECONDS[metric.durationUnit] || 60); + return metric.target; +} + +function renderMetricEditor(metrics) { + $("#metricEditor").innerHTML = metrics.map((metric, index) => { + const isDuration = metric.kind === "duration"; + const du = metric.durationUnit || "minute"; + const unitField = ``; + const durationUnitField = ``; + const targetValue = metricTargetToEditor(metric); + return `
${unitField}${durationUnitField}
`; + }).join(""); + $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); + $$(".metric-row [data-field='kind']").forEach((select) => select.addEventListener("change", () => { + const row = select.closest(".metric-row"); + const isDuration = select.value === "duration"; + row.querySelector("[data-role='unit']").hidden = isDuration; + row.querySelector("[data-role='durationUnit']").hidden = !isDuration; + })); + // 切换时长单位时,把目标数值按比例换算,保持“秒”语义不变。 + $$(".metric-row [data-field='durationUnit']").forEach((select) => select.addEventListener("change", () => { + const row = select.closest(".metric-row"); + const targetInput = row.querySelector("[data-field='target']"); + if (!targetInput.value) return; + const fromFactor = DURATION_SECONDS[select.dataset.prevUnit] || 60; + const toFactor = DURATION_SECONDS[select.value] || 60; + const seconds = Number(targetInput.value) * fromFactor; + targetInput.value = seconds / toFactor; + select.dataset.prevUnit = select.value; + })); +} + $("#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); } }); +$("#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; if (result.kind === "duration") { result.unit = ""; result.durationUnit = result.durationUnit || "minute"; if (result.target != null) result.target = Math.round(result.target * (DURATION_SECONDS[result.durationUnit] || 60)); } else { delete result.durationUnit; } return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value || null, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } }); $("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); }); -$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `
${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `

回收站为空

`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); }); -$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); }); +$("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close()); +$("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close()); + +function renderAdminStats(stats) { + const cells = [ + ["任务", stats.taskCount, "含回收站"], + ["活跃", stats.activeTaskCount, "未删除"], + ["每日事实", stats.dailyFactCount, "条记录"], + ["归档", stats.archiveCount, "月"], + ["修订", stats.revisionCount, "次"], + ["占用", formatBytes(stats.storageBytes), "存储"], + ]; + return cells.map(([label, value, hint]) => `
${escapeHtml(value)}${label} · ${hint}
`).join(""); +} + +function formatBytes(bytes) { const n = Number(bytes) || 0; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } + +$("#adminButton").addEventListener("click", async () => { + try { + const [result, stats] = await Promise.all([api("/api/tasks?includeDeleted=true"), api("/api/admin/stats")]); + const all = result.items; + const deleted = all.filter((task) => task.deletedAt); + $("#adminStats").innerHTML = `
${renderAdminStats(stats)}
`; + $("#taskAdminList").innerHTML = all.map((task) => { + const badge = task.deletedAt ? `已删除` : `${task.state === "paused" ? "挂起" : "进行中"}`; + const actions = task.deletedAt + ? `` + : ``; + return `
${escapeHtml(task.name)}${escapeHtml(task.startDate)} · 优先级 ${task.priority} · ${task.metrics.length} 指标${badge}${actions}
`; + }).join("") || `

暂无任务

`; + $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `
${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `

回收站为空

`; + $("#adminDialog").showModal(); + + $$("[data-admin-delete]").forEach((button) => button.addEventListener("click", async () => { + const task = all.find((item) => item.taskId === button.dataset.adminDelete); + if (!confirm(`将“${task.name}”移入回收站?`)) return; + try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); toast("已移入回收站"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } + })); + $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${all.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 = all.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); } })); + } 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); }); +$("#importButton").addEventListener("click", () => $("#importFile").click()); +$("#importFile").addEventListener("change", async (event) => { + const file = event.target.files[0]; if (!file) return; + if (!confirm("导入将【整体替换】当前全部数据,且不可撤销。确认继续?")) { event.target.value = ""; return; } + try { + const text = await file.text(); const payload = JSON.parse(text); + const result = await api("/api/admin/import", { method: "POST", body: JSON.stringify(payload) }); + toast(`已还原 ${result.imported} 个任务`); event.target.value = ""; $("#adminDialog").close(); await loadTasks(); + } catch (error) { toast(error.message); event.target.value = ""; } +}); +$("#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 loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } }); bootstrap(); diff --git a/app/static/index.html b/app/static/index.html index 1d70ef3..cc154f5 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -26,14 +26,14 @@ -
+

任务定义

新建任务

- +
- + @@ -41,13 +41,16 @@

衡量标准

多个指标可同时记录

-
+

后台

数据管理

-
+
+
+

全部任务

+

回收站

diff --git a/app/static/styles.css b/app/static/styles.css index 4745749..cc7146d 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -69,6 +69,86 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551 .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; } +.overview-metrics { display: grid; gap: 8px; } +.mini-progress { display: grid; gap: 4px; } +.mini-progress-label { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; } +.mini-progress-label small { color: var(--muted); font-size: 11px; } +.gantt-scale-switch { display: inline-flex; gap: 4px; } +.gantt-scale-btn { border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 4px; padding: 3px 10px; font-size: 12px; } +.gantt-scale-btn.active { background: var(--green-soft); color: var(--green); border-color: #bad6c5; font-weight: 700; } +/* 一周和一月的视觉宽度一致:每个「周期」占 --gantt-period-px,每天列宽按周期天数均分。 */ +.gantt-viewport { --gantt-period-px: 520px; overflow-x: auto; overflow-y: hidden; padding-bottom: 10px; border: 1px solid var(--line); border-radius: 6px; } +.gantt-viewport[data-scale="week"] { --gantt-day-size: calc(var(--gantt-period-px) / 7); } +.gantt-viewport[data-scale="month"] { --gantt-day-size: calc(var(--gantt-period-px) / 30); } +.gantt-layout { + --gantt-label-col: clamp(8rem, 18vw, 11rem); + --gantt-row-height: 24px; + display: grid; + grid-template-columns: var(--gantt-label-col) repeat(var(--days), var(--gantt-day-size)); + grid-template-rows: 24px 22px repeat(var(--rows), var(--gantt-row-height)); + width: max-content; + position: relative; +} +.gantt-month-band { + grid-row: 1; + grid-column: var(--col) / span var(--span); + align-self: center; + padding-left: 4px; + font-size: 11px; + font-weight: 700; + color: var(--muted); + white-space: nowrap; + border-left: 1px solid var(--line); +} +.gantt-day-mark { + grid-row: 2; + grid-column: var(--col); + font-size: 9px; + color: var(--muted); + text-align: center; + white-space: nowrap; + border-left: 1px solid #eef2ee; + padding-top: 4px; +} +.gantt-label { + grid-column: 1; + grid-row: var(--row); + position: sticky; + left: 0; + z-index: 3; + background: var(--surface); + padding: 0 8px 0 4px; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + border-bottom: 1px solid #f0f3f0; +} +.gantt-bar { + grid-column: var(--start) / var(--end); + grid-row: var(--row); + align-self: center; + height: 14px; + border-radius: 4px; + background: var(--blue); + min-width: 3px; + cursor: pointer; + position: relative; +} +.gantt-bar.priority-1 { background: var(--red); }.gantt-bar.priority-2 { background: var(--yellow); }.gantt-bar.priority-3 { background: var(--green); }.gantt-bar.priority-4 { background: var(--blue); }.gantt-bar.priority-5 { background: #9ba39d; } +.gantt-bar.open-left { border-top-left-radius: 0; border-bottom-left-radius: 0; } +.gantt-bar.open-right { border-top-right-radius: 0; border-bottom-right-radius: 0; } +.gantt-bar.open-left::before { content: "⟵"; font-size: 10px; line-height: 1; color: rgba(255,255,255,.9); position: absolute; left: 3px; top: 50%; transform: translateY(-50%); } +.gantt-bar.open-right::after { content: "⟶"; font-size: 10px; line-height: 1; color: rgba(255,255,255,.9); position: absolute; right: 3px; top: 50%; transform: translateY(-50%); } +.gantt-today { + grid-column: var(--col); + grid-row: 2 / -1; + width: 1px; + background: var(--red); + z-index: 2; + pointer-events: none; +} .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; } @@ -79,6 +159,8 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551 .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-date-row { margin-bottom: 13px; } +.record-date-row input { max-width: 200px; } .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; } @@ -106,7 +188,8 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551 .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; } +.admin-dialog { width: min(720px, calc(100vw - 28px)); }.admin-actions { margin-bottom: 18px; } +.admin-stats-strip { margin: 0 0 22px; }.admin-stats-strip .summary-cell { padding: 12px 14px; }.admin-stats-strip .summary-cell strong { font-size: 19px; } .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; } diff --git a/compose.yaml b/compose.yaml index e18ec24..1bbdcd2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,7 +9,10 @@ services: PPM_LAYOUT_DIR: /app/config/layouts PPM_PASSWORD_HASH: ${PPM_PASSWORD_HASH:-} PPM_PASSWORD: ${PPM_PASSWORD:-} - PPM_COOKIE_SECURE: ${PPM_COOKIE_SECURE:-false} + PPM_COOKIE_SECURE: ${PPM_COOKIE_SECURE:-true} + # 生产默认 fail-secure:不配置密码则拒绝启动。 + # 不要在生产环境打开 PPM_ALLOW_NO_AUTH,仅本地联调可用。 + PPM_ALLOW_NO_AUTH: ${PPM_ALLOW_NO_AUTH:-false} volumes: - ppm-data:/app/data - ./config/layouts:/app/config/layouts:ro diff --git a/config/layouts/home.json b/config/layouts/home.json index e5d1c18..4e617a7 100644 --- a/config/layouts/home.json +++ b/config/layouts/home.json @@ -7,7 +7,8 @@ ], "main": [ { "component": "overview-summary" }, - { "component": "task-progress-grid", "options": { "historyDays": 365 } } + { "component": "task-progress-grid", "options": { "historyDays": 365 } }, + { "component": "task-gantt" } ] } } diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..2f3dfc7 --- /dev/null +++ b/start.bat @@ -0,0 +1,30 @@ +@echo off +setlocal + +rem Change to the script's own directory +cd /d "%~dp0" + +rem Port: default 8001, override with first arg (e.g. start.bat 9000) +set "PORT=8001" +if not "%~1"=="" set "PORT=%~1" + +rem Prefer conda ppm env, fall back to system python +set "PY=D:\ProgramData\miniconda3\envs\ppm\python.exe" +if not exist "%PY%" set "PY=python" + +rem Local-only: allow no-password mode (fail-secure default would refuse to start) +set "PPM_ALLOW_NO_AUTH=1" + +echo ============================================== +echo Progress Monitor - dev server (no-auth local) +echo URL: http://127.0.0.1:%PORT% +echo Stop: press Ctrl+C +echo ============================================== +echo. + +"%PY%" -m uvicorn app.main:app --host 127.0.0.1 --port %PORT% --reload + +rem If uvicorn exits on its own (e.g. port busy / import error), keep the window open +pause + +endlocal diff --git a/tests/conftest.py b/tests/conftest.py index a696744..c3623de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,9 @@ +import os from pathlib import Path +# 测试环境:允许无密码启动,避免模块级 create_app() 因 fail-secure 抛错。 +os.environ.setdefault("PPM_ALLOW_NO_AUTH", "1") + import pytest from fastapi.testclient import TestClient diff --git a/tests/test_api.py b/tests/test_api.py index 186874d..6fea874 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -52,3 +52,42 @@ def test_invalid_input_and_auth(auth_client): forbidden = auth_client.post("/api/tasks", json=payload()) assert forbidden.status_code == 403 + +def test_duration_unit_and_backfill(client): + task = client.post("/api/tasks", json={ + "name": "时长任务", "startDate": "2026-08-01", + "metrics": [{"name": "专注", "kind": "duration", "period": "day", "durationUnit": "minute", "target": 120}], + }).json() + assert task["metrics"][0]["durationUnit"] == "minute" + metric_id = task["metrics"][0]["metricId"] + saved = client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-10", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 3600}]}) + assert saved.status_code == 200 + summary = client.get(f"/api/tasks/{task['taskId']}/summary?date=2026-08-10").json() + metric = summary["tasks"][task["taskId"]]["metrics"][0] + assert metric["value"] == 3600, "时长仍以整数秒存储" + assert metric.get("durationUnit") == "minute" + + +def test_invalid_duration_unit_rejected(client): + bad = client.post("/api/tasks", json={"name": "非法单位", "startDate": "2026-08-01", "metrics": [{"name": "x", "kind": "duration", "period": "day", "durationUnit": "year"}]}) + assert bad.status_code == 422 + + +def test_task_without_dates(client): + task = client.post("/api/tasks", json={"name": "无日期任务", "metrics": [{"name": "完成", "kind": "checkin", "period": "day"}]}).json() + assert task["startDate"] is None and task["endDate"] is None + # 无开始日期的 lifetime 统计不应报错 + assert client.get(f"/api/tasks/{task['taskId']}/summary").status_code == 200 + + +def test_admin_stats_and_import(client): + task = client.post("/api/tasks", json=payload("导入任务")).json() + metric_id = task["metrics"][0]["metricId"] + client.put(f"/api/tasks/{task['taskId']}/daily/2026-08-20", json={"expectedVersion": 0, "values": [{"metricId": metric_id, "value": 1}]}) + stats = client.get("/api/admin/stats").json() + assert stats["taskCount"] == 1 and stats["dailyFactCount"] == 1 + exported = client.get("/api/export").json() + imported = client.post("/api/admin/import", json=exported) + assert imported.status_code == 200 and imported.json()["imported"] == 1 + assert client.get("/api/admin/stats").json()["taskCount"] == 1 + diff --git a/tests/test_repository.py b/tests/test_repository.py index 54a89b9..30f1174 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -2,7 +2,7 @@ from datetime import date, timedelta from app.auth import SessionManager, make_password_hash from app.models import DailyRecordInput, MetricValue, TaskInput -from app.repository import ConflictError, JsonRepository, period_window +from app.repository import ConflictError, JsonRepository, ValidationError, period_window def task_payload(): @@ -180,3 +180,76 @@ def test_session_ttl_keeps_fresh_sessions(): session_id, session = manager.login("secret") session.created_at -= 5 assert manager.get(session_id) is session + + +def test_import_replaces_stale_files(tmp_path): + repo = JsonRepository(tmp_path) + first = repo.create_task(task_payload()) + second = repo.create_task(task_payload().model_copy(update={"name": "多出来的任务"})) + assert repo.list_tasks()["items"] and len(repo.list_tasks()["items"]) == 2 + + # 用只含第一个任务的备份整体替换,第二个任务应被清除。 + export = repo.export_all() + export["tasks"] = [first] + export["revisions"] = [rev for rev in export["revisions"] if rev["taskId"] == first["taskId"]] + export["daily"] = [] + repo.import_all(export) + remaining = repo.list_tasks(include_deleted=True)["items"] + assert [task["taskId"] for task in remaining] == [first["taskId"]] + + +def test_import_rejects_path_traversal(tmp_path): + repo = JsonRepository(tmp_path) + payload = repo.export_all() + payload["tasks"] = [{"taskId": "../evil", "schemaVersion": 1, "name": "x", "version": 1, "revision": 1, "createdAt": "x", "updatedAt": "x", "deletedAt": None, "metrics": []}] + try: + repo.import_all(payload) + except ValidationError: + pass + else: + raise AssertionError("路径逃逸的 taskId 应被拒绝") + + +def test_import_rebuilds_archives(tmp_path): + repo = JsonRepository(tmp_path) + task = repo.create_task(task_payload()) + metric_id = task["metrics"][0]["metricId"] + repo.upsert_daily(task["taskId"], date(2026, 8, 10), DailyRecordInput(values=[MetricValue(metricId=metric_id, value=1)])) + export = repo.export_all() + # 清空归档目录,模拟“从未归档”的目标状态。 + for path in (tmp_path / "archive").glob("*/*.json"): + path.unlink() + assert list((tmp_path / "archive").glob("*/*.json")) == [] + repo.import_all(export) + assert list((tmp_path / "archive").glob("*/*.json")), "导入后应重建归档" + + +def test_session_manager_fail_secure(monkeypatch): + from app.auth import SessionManager + # 清掉所有相关环境变量后,from_environment 应拒绝启动 + for key in ("PPM_PASSWORD", "PPM_PASSWORD_HASH", "PPM_ALLOW_NO_AUTH"): + monkeypatch.delenv(key, raising=False) + try: + SessionManager.from_environment() + except RuntimeError: + pass + else: + raise AssertionError("未配置密码时应 fail-secure 拒绝启动") + + +def test_session_manager_allow_no_auth(monkeypatch): + from app.auth import SessionManager + monkeypatch.delenv("PPM_PASSWORD", raising=False) + monkeypatch.delenv("PPM_PASSWORD_HASH", raising=False) + monkeypatch.setenv("PPM_ALLOW_NO_AUTH", "1") + assert SessionManager.from_environment().enabled is False + + +def test_login_rate_limiting(): + from app.auth import SessionManager + manager = SessionManager(make_password_hash("secret"), max_failures=3, lockout_seconds=60) + for _ in range(3): + assert manager.login("wrong", "1.2.3.4") is None + assert manager.is_login_locked("1.2.3.4") is True + # 正确密码在锁定期也应被拒 + assert manager.login("secret", "1.2.3.4") is None