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) <noreply@anthropic.com>
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
# 运行数据不入镜像(compose 用命名卷挂载到 /app/data)
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# 开发与版本控制产物
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
|
||||
# 测试与文档
|
||||
tests/
|
||||
需求记录.md
|
||||
README.md
|
||||
start.bat
|
||||
@@ -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
|
||||
|
||||
|
||||
50
app/auth.py
50
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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
16
app/main.py
16
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
|
||||
|
||||
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 `<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) ?? 0}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${progressBadge(task)}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
||||
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 `<div class="mini-progress"><span class="mini-progress-label" title="${escapeHtml(metric.name)}">${escapeHtml(metric.name)}<small>${valueText}${targetText}${metric.target ? ` · ${pct}%` : ""}</small></span><div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div></div>`;
|
||||
}).join("");
|
||||
return `<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="overview-metrics">${metrics}</div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : ""}</span></div>`;
|
||||
}).join("");
|
||||
return `<section class="section"><div class="section-heading"><h2>任务进度</h2><span>每个指标独立展示</span></div><div class="overview-list">${rows || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
||||
}
|
||||
|
||||
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 `<section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览</span></div><div class="empty-list">还没有任务,从右上角新建</div></section>`;
|
||||
|
||||
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 = `<div class="gantt-scale-switch"><button class="gantt-scale-btn ${scale === "week" ? "active" : ""}" data-gantt-scale="week">按周</button><button class="gantt-scale-btn ${scale === "month" ? "active" : ""}" data-gantt-scale="month">按月</button></div>`;
|
||||
|
||||
const monthBands = _ganttMonthBands(min, max).map((b) =>
|
||||
`<div class="gantt-month-band" style="--col:${col(b.start)}; --span:${b.span}">${b.label}</div>`
|
||||
).join("");
|
||||
const dayMarks = _ganttDayMarks(min, max, scale).map((m) =>
|
||||
`<div class="gantt-day-mark" style="--col:${col(m.start)}">${m.label}</div>`
|
||||
).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 `<div class="gantt-label" data-task-id="${task.taskId}" style="--row:${row}" title="${escapeHtml(task.name)}">${escapeHtml(task.name)}</div><div class="gantt-bar priority-${task.priority} ${openLeft ? "open-left" : ""} ${openRight ? "open-right" : ""}" data-task-id="${task.taskId}" style="--row:${row}; --start:${startCol}; --end:${endCol}"></div>`;
|
||||
}).join("");
|
||||
|
||||
const todayMark = (todayEpoch >= min && todayEpoch <= max)
|
||||
? `<div class="gantt-today" style="--col:${col(todayEpoch)}"></div>`
|
||||
: "";
|
||||
|
||||
return `<section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览 · 无日期视为无限</span>${header}</div><div class="gantt-viewport" data-scale="${scale}"><div class="gantt-layout" style="--days:${days}; --rows:${active.length}">${monthBands}${dayMarks}${rows}${todayMark}</div></div></section>`;
|
||||
}
|
||||
|
||||
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 `<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>`;
|
||||
if (metric.kind === "duration") return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · 时:分:秒 · ${periodLabel[metric.period]}</small></span><input type="text" inputmode="numeric" placeholder="0:30:00" data-kind="duration" data-metric-id="${metric.metricId}" value="${toDurationString(value)}"></label>`;
|
||||
if (metric.kind === "duration") {
|
||||
const unit = metric.durationUnit || "minute";
|
||||
const unitLabel = durationUnitLabel[unit] || "分";
|
||||
const scaled = value / (DURATION_SECONDS[unit] || 60);
|
||||
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · ${unitLabel} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="any" data-kind="duration" data-duration-unit="${unit}" data-metric-id="${metric.metricId}" value="${scaled}"></label>`;
|
||||
}
|
||||
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || "次"} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`;
|
||||
}
|
||||
|
||||
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 `<section class="section" id="todayRecord"><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">${hasCheckin ? `<button class="button primary" id="quickCheckinButton" type="button">一键打卡</button>` : `<span class="muted">修改会保留任务历史版本</span>`}<button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
|
||||
return `<section class="section" id="todayRecord"><div class="section-heading"><div><h2>${isToday ? "今天" : "补录"},${recordDate}</h2><span>${isToday ? "只记录今天真实完成的部分" : "补充过去某天的记录,历史事实不会被删除"}</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-date-row"><input id="recordDate" type="date" value="${recordDate}" max="${today()}"></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">${hasCheckin ? `<button class="button primary" id="quickCheckinButton" type="button">一键打卡</button>` : `<span class="muted">修改会保留任务历史版本</span>`}<button class="button primary" id="saveDailyButton">保存${isToday ? "今日" : "补录"}记录</button></div></section>`;
|
||||
}
|
||||
|
||||
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 `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${info.value}${info.target ? ` <small>/ ${info.target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${info.percent ?? 0}%"></div></div><small class="muted">${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}</small></div>`; }).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 ? ` <small>/ ${metricDisplayTarget(metric)}</small>` : "";
|
||||
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">${valueText}${targetText}</div><div class="progress-track"><div class="progress-fill" style="width:${info.percent ?? 0}%"></div></div><small class="muted">${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}</small></div>`;
|
||||
}).join("");
|
||||
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section>`;
|
||||
}
|
||||
|
||||
@@ -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) => `<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(); })); }
|
||||
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 = `<label data-role="unit" ${isDuration ? "hidden" : ""}>单位<input data-field="unit" maxlength="20" value="${escapeHtml(metric.unit || "")}" placeholder="次"></label>`;
|
||||
const durationUnitField = `<label data-role="durationUnit" ${isDuration ? "" : "hidden"}>时长单位<select data-field="durationUnit" data-prev-unit="${du}"><option value="second" ${du === "second" ? "selected" : ""}>秒</option><option value="minute" ${du === "minute" ? "selected" : ""}>分</option><option value="hour" ${du === "hour" ? "selected" : ""}>时</option></select></label>`;
|
||||
const targetValue = metricTargetToEditor(metric);
|
||||
return `<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="${targetValue}"></label>${unitField}${durationUnitField}<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(); }));
|
||||
$$(".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) => `<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(); });
|
||||
$("#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]) => `<div class="summary-cell"><strong>${escapeHtml(value)}</strong><span>${label} · ${hint}</span></div>`).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 = `<div class="summary-strip admin-stats-strip">${renderAdminStats(stats)}</div>`;
|
||||
$("#taskAdminList").innerHTML = all.map((task) => {
|
||||
const badge = task.deletedAt ? `<span class="status-badge paused">已删除</span>` : `<span class="status-badge">${task.state === "paused" ? "挂起" : "进行中"}</span>`;
|
||||
const actions = task.deletedAt
|
||||
? `<button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button>`
|
||||
: `<button class="button" data-admin-delete="${task.taskId}">移入回收站</button>`;
|
||||
return `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${escapeHtml(task.startDate)} · 优先级 ${task.priority} · ${task.metrics.length} 指标</small>${badge}</span><span class="trash-item-actions">${actions}</span></div>`;
|
||||
}).join("") || `<p class="muted">暂无任务</p>`;
|
||||
$("#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-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();
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
</div>
|
||||
|
||||
<dialog id="taskDialog" class="dialog task-dialog">
|
||||
<form id="taskForm" method="dialog">
|
||||
<form id="taskForm">
|
||||
<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>
|
||||
<button class="icon-button" id="closeTaskButton" type="button" 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="startDate" type="date"></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>
|
||||
@@ -41,13 +41,16 @@
|
||||
</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>
|
||||
<div class="dialog-actions"><button class="button" id="cancelTaskButton" type="button">取消</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>
|
||||
<div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="importButton">导入还原</button><input type="file" id="importFile" accept="application/json,.json" hidden><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>
|
||||
<div class="admin-stats" id="adminStats"></div>
|
||||
<h3>全部任务</h3>
|
||||
<div id="taskAdminList" class="trash-list"></div>
|
||||
<h3>回收站</h3>
|
||||
<div id="trashList" class="trash-list"></div>
|
||||
</dialog>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
30
start.bat
Normal file
30
start.bat
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user