feat: scaffold personal progress monitor MVP
This commit is contained in:
143
app/static/app.js
Normal file
143
app/static/app.js
Normal file
@@ -0,0 +1,143 @@
|
||||
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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();
|
||||
Reference in New Issue
Block a user