const state = { tasks: [], version: 1, selectedId: null, selectedTask: null, 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: "总计" }; 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) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
`).join(""); return `
${items || `
还没有任务
从右上角新建一项计划
`}
`; } 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-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)); 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 `

OVERVIEW · ${today()}

把注意力放回正在发生的事

所有计划、记录和节奏,都在这里保持可见。

${active.length}进行中的任务
${activePlans}活跃计划
${active.filter((task) => task.priority <= 2).length}高优先级
`; } function renderTaskProgressGrid() { const active = state.tasks.filter((task) => !task.deletedAt); return `

任务进度

按各指标自身周期计算
${active.map((task) => `
${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${progressBadge(task)}
`).join("") || `
还没有计划,从右上角开始添加。
`}
`; } 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}%`; } function renderTaskHeader() { const task = state.selectedTask; return `

TASK DETAIL · 优先级 ${task.priority}

${escapeHtml(task.name)}

${escapeHtml(task.description || "还没有写下说明")}

${task.state === "paused" ? "已挂起" : "进行中"}
`; } 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 ``; if (metric.kind === "duration") return ``; return ``; } 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 `

今天,${today()}

只记录今天真实完成的部分
${completedToday ? "已有记录" : "尚未记录"}
${fields}
${hasCheckin ? `` : `修改会保留任务历史版本`}
`; } function collectTodayValues() { return $$(`#todayRecord [data-metric-id]`).map((node) => ({ metricId: node.dataset.metricId, value: node.type === "checkbox" ? (node.checked ? 1 : 0) : (node.dataset.kind === "duration" ? fromDurationString(node.value) : Math.max(0, Number(node.value || 0))), })); } 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() { $("#saveDailyButton").addEventListener("click", async () => { 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 renderMetricProgress() { const task = state.selectedTask; const cards = task.metrics.map((metric) => { const info = metricSummary(task.taskId, metric.metricId) || { value: 0, target: metric.target, percent: null, windowStart: "", windowEnd: "" }; const scope = metric.period === "lifetime" ? `自 ${info.windowStart}` : `${info.windowStart} ~ ${info.windowEnd}`; return `
${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${info.value}${info.target ? ` / ${info.target}` : ""}
${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope}
`; }).join(""); return `

指标总览

基于每日事实计算
${cards}
`; } 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 ``; }).join(""); return `

活动热力图

过去 ${Math.round(days / 30)} 个月
${heat}
少多
`; } function renderTaskSettings() { return `

危险操作

默认保留历史,可从后台彻底清理
`; } 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 || 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"; renderMetricEditor(task?.metrics || [{ name: "完成", kind: "checkin", period: "day", target: 1, unit: "次" }]); $("#taskDialog").showModal(); } function renderMetricEditor(metrics) { $("#metricEditor").innerHTML = metrics.map((metric, index) => `
`).join(""); $$("[data-remove]").forEach((button) => button.addEventListener("click", () => { const rows = $$(".metric-row"); if (rows.length <= 1) return toast("至少保留一个指标"); button.closest(".metric-row").remove(); })); } $("#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); } }); $("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); }); $("#adminButton").addEventListener("click", async () => { const result = await api("/api/tasks?includeDeleted=true"); const deleted = result.items.filter((task) => task.deletedAt); $("#trashList").innerHTML = deleted.length ? deleted.map((task) => `
${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `

回收站为空

`; $("#adminDialog").showModal(); $$("[data-restore]").forEach((button) => button.addEventListener("click", async () => { try { await api(`/api/tasks/${button.dataset.restore}/restore?expectedVersion=${deleted.find((task) => task.taskId === button.dataset.restore).version}`, { method: "POST" }); toast("任务已恢复"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); $$("[data-purge]").forEach((button) => button.addEventListener("click", async () => { const task = deleted.find((item) => item.taskId === button.dataset.purge); if (!confirm(`永久删除“${task.name}”?此操作不可恢复。`)) return; try { await api(`/api/tasks/${task.taskId}/purge`, { method: "POST", body: JSON.stringify({ confirmation: task.taskId }) }); toast("任务及其历史已永久删除"); $("#adminDialog").close(); await loadTasks(); } catch (error) { toast(error.message); } })); }); $("#closeAdminButton").addEventListener("click", () => $("#adminDialog").close()); $("#exportButton").addEventListener("click", async () => { const response = await fetch("/api/export"); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "progress-export.json"; link.click(); URL.revokeObjectURL(link.href); }); $("#archiveButton").addEventListener("click", async () => { try { const result = await api("/api/admin/archive", { method: "POST", body: "{}" }); toast(`补归档完成,处理 ${result.closedDays} 天`); } catch (error) { toast(error.message); } }); $("#logoutButton").addEventListener("click", async () => { await api("/api/auth/logout", { method: "POST", body: "{}" }); showLogin(); }); $("#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();