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 `任务进度
按各指标自身周期计算
回收站为空
`; $("#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]) => `暂无任务
`; + $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `回收站为空
`; + $("#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 @@ 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