- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示 - 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开) - Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权) - 导出/导入/统计纳入日程与图表;47 项测试全绿
630 lines
52 KiB
JavaScript
630 lines
52 KiB
JavaScript
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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 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: 14, bottom: 30 };
|
||
const max = Math.max(...points.map((point) => Number(point.value) || 0), 1);
|
||
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 / max);
|
||
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(max)}" x2="${width - pad.right}" y2="${y(max)}"/><text class="chart-max" x="4" y="${y(max) + 4}">${max}</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; $("#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, " ");
|
||
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) };
|
||
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();
|