feat: complete frozen MVP — period-aware stats, layout-driven UI, quick checkin
- Fix first daily record 422 (expectedVersion now accepts 0)
- Add period_window + summarize; /api/summary and /api/tasks/{id}/summary
aggregate each metric inside its own day/week/month/lifetime window,
reading raw facts only (never the rebuildable month archives)
- Drive frontend rendering from per-page layout JSON with a component
registry; scope/cardinality enforced server-side, invalid layouts fall back
- Add quick checkin and duration input in H:M:S that round-trips integer seconds
- Fix heatmap/completedAt timezone drift to Asia/Shanghai
- Add server-side session TTL (30d) matching the login cookie
- Handle date.min/date.max boundaries without OverflowError
- Half-up rounding to match browser Math.round
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
const state = {
|
||||
tasks: [], version: 1, selectedId: null, selectedTask: null,
|
||||
daily: null, history: null, layout: null, csrf: "", authEnabled: false,
|
||||
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
|
||||
filter: "", editingId: null, dragId: null,
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
const today = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date());
|
||||
const 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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[char]));
|
||||
const metricKindLabel = { checkin: "打卡", quantity: "数量", duration: "时长" };
|
||||
const periodLabel = { day: "每日", week: "每周", month: "每月", lifetime: "总计" };
|
||||
@@ -31,32 +35,46 @@ function showLogin() { $("#loginOverlay").hidden = false; $("#app").setAttribute
|
||||
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 loadLayout(); await loadTasks();
|
||||
await loadTasks();
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
|
||||
async function loadLayout() { state.layout = await api("/api/layouts/home"); }
|
||||
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 renderSidebar() {
|
||||
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("");
|
||||
$("#sidebar").innerHTML = `<div class="sidebar-heading"><div><p class="eyebrow">WORKSPACE</p><h2>我的任务</h2></div><span>${state.tasks.length} 项</span></div><div class="filter-row"><input id="taskFilter" placeholder="搜索任务" value="${escapeHtml(state.filter)}"><button class="icon-button" id="clearFilter" title="清除搜索" aria-label="清除搜索">×</button></div><div class="task-list">${items || `<div class="empty-list">还没有任务<br>从右上角新建一项计划</div>`}</div>`;
|
||||
$("#taskFilter").addEventListener("input", (event) => { state.filter = event.target.value; renderSidebar(); bindSidebar(); });
|
||||
$("#clearFilter").addEventListener("click", () => { state.filter = ""; renderSidebar(); bindSidebar(); }); bindSidebar();
|
||||
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"); });
|
||||
@@ -74,57 +92,149 @@ async function reorder(fromId, toId) {
|
||||
}
|
||||
|
||||
function periodText(task) { const metrics = task.metrics || []; return [...new Set(metrics.map((metric) => periodLabel[metric.period] || metric.period))].join(" / "); }
|
||||
function percent(value, target) { return target ? Math.min(100, Math.round(value / target * 100)) : value ? 100 : 0; }
|
||||
function metricTotal(metricId) { return (state.history?.records || []).reduce((sum, record) => sum + Number(record.values?.[metricId] || 0), 0); }
|
||||
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
|
||||
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
|
||||
|
||||
async function render() {
|
||||
renderSidebar();
|
||||
if (!state.selectedId) { renderHome(); return; }
|
||||
state.selectedTask = state.tasks.find((task) => task.taskId === state.selectedId);
|
||||
try { [state.daily, state.history] = await Promise.all([api(`/api/tasks/${state.selectedId}/daily/${today()}`), api(`/api/tasks/${state.selectedId}/history`)]); }
|
||||
catch (error) { toast(error.message); return; }
|
||||
renderDetail();
|
||||
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
|
||||
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
|
||||
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
|
||||
const components = {
|
||||
"task-list": { render: renderTaskList, bind: bindSidebar },
|
||||
"overview-summary": { render: renderOverviewSummary },
|
||||
"task-progress-grid": { render: renderTaskProgressGrid, bind: bindOverview },
|
||||
"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 || {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
const active = state.tasks.filter((task) => !task.deletedAt); const completed = active.filter((task) => task.state === "active").length;
|
||||
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${completed}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div><section class="section"><div class="section-heading"><h2>任务进度</h2><span>点击任务查看详情</span></div><div class="overview-list">${active.map((task) => `<div class="overview-row" data-task-id="${task.taskId}"><div class="overview-name"><strong>${escapeHtml(task.name)}</strong><small>${periodText(task)} · ${task.metrics.length} 个指标</small></div><div class="progress-track"><div class="progress-fill" style="width:${taskProgress(task)}%"></div></div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : `${taskProgress(task)}%`}</span></div>`).join("") || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
|
||||
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));
|
||||
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}/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);
|
||||
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>`;
|
||||
}
|
||||
|
||||
function bindOverview() {
|
||||
$$(".overview-row").forEach((row) => row.addEventListener("click", () => { state.selectedId = row.dataset.taskId; render(); }));
|
||||
}
|
||||
|
||||
function taskProgress(task) {
|
||||
const values = task.metrics.map((metric) => ({ value: metricTotal(metric.metricId), target: metric.target })).filter((item) => item.target);
|
||||
if (!values.length) return 0; return Math.round(values.reduce((sum, item) => sum + percent(item.value, item.target), 0) / values.length);
|
||||
function 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}%`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const task = state.selectedTask; const completedToday = Object.values(state.daily?.values || {}).some((value) => value > 0);
|
||||
$("#main").innerHTML = `<div class="page-heading"><div><p class="eyebrow">TASK DETAIL · 优先级 ${task.priority}</p><h1>${escapeHtml(task.name)}</h1><p>${escapeHtml(task.description || "还没有写下说明")}</p></div><div class="top-actions"><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : "进行中"}</span><button class="button" id="editTaskButton">编辑属性</button></div></div><div class="tabs"><button class="tab active" data-tab="record">今日记录</button><button class="tab" data-tab="insight">完成情况</button><button class="tab" data-tab="settings">任务设置</button></div><div class="tab-panel" data-panel="record">${renderTodayRecord(task, completedToday)}</div><div class="tab-panel" data-panel="insight" hidden>${renderInsight(task)}</div><div class="tab-panel" data-panel="settings" hidden>${renderSettings(task)}</div>`;
|
||||
$$(".tab").forEach((tab) => tab.addEventListener("click", () => { $$(".tab").forEach((item) => item.classList.toggle("active", item === tab)); $$(".tab-panel").forEach((panel) => panel.hidden = panel.dataset.panel !== tab.dataset.tab); }));
|
||||
$("#editTaskButton").addEventListener("click", () => openTaskDialog(task)); bindRecordForm(task); bindSettings(task);
|
||||
function 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 renderTodayRecord(task, completedToday) {
|
||||
const fields = task.metrics.map((metric) => { const value = state.daily?.values?.[metric.metricId] || 0; if (metric.kind === "checkin") return `<label class="record-field check"><input type="checkbox" data-metric-id="${metric.metricId}" value="1" ${value ? "checked" : ""}><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${periodLabel[metric.period]}</small></span></label>`; return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">${metricKindLabel[metric.kind]} · ${metric.unit || (metric.kind === "duration" ? "秒" : "次")} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="1" data-metric-id="${metric.metricId}" value="${value}"></label>`; }).join("");
|
||||
return `<section class="section"><div class="section-heading"><div><h2>今天,${today()}</h2><span>只记录今天真实完成的部分</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-grid">${fields}<label class="record-field wide">心得<textarea id="dailyNote" rows="4" maxlength="4000" placeholder="记下今天的阻力、收获或下一步">${escapeHtml(state.daily?.note || "")}</textarea></label><label class="record-field">完成时间<input id="completedAt" type="datetime-local" value="${toLocalDatetime(state.daily?.completedAt)}"></label></div><div class="record-actions"><span class="muted">修改会保留任务历史版本</span><button class="button primary" id="saveDailyButton">保存今日记录</button></div></section>`;
|
||||
function 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 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>`;
|
||||
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 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>`;
|
||||
}
|
||||
|
||||
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))),
|
||||
}));
|
||||
}
|
||||
|
||||
function saveTodayRecord(values = collectTodayValues()) {
|
||||
const task = state.selectedTask;
|
||||
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 }) });
|
||||
}
|
||||
|
||||
function toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
|
||||
function bindRecordForm(task) {
|
||||
function bindRecordForm() {
|
||||
$("#saveDailyButton").addEventListener("click", async () => {
|
||||
const values = $$(`[data-metric-id]`).map((node) => ({ metricId: node.dataset.metricId, value: node.type === "checkbox" ? (node.checked ? 1 : 0) : Math.max(0, Number(node.value || 0)) }));
|
||||
const completedAt = $("#completedAt").value; try { await api(`/api/tasks/${task.taskId}/daily/${today()}`, { method: "PUT", body: JSON.stringify({ expectedVersion: state.daily?.version || 0, note: $("#dailyNote").value, completedAt: completedAt ? new Date(completedAt).toISOString() : null, values }) }); toast("今日记录已保存"); await render(); } catch (error) { toast(error.message); }
|
||||
try { await saveTodayRecord(); toast("今日记录已保存"); 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 renderInsight(task) {
|
||||
const cards = task.metrics.map((metric) => { const total = metricTotal(metric.metricId); const target = metric.target; return `<div class="metric-card"><div class="metric-card-head"><strong>${escapeHtml(metric.name)}</strong><small>${metricKindLabel[metric.kind]}</small></div><div class="metric-value">${total}${target ? ` <small>/ ${target}</small>` : ""}</div><div class="progress-track"><div class="progress-fill" style="width:${percent(total, target)}%"></div></div><small class="muted">${target ? `${percent(total, target)}% · ${periodLabel[metric.period]}` : `累计 ${periodLabel[metric.period]} · 无上限`}</small></div>`; }).join("");
|
||||
const heat = Array.from({ length: 364 }, (_, index) => { const day = new Date(); day.setDate(day.getDate() - (363 - index)); const iso = day.toISOString().slice(0, 10); const record = state.history?.records?.find((item) => item.date === iso); const amount = record ? Object.values(record.values || {}).reduce((sum, value) => sum + Number(value), 0) : 0; return `<span class="heat-cell level-${amount ? Math.min(4, Math.ceil(amount / 2)) : 0}" title="${iso} · ${amount}"></span>`; }).join("");
|
||||
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section><section class="section"><div class="section-heading"><h2>活动热力图</h2><span>过去 12 个月</span></div><div class="heatmap-wrap"><div class="heatmap">${heat}</div></div><div class="heat-legend"><span>少</span><i></i><i class="heat-cell level-1"></i><i class="heat-cell level-2"></i><i class="heat-cell level-3"></i><i class="heat-cell level-4"></i><span>多</span></div></section>`;
|
||||
function 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("");
|
||||
return `<section class="section"><div class="section-heading"><h2>指标总览</h2><span>基于每日事实计算</span></div><div class="metric-cards">${cards}</div></section>`;
|
||||
}
|
||||
|
||||
function renderSettings(task) { return `<section class="section"><div class="section-heading"><div><h2>危险操作</h2><span>默认保留历史,可从后台彻底清理</span></div></div><button class="button danger" id="deleteTaskButton">移入回收站</button></section>`; }
|
||||
function bindSettings(task) { $("#deleteTaskButton").addEventListener("click", async () => { if (!confirm(`确认将“${task.name}”移入回收站?`)) return; try { await api(`/api/tasks/${task.taskId}?expectedVersion=${task.version}`, { method: "DELETE" }); state.selectedId = null; toast("任务已移入回收站"); await loadTasks(); } catch (error) { toast(error.message); } }); }
|
||||
function 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 ? "编辑任务属性" : "新建任务";
|
||||
@@ -139,5 +249,5 @@ $("#taskForm").addEventListener("submit", async (event) => { event.preventDefaul
|
||||
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
|
||||
$("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${task.deletedAt}</small></span><span class="trash-item-actions"><button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button></span></div>`).join("") : `<p class="muted">回收站为空</p>`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); });
|
||||
$("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); });
|
||||
$("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); try { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: event.currentTarget.password.value }) }); const body = await response.json(); if (!response.ok) throw new Error(body.detail || "登录失败"); state.csrf = body.csrfToken; hideLogin(); await loadLayout(); await loadTasks(); } catch (error) { $("#loginError").textContent = error.message; } });
|
||||
$("#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();
|
||||
|
||||
Reference in New Issue
Block a user