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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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) => `
${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-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 `OVERVIEW · ${today()}
把注意力放回正在发生的事
所有计划、记录和节奏,都在这里保持可见。
${active.length}进行中的任务
${activePlans}活跃计划
${active.filter((task) => task.priority <= 2).length}高优先级
`;
}
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 `${escapeHtml(metric.name)}${valueText}${targetText}${metric.target ? ` · ${pct}%` : ""} `;
}).join("");
return `${escapeHtml(task.name)}${periodText(task)} · ${task.metrics.length} 个指标
${metrics}
${task.state === "paused" ? "已挂起" : ""} `;
}).join("");
return `任务进度
每个指标独立展示${rows || `
还没有计划,从右上角开始添加。
`}
`;
}
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 ``;
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 = ``;
const monthBands = _ganttMonthBands(min, max).map((b) =>
`${b.label}
`
).join("");
const dayMarks = _ganttDayMarks(min, max, scale).map((m) =>
`${m.label}
`
).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 `${escapeHtml(task.name)}
`;
}).join("");
const todayMark = (todayEpoch >= min && todayEpoch <= max)
? ``
: "";
return `时间线
起止概览 · 无日期视为无限${header}${monthBands}${dayMarks}${rows}${todayMark}
`;
}
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 `TASK DETAIL · 优先级 ${task.priority}
${escapeHtml(task.name)}
${escapeHtml(task.description || "还没有写下说明")}
${task.state === "paused" ? "已挂起" : "进行中"}
`;
}
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 ``;
if (metric.kind === "duration") {
const unit = metric.durationUnit || "minute";
const unitLabel = durationUnitLabel[unit] || "分";
const scaled = value / (DURATION_SECONDS[unit] || 60);
return ``;
}
return ``;
}
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 ``;
}
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 ? ` / ${metricDisplayTarget(metric)}` : "";
return `${escapeHtml(metric.name)}${metricKindLabel[metric.kind]}
${valueText}${targetText}
${periodLabel[metric.period]} · ${info.percent === null ? "无上限" : `${info.percent}%`} · ${scope} `;
}).join("");
return ``;
}
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)} 个月少多
`;
}
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 || ""; 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 = ``;
const durationUnitField = ``;
const targetValue = metricTargetToEditor(metric);
return `${unitField}${durationUnitField}
`;
}).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]) => `${escapeHtml(value)}${label} · ${hint}
`).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 = `${renderAdminStats(stats)}
`;
$("#taskAdminList").innerHTML = all.map((task) => {
const badge = task.deletedAt ? `已删除` : `${task.state === "paused" ? "挂起" : "进行中"}`;
const actions = task.deletedAt
? ``
: ``;
return `${escapeHtml(task.name)}${escapeHtml(task.startDate)} · 优先级 ${task.priority} · ${task.metrics.length} 指标${badge}${actions}
`;
}).join("") || `暂无任务
`;
$("#trashList").innerHTML = deleted.length ? deleted.map((task) => `${escapeHtml(task.name)}${task.deletedAt}
`).join("") : `回收站为空
`;
$("#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();