Files
personal-progress-monitor/app/static/app.js
2026-08-24 06:18:21 +08:00

144 lines
20 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, layout: 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 escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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() {
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();
} catch (error) { toast(error.message); }
}
async function loadLayout() { state.layout = await api("/api/layouts/home"); }
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() {
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();
}
function bindSidebar() {
$$(".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 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); }
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();
}
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>`;
$$(".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 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 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 toLocalDatetime(value) { return value ? value.slice(0, 16) : ""; }
function bindRecordForm(task) {
$("#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); }
});
}
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 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 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) => `<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(); })); }
$("#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) => `<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; } });
bootstrap();