Files
MobKBK ff10e7f065 fix: 折线图纵轴优化——默认约80%头空间、取整到nice刻度,支持手动设置纵轴上限
- 纵轴自动刻度改为精细步进(1/1.5/2/2.5/3/4/5/6/8/10),数据最大值落在约80%处
- ChartInput 新增 yMax 字段(JSON 存储、可拓展),图表弹窗新增「纵轴上限」输入
- 新增 yMax 往返测试
2026-09-04 15:07:10 +08:00

642 lines
52 KiB
JavaScript
Raw Permalink 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 = {
view: "home",
tasks: [], version: 1, selectedId: null, selectedTask: null,
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
charts: [], notes: null, editingChartId: null,
filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month",
schedules: { calendar: null, today: null }, scheduleMonth: new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()).slice(0, 7), editingScheduleId: 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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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, review: 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();
}
async function loadSchedules() {
const day = state.selectedDate || today();
const [calendar, todayResult] = await Promise.all([
api(`/api/schedules/calendar?month=${encodeURIComponent(state.scheduleMonth)}`),
api(`/api/schedules/today?date=${encodeURIComponent(day)}`),
]);
state.schedules = { calendar, today: todayResult };
}
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("");
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.view = "home"; 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; }
function niceCeil(value) {
if (!(value > 0)) return 1;
const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
for (const step of steps) {
const candidate = step * magnitude;
if (value <= candidate) return Number(candidate.toFixed(6));
}
return magnitude * 10;
}
function renderChartSvg(chart) {
const points = chart.data?.points || [];
if (!points.length || points.every((point) => Number(point.value) === 0)) return `<div class="chart-empty">暂无数据</div>`;
const width = 600, height = 200, pad = { left: 34, right: 12, top: 16, bottom: 30 };
const dataMax = Math.max(...points.map((point) => Number(point.value) || 0), 1);
const scaleMax = chart.yMax ? Math.max(Number(chart.yMax), dataMax) : niceCeil(dataMax * 1.25);
const x = (index) => pad.left + (points.length === 1 ? 0 : index * (width - pad.left - pad.right) / (points.length - 1));
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / scaleMax);
const coords = points.map((point, index) => `${x(index).toFixed(1)},${y(Number(point.value) || 0).toFixed(1)}`).join(" ");
const areaPath = `M ${pad.left.toFixed(1)} ${y(0).toFixed(1)} L ${coords.replace(/ /g, " L ")} L ${x(points.length - 1).toFixed(1)} ${y(0).toFixed(1)} Z`;
const labels = [points[0], points[Math.floor((points.length - 1) / 2)], points[points.length - 1]].filter((point, index, list) => point && list.findIndex((item) => item.date === point.date) === index).map((point) => {
const index = points.indexOf(point); return `<text x="${x(index)}" y="${height - 7}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
}).join("");
const circles = points.map((point, index) => `<circle cx="${x(index).toFixed(1)}" cy="${y(Number(point.value) || 0).toFixed(1)}" r="2.2"/>`).join("");
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(scaleMax)}" x2="${width - pad.right}" y2="${y(scaleMax)}"/><text class="chart-max" x="4" y="${y(scaleMax) + 4}">${scaleMax}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
}
function renderReviewCharts() {
const cards = state.charts.map((chart) => `<article class="review-chart-card"><div class="review-chart-head"><h3>${escapeHtml(chart.title)}</h3><span><button class="icon-button chart-edit" data-chart-id="${chart.chartId}" title="编辑图表" aria-label="编辑图表">✎</button><button class="icon-button chart-delete" data-chart-id="${chart.chartId}" title="删除图表" aria-label="删除图表">×</button></span></div>${renderChartSvg(chart)}</article>`).join("");
return `<section class="section review-charts"><div class="page-heading review-heading"><div><p class="eyebrow">REVIEW</p><h1>复盘</h1><p>观察完成趋势,回看每日心得</p></div><button class="button primary" id="addChartButton">+ 添加图表</button></div><div class="review-chart-grid">${cards || `<p class="empty-list">还没有图表,添加一张开始复盘</p>`}</div></section>`;
}
function renderReviewNotes() {
const days = state.notes?.days || [];
const content = days.map((day) => `<details class="review-day" open><summary><span class="review-date-badge">${escapeHtml(day.date.slice(5))}</span><strong>${escapeHtml(day.date)}</strong><span class="muted">${day.records.length} 条记录</span></summary><div class="review-day-records">${day.records.map((record) => `<div class="review-note-record"><strong>${escapeHtml(record.taskName)}</strong>${record.completedAt ? `<time>${escapeHtml(record.completedAt.replace("T", " "))}</time>` : ""}${record.note ? `<p>${escapeHtml(record.note)}</p>` : ""}</div>`).join("")}</div></details>`).join("");
return `<section class="section review-notes"><div class="section-heading"><div><p class="eyebrow">REVIEW</p><h2>每日心得</h2></div><span>最近 30 天</span></div>${content || `<p class="empty-list">最近没有心得记录</p>`}</section>`;
}
function fillChartMetrics(taskId, metricId = "") {
const select = $("#chartMetricSelect");
const task = state.tasks.find((item) => item.taskId === taskId);
const options = [`<option value="">完成数</option>`].concat(task ? task.metrics.map((metric) => `<option value="${escapeHtml(metric.metricId)}" ${metric.metricId === metricId ? "selected" : ""}>${escapeHtml(metric.name)}</option>`) : []);
select.innerHTML = options.join("");
}
function openChartDialog(chart = null) {
state.editingChartId = chart?.chartId || null;
const form = $("#chartForm"); form.reset(); $("#chartDialogTitle").textContent = chart ? "编辑图表" : "添加图表";
$("#chartTaskSelect").innerHTML = `<option value="">全部任务</option>${state.tasks.map((task) => `<option value="${escapeHtml(task.taskId)}">${escapeHtml(task.name)}</option>`).join("")}`;
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; form.yMax.value = chart?.yMax || ""; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
}
function bindReviewCharts() {
$("#addChartButton")?.addEventListener("click", () => openChartDialog());
$(".chart-edit") && $$(".chart-edit").forEach((button) => button.addEventListener("click", () => openChartDialog(state.charts.find((chart) => chart.chartId === button.dataset.chartId))));
$$(".chart-delete").forEach((button) => button.addEventListener("click", async () => { if (!confirm("确认删除这张图表?")) return; try { await api(`/api/charts/${button.dataset.chartId}`, { method: "DELETE" }); toast("图表已删除"); await render(); } catch (error) { toast(error.message); } }));
}
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
// 页面/区域约束与单例约束由服务端 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 },
"schedule-board": { render: renderScheduleBoard, bind: bindScheduleBoard },
"review-charts": { render: renderReviewCharts, bind: bindReviewCharts },
"review-notes": { render: renderReviewNotes },
};
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.view === "review") {
try {
const [charts, notes] = await Promise.all([api("/api/charts"), api(`/api/review/notes?start=${encodeURIComponent(shanghaiDate(-29))}&end=${encodeURIComponent(today())}`)]);
state.notes = notes;
state.charts = await Promise.all((charts.items || []).map(async (chart) => ({ ...chart, data: await api(`/api/charts/${chart.chartId}/data`) })));
} catch (error) { toast(error.message); return; }
await renderLayout("review");
return;
}
if (!state.selectedId) {
try { state.summary = await api("/api/summary"); await loadSchedules(); } 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 scheduleMonthOffset(offset) {
const [year, month] = state.scheduleMonth.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1 + offset, 1));
state.scheduleMonth = `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}`;
}
function scheduleForSelectedDate() {
const selected = state.selectedDate || today();
if (state.schedules.today?.date === selected) return state.schedules.today.items || [];
return state.schedules.calendar?.days?.find((item) => item.date === selected)?.schedules || [];
}
function renderScheduleBoard() {
const calendar = state.schedules.calendar;
if (!calendar) return `<section class="section"><div class="section-heading"><h2>日程</h2></div><p class="muted">正在加载日程…</p></section>`;
const selected = state.selectedDate || today();
const days = calendar.days || [];
const offset = days.length ? (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7 : 0;
const cells = Array.from({ length: offset }, () => `<span class="schedule-day blank"></span>`).join("") + days.map((item) => {
const schedules = item.schedules || [];
const priorities = [...new Set(schedules.map((schedule) => Number(schedule.priority)).filter((priority) => priority >= 1 && priority <= 5))].sort((a, b) => a - b);
const visiblePriorities = priorities.slice(0, 3);
const remainingPriorities = Math.max(0, priorities.length - visiblePriorities.length);
const dots = visiblePriorities.map((priority) => `<i class="dot priority-${priority}" aria-hidden="true"></i>`).join("");
const more = remainingPriorities ? `<span class="schedule-dots-more">+${remainingPriorities}</span>` : "";
const tooltip = schedules.map((schedule) => `${schedule.time ? escapeHtml(schedule.time) : "全天"} ${escapeHtml(schedule.title)} · 优先级${Number(schedule.priority) || 3}`).join("\n").replace(/\n/g, "&#10;");
const tooltipAttribute = tooltip ? ` data-tooltip="${tooltip}"` : "";
return `<button type="button" class="schedule-day ${item.date === selected ? "selected" : ""} ${item.date === today() ? "today" : ""}" data-schedule-date="${item.date}"${tooltipAttribute}><span class="schedule-date">${Number(item.date.slice(-2))}</span><span class="schedule-dots" aria-label="${priorities.length} 种优先级">${dots}${more}</span></button>`;
}).join("");
const items = scheduleForSelectedDate().map((schedule) => `<div class="schedule-item ${schedule.completed ? "completed" : ""}"><span class="priority-bar priority-${schedule.priority}"></span><div class="schedule-copy"><strong>${escapeHtml(schedule.title)}</strong><small>${schedule.time ? escapeHtml(schedule.time) : "全天"}${schedule.recurrence !== "none" ? ` · ${escapeHtml(schedule.recurrence)}` : ""}</small></div><button type="button" class="button subtle schedule-complete" data-schedule-id="${schedule.scheduleId}" data-schedule-version="${schedule.version}" data-completed="${schedule.completed ? "false" : "true"}">${schedule.completed ? "取消完成" : "完成"}</button><button type="button" class="icon-button schedule-edit" data-schedule-id="${schedule.scheduleId}" title="编辑" aria-label="编辑">✎</button></div>`).join("");
const legend = [[1, "紧急"], [2, "高"], [3, "正常"], [4, "低"], [5, "挂起候选"]].map(([priority, label]) => `<span class="schedule-legend-item"><i class="dot priority-${priority}" aria-hidden="true"></i><span>${label}</span></span>`).join("");
return `<section class="section schedule-board"><div class="section-heading"><div><p class="eyebrow">SCHEDULE</p><h2>日程安排</h2><span>${selected} · ${scheduleForSelectedDate().length} 项</span></div><div class="schedule-actions"><button type="button" class="icon-button" id="schedulePrev" title="上月" aria-label="上月">‹</button><strong>${calendar.year}-${String(calendar.month).padStart(2, "0")}</strong><button type="button" class="icon-button" id="scheduleNext" title="下月" aria-label="下月">›</button><button type="button" class="button primary" id="addScheduleButton">+ 新建日程</button></div></div><div class="schedule-weekdays"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div><div class="schedule-calendar">${cells}</div><div class="schedule-legend" aria-label="优先级图例"><span class="schedule-legend-label">优先级图例</span>${legend}</div><div class="schedule-list">${items || `<p class="muted">这一天没有日程</p>`}</div></section>`;
}
async function refreshSchedules() { try { await loadSchedules(); await renderLayout("home"); } catch (error) { toast(error.message); } }
function bindScheduleBoard() {
$("#schedulePrev")?.addEventListener("click", async () => { scheduleMonthOffset(-1); await refreshSchedules(); });
$("#scheduleNext")?.addEventListener("click", async () => { scheduleMonthOffset(1); await refreshSchedules(); });
$("#addScheduleButton")?.addEventListener("click", () => openScheduleDialog());
$$('[data-schedule-date]').forEach((node) => node.addEventListener("click", async () => { state.selectedDate = node.dataset.scheduleDate; await refreshSchedules(); }));
$$(".schedule-edit").forEach((node) => node.addEventListener("click", () => {
const schedule = scheduleForSelectedDate().find((item) => item.scheduleId === node.dataset.scheduleId); if (schedule) openScheduleDialog(schedule);
}));
$$(".schedule-complete").forEach((node) => node.addEventListener("click", async () => {
try { await api(`/api/schedules/${node.dataset.scheduleId}/complete`, { method: "POST", body: JSON.stringify({ date: state.selectedDate || today(), completed: node.dataset.completed === "true", expectedVersion: Number(node.dataset.scheduleVersion) }) }); toast("日程状态已更新"); await refreshSchedules(); } catch (error) { toast(error.message); }
}));
}
function openScheduleDialog(schedule = null) {
state.editingScheduleId = schedule?.scheduleId || null;
const form = $("#scheduleForm"); form.reset(); $("#scheduleDialogTitle").textContent = schedule ? "编辑日程" : "新建日程";
form.title.value = schedule?.title || ""; form.description.value = schedule?.description || ""; form.date.value = schedule?.date || state.selectedDate || today(); form.time.value = schedule?.time || ""; form.recurrence.value = schedule?.recurrence || "none"; form.priority.value = schedule?.priority || 3; form.state.value = schedule?.state || "active";
$("#scheduleDialog").showModal();
}
$("#scheduleForm")?.addEventListener("submit", async (event) => {
event.preventDefault(); const form = event.currentTarget;
const payload = { title: form.title.value, description: form.description.value, date: form.date.value || null, time: form.time.value || null, recurrence: form.recurrence.value, priority: Number(form.priority.value), state: form.state.value };
try { const editing = state.editingScheduleId; if (editing) { payload.expectedVersion = scheduleForSelectedDate().find((item) => item.scheduleId === editing)?.version || 1; }
const schedule = await api(editing ? `/api/schedules/${editing}` : "/api/schedules", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#scheduleDialog").close(); state.selectedDate = schedule.date; state.scheduleMonth = schedule.date.slice(0, 7); toast(editing ? "日程已更新" : "日程已创建"); await refreshSchedules();
} catch (error) { toast(error.message); }
});
$("#cancelScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close()); $("#closeScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close());
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);
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 `<div class="mini-progress"><span class="mini-progress-label" title="${escapeHtml(metric.name)}">${escapeHtml(metric.name)}<small>${valueText}${targetText}${metric.target ? ` · ${pct}%` : ""}</small></span><div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div></div>`;
}).join("");
return `<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="overview-metrics">${metrics}</div><span class="status-badge ${task.state === "paused" ? "paused" : ""}">${task.state === "paused" ? "已挂起" : ""}</span></div>`;
}).join("");
return `<section class="section"><div class="section-heading"><h2>任务进度</h2><span>每个指标独立展示</span></div><div class="overview-list">${rows || `<div class="empty-list">还没有计划,从右上角开始添加。</div>`}</div></section>`;
}
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 `<section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览</span></div><div class="empty-list">还没有任务,从右上角新建</div></section>`;
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 = `<div class="gantt-scale-switch"><button class="gantt-scale-btn ${scale === "week" ? "active" : ""}" data-gantt-scale="week">按周</button><button class="gantt-scale-btn ${scale === "month" ? "active" : ""}" data-gantt-scale="month">按月</button></div>`;
const monthBands = _ganttMonthBands(min, max).map((b) =>
`<div class="gantt-month-band" style="--col:${col(b.start)}; --span:${b.span}">${b.label}</div>`
).join("");
const dayMarks = _ganttDayMarks(min, max, scale).map((m) =>
`<div class="gantt-day-mark" style="--col:${col(m.start)}">${m.label}</div>`
).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 `<div class="gantt-label" data-task-id="${task.taskId}" style="--row:${row}" title="${escapeHtml(task.name)}">${escapeHtml(task.name)}</div><div class="gantt-bar priority-${task.priority} ${openLeft ? "open-left" : ""} ${openRight ? "open-right" : ""}" data-task-id="${task.taskId}" style="--row:${row}; --start:${startCol}; --end:${endCol}"></div>`;
}).join("");
const todayMark = (todayEpoch >= min && todayEpoch <= max)
? `<div class="gantt-today" style="--col:${col(todayEpoch)}"></div>`
: "";
return `<section class="section"><div class="section-heading"><h2>时间线</h2><span>起止概览 · 无日期视为无限</span>${header}</div><div class="gantt-viewport" data-scale="${scale}"><div class="gantt-layout" style="--days:${days}; --rows:${active.length}">${monthBands}${dayMarks}${rows}${todayMark}</div></div></section>`;
}
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 `<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 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 `<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") {
const unit = metric.durationUnit || "minute";
const unitLabel = durationUnitLabel[unit] || "分";
const scaled = value / (DURATION_SECONDS[unit] || 60);
return `<label class="record-field"><span><strong>${escapeHtml(metric.name)}</strong><small class="muted">时长 · ${unitLabel} · ${periodLabel[metric.period]}</small></span><input type="number" min="0" step="any" data-kind="duration" data-duration-unit="${unit}" data-metric-id="${metric.metricId}" value="${scaled}"></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 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 `<section class="section" id="todayRecord"><div class="section-heading"><div><h2>${isToday ? "今天" : "补录"},${recordDate}</h2><span>${isToday ? "只记录今天真实完成的部分" : "补充过去某天的记录,历史事实不会被删除"}</span></div><span class="status-badge ${completedToday ? "" : "paused"}">${completedToday ? "已有记录" : "尚未记录"}</span></div><div class="record-date-row"><input id="recordDate" type="date" value="${recordDate}" max="${today()}"></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">保存${isToday ? "今日" : "补录"}记录</button></div></section>`;
}
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 ? ` <small>/ ${metricDisplayTarget(metric)}</small>` : "";
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">${valueText}${targetText}</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 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 ? "编辑任务属性" : "新建任务";
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 = `<label data-role="unit" ${isDuration ? "hidden" : ""}>单位<input data-field="unit" maxlength="20" value="${escapeHtml(metric.unit || "")}" placeholder="次"></label>`;
const durationUnitField = `<label data-role="durationUnit" ${isDuration ? "" : "hidden"}>时长单位<select data-field="durationUnit" data-prev-unit="${du}"><option value="second" ${du === "second" ? "selected" : ""}>秒</option><option value="minute" ${du === "minute" ? "selected" : ""}>分</option><option value="hour" ${du === "hour" ? "selected" : ""}>时</option></select></label>`;
const targetValue = metricTargetToEditor(metric);
return `<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="${targetValue}"></label>${unitField}${durationUnitField}<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(); }));
$$(".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());
$("#reviewButton").addEventListener("click", () => { state.view = "review"; render(); });
$("#homeButton").addEventListener("click", () => { state.view = "home"; state.selectedId = null; render(); });
$("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close());
$("#cancelChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#closeChartButton").addEventListener("click", () => $("#chartDialog").close());
$("#chartTaskSelect").addEventListener("change", (event) => fillChartMetrics(event.target.value));
$("#chartForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value), yMax: form.yMax.value ? Number(form.yMax.value) : null };
try {
const editing = state.editingChartId ? state.charts.find((chart) => chart.chartId === state.editingChartId) : null;
if (editing) payload.expectedVersion = editing.version;
await api(editing ? `/api/charts/${editing.chartId}` : "/api/charts", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#chartDialog").close(); toast(editing ? "图表已更新" : "图表已创建"); await render();
} catch (error) { toast(error.message); }
});
function renderAdminStats(stats) {
const cells = [
["任务", stats.taskCount, "含回收站"],
["活跃", stats.activeTaskCount, "未删除"],
["每日事实", stats.dailyFactCount, "条记录"],
["归档", stats.archiveCount, "月"],
["修订", stats.revisionCount, "次"],
["占用", formatBytes(stats.storageBytes), "存储"],
];
return cells.map(([label, value, hint]) => `<div class="summary-cell"><strong>${escapeHtml(value)}</strong><span>${label} · ${hint}</span></div>`).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 = `<div class="summary-strip admin-stats-strip">${renderAdminStats(stats)}</div>`;
$("#taskAdminList").innerHTML = all.map((task) => {
const badge = task.deletedAt ? `<span class="status-badge paused">已删除</span>` : `<span class="status-badge">${task.state === "paused" ? "挂起" : "进行中"}</span>`;
const actions = task.deletedAt
? `<button class="button" data-restore="${task.taskId}">恢复</button><button class="button danger" data-purge="${task.taskId}">永久删除</button>`
: `<button class="button" data-admin-delete="${task.taskId}">移入回收站</button>`;
return `<div class="trash-item"><span><strong>${escapeHtml(task.name)}</strong><small class="muted">${escapeHtml(task.startDate)} · 优先级 ${task.priority} · ${task.metrics.length} 指标</small>${badge}</span><span class="trash-item-actions">${actions}</span></div>`;
}).join("") || `<p class="muted">暂无任务</p>`;
$("#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-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();