Files
personal-progress-monitor/app/static/app.js
MobKBK 02b81f0113 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>
2026-08-24 12:11:03 +08:00

479 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month",
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
const shanghaiDate = (offsetDays) => {
const now = new Date(Date.now() + offsetDays * 86400000);
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(now);
};
const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[char]));
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
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 || {}) } };
if (!opts.method || !["GET", "HEAD"].includes(opts.method)) opts.headers["X-CSRF-Token"] = state.csrf;
const response = await fetch(path, opts);
let body = null;
try { body = await response.json(); } catch { body = {}; }
if (response.status === 401 && state.authEnabled) { showLogin(); throw new Error(body.detail || "需要登录"); }
if (!response.ok) throw new Error(body.detail || `请求失败 (${response.status})`);
return body;
}
function toast(message) {
const node = $("#toast"); node.textContent = message; node.classList.add("show");
window.clearTimeout(toast.timer); toast.timer = window.setTimeout(() => node.classList.remove("show"), 2600);
}
function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute("aria-busy", "true"); }
function hideLogin() { $("#loginOverlay").hidden = true; $("#app").setAttribute("aria-busy", "false"); }
async function bootstrap() {
const dateNode = $("#logicalDate"); if (dateNode) dateNode.textContent = today();
try {
const auth = await api("/api/auth/status");
state.authEnabled = auth.enabled; state.csrf = auth.csrfToken || "";
if (auth.enabled && !auth.authenticated) { showLogin(); return; }
hideLogin();
await loadTasks();
} catch (error) { toast(error.message); }
}
const layoutCache = { home: null, "task-detail": null };
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
async function loadTasks() {
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
if (!state.selectedId || !state.tasks.some((task) => task.taskId === state.selectedId)) state.selectedId = state.tasks[0]?.taskId || null;
await render();
}
function renderTaskList() {
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
<span class="priority-bar priority-${task.priority}"></span><span class="task-copy"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></span><span class="drag-handle" title="拖动排序" aria-hidden="true">⠿</span></div>`).join("");
return `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
}
function bindSidebar() {
const filterInput = $("#taskFilter");
if (filterInput) {
filterInput.addEventListener("input", (event) => { state.filter = event.target.value; rerenderSidebarList(); });
$("#clearFilter")?.addEventListener("click", () => { state.filter = ""; rerenderSidebarList(); });
}
bindSidebarItems();
}
function rerenderSidebarList() {
$("#sidebar").innerHTML = renderTaskList();
bindSidebar();
}
function bindSidebarItems() {
$$(".task-item").forEach((node) => {
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
node.addEventListener("dragover", (event) => event.preventDefault());
node.addEventListener("drop", async (event) => { event.preventDefault(); if (!state.dragId || state.dragId === node.dataset.taskId) return; await reorder(state.dragId, node.dataset.taskId); });
});
}
async function reorder(fromId, toId) {
const ids = state.tasks.map((task) => task.taskId); const from = ids.indexOf(fromId); const to = ids.indexOf(toId);
ids.splice(from, 1); ids.splice(to, 0, fromId);
try { await api("/api/tasks/reorder/all", { method: "PUT", body: JSON.stringify({ taskIds: ids, expectedVersion: state.version }) }); toast("排序已保存"); await loadTasks(); }
catch (error) { toast(error.message); }
}
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
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 },
"activity-heatmap": { render: renderActivityHeatmap },
"task-settings": { render: renderTaskSettings, bind: bindSettings },
};
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
function bindComponents(items) {
for (const item of items) {
const entry = components[item.component];
if (entry?.bind) entry.bind(item.options || {});
}
}
async function renderLayout(page) {
const layout = await ensureLayout(page);
const sidebarItems = layout.regions.sidebar || [];
const mainItems = layout.regions.main || [];
$("#sidebar").innerHTML = sidebarItems.map(componentHtml).join("");
$("#main").innerHTML = mainItems.map(componentHtml).join("");
bindComponents(sidebarItems);
bindComponents(mainItems);
}
async function render() {
if (!state.selectedId) {
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
await renderLayout("home");
return;
}
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
const layout = await ensureLayout("task-detail");
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/${recordDate}`),
api(`/api/tasks/${state.selectedId}/history?start=${start}&end=${today()}`),
]);
} catch (error) { toast(error.message); return; }
await renderLayout("task-detail");
}
function renderOverviewSummary() {
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
}
function renderTaskProgressGrid() {
const active = state.tasks.filter((task) => !task.deletedAt);
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(); }));
}
// ---- 主页任务时间甘特图 ----
// 日期统一用 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() {
const task = state.selectedTask;
return `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div>`;
}
function bindTaskHeader() {
$("#editTaskButton").addEventListener("click", () => openTaskDialog(state.selectedTask));
}
// 按指标配置的时长单位把「整数秒」格式化为人类可读口径。
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") {
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>${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) => {
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/${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(state.selectedDate ? "补录已保存" : "今日记录已保存"); await render(); } catch (error) { toast(error.message); }
});
$("#quickCheckinButton")?.addEventListener("click", async () => {
$$(`#todayRecord [data-metric-id][type="checkbox"]`).forEach((node) => { node.checked = true; });
try { await saveTodayRecord(); toast("已打卡"); await render(); } catch (error) { toast(error.message); }
});
}
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}`;
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>`;
}
function renderActivityHeatmap(options) {
const days = Math.min(options.historyDays ?? 365, 730);
const heat = Array.from({ length: days }, (_, index) => { const iso = shanghaiDate(index - (days - 1)); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
return `<section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 ${Math.round(days / 30)} 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
}
function renderTaskSettings() { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
function bindSettings() { const task = state.selectedTask; $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
function openTaskDialog(task = null) {
state.editingId = task?.taskId || null; const form = $("#taskForm"); form.reset(); $("#taskDialogTitle").textContent = task ? "编辑任务属性" : "新建任务";
form.startDate.value = task?.startDate || ""; 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 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; 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(); });
$("#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();