| (function () { |
| const state = { |
| authenticated: document.body.dataset.authenticated === "true", |
| nextPath: (window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.nextPath) || "/", |
| loginRequired: !!(window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.loginRequired), |
| }; |
|
|
| const loginModal = document.getElementById("loginModal"); |
| const taskModal = document.getElementById("taskModal"); |
| const renameModal = document.getElementById("renameModal"); |
| const toastStack = document.getElementById("toastStack"); |
| const boardGrid = document.getElementById("boardGrid"); |
| const loginForm = document.getElementById("loginForm"); |
| const taskForm = document.getElementById("taskForm"); |
| const renameForm = document.getElementById("renameForm"); |
| const openLoginButton = document.getElementById("openLoginButton"); |
| const logoutButton = document.getElementById("logoutButton"); |
|
|
| const clockDisplay = document.getElementById("clockDisplay"); |
| const dateDisplay = document.getElementById("dateDisplay"); |
| const weekdayDisplay = document.getElementById("weekdayDisplay"); |
| const lunarDisplay = document.getElementById("lunarDisplay"); |
|
|
| const lunarData = [ |
| 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, |
| 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, |
| 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, |
| 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, |
| 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, |
| 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573, 0x052d0, 0x0a9a8, 0x0e950, 0x06aa0, |
| 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, |
| 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6, |
| 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, |
| 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, |
| 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, |
| 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, |
| 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, |
| 0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, |
| 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, |
| 0x14b63, |
| ]; |
|
|
| function showToast(message, kind = "success") { |
| const toast = document.createElement("div"); |
| toast.className = `toast ${kind}`; |
| toast.textContent = message; |
| toastStack.appendChild(toast); |
| window.setTimeout(() => { |
| toast.remove(); |
| }, 2600); |
| } |
|
|
| function openModal(modal) { |
| if (modal) { |
| modal.classList.add("is-open"); |
| } |
| } |
|
|
| function closeModal(modal) { |
| if (modal) { |
| modal.classList.remove("is-open"); |
| } |
| } |
|
|
| function requireAuth() { |
| if (!state.authenticated) { |
| openModal(loginModal); |
| return false; |
| } |
| return true; |
| } |
|
|
| async function requestJSON(url, options = {}) { |
| const response = await fetch(url, { |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| ...options, |
| }); |
|
|
| const payload = await response.json().catch(() => ({ ok: false, error: "请求失败" })); |
| if (!response.ok || !payload.ok) { |
| throw new Error(payload.error || "请求失败"); |
| } |
| return payload; |
| } |
|
|
| function formatLocalDateTime(isoString) { |
| const date = new Date(isoString); |
| return new Intl.DateTimeFormat("zh-CN", { |
| timeZone: "Asia/Shanghai", |
| month: "2-digit", |
| day: "2-digit", |
| hour: "2-digit", |
| minute: "2-digit", |
| hour12: false, |
| }).format(date).replace(",", ""); |
| } |
|
|
| function clamp(number, min, max) { |
| return Math.min(Math.max(number, min), max); |
| } |
|
|
| function mixColor(percent) { |
| if (percent <= 50) { |
| return "#73d883"; |
| } |
| if (percent <= 80) { |
| return "#ffc857"; |
| } |
| return "#ff6b5c"; |
| } |
|
|
| function renderProgress() { |
| const now = new Date(); |
| document.querySelectorAll(".task-card").forEach((card) => { |
| const createdAt = new Date(card.dataset.createdAt); |
| const dueAt = new Date(card.dataset.dueAt); |
| const completed = card.dataset.completed === "true"; |
| const total = dueAt.getTime() - createdAt.getTime(); |
| const elapsed = now.getTime() - createdAt.getTime(); |
| const progress = completed || total <= 0 ? 100 : clamp((elapsed / total) * 100, 0, 100); |
| const dueLabel = card.querySelector("[data-due-label]"); |
| const progressText = card.querySelector("[data-progress-text]"); |
| const progressBar = card.querySelector("[data-progress-bar]"); |
|
|
| if (dueLabel) { |
| dueLabel.textContent = `截止 ${formatLocalDateTime(card.dataset.dueAt)}`; |
| } |
|
|
| if (progressText) { |
| progressText.textContent = completed ? "已完成" : `已过去 ${progress.toFixed(0)}%`; |
| } |
|
|
| if (progressBar) { |
| progressBar.style.width = `${progress}%`; |
| progressBar.style.background = completed |
| ? "linear-gradient(90deg, #66d0ff 0%, #7be7ea 100%)" |
| : mixColor(progress); |
| } |
| }); |
| } |
|
|
| function getBit(year, month) { |
| return (lunarData[year - 1900] & (0x10000 >> month)) !== 0 ? 1 : 0; |
| } |
|
|
| function leapMonth(year) { |
| return lunarData[year - 1900] & 0xf; |
| } |
|
|
| function leapDays(year) { |
| if (leapMonth(year)) { |
| return (lunarData[year - 1900] & 0x10000) ? 30 : 29; |
| } |
| return 0; |
| } |
|
|
| function monthDays(year, month) { |
| return getBit(year, month) ? 30 : 29; |
| } |
|
|
| function lunarYearDays(year) { |
| let sum = 348; |
| for (let i = 0x8000; i > 0x8; i >>= 1) { |
| sum += (lunarData[year - 1900] & i) ? 1 : 0; |
| } |
| return sum + leapDays(year); |
| } |
|
|
| function solarToLunar(date) { |
| const baseDate = new Date(Date.UTC(1900, 0, 31)); |
| const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); |
| let offset = Math.floor((utcDate - baseDate) / 86400000); |
| let year; |
|
|
| for (year = 1900; year < 2101 && offset > 0; year += 1) { |
| const temp = lunarYearDays(year); |
| if (offset < temp) { |
| break; |
| } |
| offset -= temp; |
| } |
|
|
| let month = 1; |
| let isLeap = false; |
| const leap = leapMonth(year); |
|
|
| while (month <= 12 && offset >= 0) { |
| let temp; |
| if (leap > 0 && month === leap + 1 && !isLeap) { |
| month -= 1; |
| isLeap = true; |
| temp = leapDays(year); |
| } else { |
| temp = monthDays(year, month); |
| } |
|
|
| if (offset < temp) { |
| break; |
| } |
|
|
| offset -= temp; |
| if (isLeap && month === leap) { |
| isLeap = false; |
| } |
| month += 1; |
| } |
|
|
| return { |
| year, |
| month, |
| day: offset + 1, |
| isLeap, |
| }; |
| } |
|
|
| function formatLunar(date) { |
| const lunar = solarToLunar(date); |
| const monthNames = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"]; |
| const dayNames = [ |
| "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", |
| "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", |
| "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十", |
| ]; |
| const prefix = lunar.isLeap ? "闰" : ""; |
| return `农历 ${prefix}${monthNames[lunar.month - 1]}月${dayNames[lunar.day - 1]}`; |
| } |
|
|
| function getBeijingDate() { |
| const parts = new Intl.DateTimeFormat("en-CA", { |
| timeZone: "Asia/Shanghai", |
| year: "numeric", |
| month: "2-digit", |
| day: "2-digit", |
| hour: "2-digit", |
| minute: "2-digit", |
| second: "2-digit", |
| hour12: false, |
| }).formatToParts(new Date()); |
|
|
| const map = {}; |
| parts.forEach((part) => { |
| if (part.type !== "literal") { |
| map[part.type] = part.value; |
| } |
| }); |
|
|
| return { |
| year: Number(map.year), |
| month: Number(map.month), |
| day: Number(map.day), |
| hour: map.hour, |
| minute: map.minute, |
| second: map.second, |
| }; |
| } |
|
|
| function renderClock() { |
| const now = getBeijingDate(); |
| clockDisplay.textContent = `${now.hour}:${now.minute}:${now.second}`; |
| dateDisplay.textContent = `${now.year} 年 ${String(now.month).padStart(2, "0")} 月 ${String(now.day).padStart(2, "0")} 日`; |
| const currentDate = new Date(now.year, now.month - 1, now.day); |
| const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; |
| weekdayDisplay.textContent = weekdays[currentDate.getDay()]; |
| lunarDisplay.textContent = formatLunar(currentDate); |
| } |
|
|
| function taskTemplate(task, authenticated) { |
| const card = document.createElement("article"); |
| card.className = `task-card ${task.completed ? "task-complete" : ""}`; |
| card.dataset.taskId = task.id; |
| card.dataset.createdAt = task.created_at; |
| card.dataset.dueAt = task.due_at; |
| card.dataset.completed = task.completed ? "true" : "false"; |
| card.innerHTML = ` |
| <div class="task-main"> |
| <label class="check-wrap"> |
| <input class="task-check" type="checkbox" ${task.completed ? "checked" : ""} data-toggle-task="${task.id}"> |
| <span class="custom-check"></span> |
| </label> |
| <div class="task-copy"> |
| <h4>${task.title}</h4> |
| <p class="meta-line"> |
| <span class="meta-badge due-time" data-due-label></span> |
| <span class="meta-badge progress-text" data-progress-text></span> |
| </p> |
| </div> |
| </div> |
| <div class="progress-shell"> |
| <div class="progress-bar" data-progress-bar></div> |
| </div> |
| ${authenticated ? `<button class="task-delete" type="button" data-delete-task="${task.id}">删除</button>` : ""} |
| `; |
| return card; |
| } |
|
|
| function ensureEmptyState(column) { |
| const list = column.querySelector(".task-list"); |
| if (!list) { |
| return; |
| } |
| const cards = list.querySelectorAll(".task-card"); |
| const empty = list.querySelector(".empty-state"); |
| if (!cards.length && !empty) { |
| const block = document.createElement("div"); |
| block.className = "empty-state"; |
| block.innerHTML = "<p>这里还没有任务</p><span>点击右上角的加号,给这个分类加上第一条提醒。</span>"; |
| list.appendChild(block); |
| } |
| if (cards.length && empty) { |
| empty.remove(); |
| } |
| } |
|
|
| function setDefaultDueAt() { |
| const input = document.getElementById("taskDueAt"); |
| const now = getBeijingDate(); |
| const rounded = new Date(now.year, now.month - 1, now.day, Number(now.hour) + 1, 0, 0); |
| const yyyy = rounded.getFullYear(); |
| const mm = String(rounded.getMonth() + 1).padStart(2, "0"); |
| const dd = String(rounded.getDate()).padStart(2, "0"); |
| const hh = String(rounded.getHours()).padStart(2, "0"); |
| input.value = `${yyyy}-${mm}-${dd}T${hh}:00`; |
| } |
|
|
| async function handleLogin(event) { |
| event.preventDefault(); |
| const password = document.getElementById("loginPassword").value; |
| try { |
| await requestJSON("/api/login", { |
| method: "POST", |
| body: JSON.stringify({ |
| password, |
| next: state.nextPath, |
| }), |
| }); |
| showToast("登录成功,已解锁编辑权限"); |
| window.location.href = state.nextPath || "/"; |
| } catch (error) { |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| async function handleLogout() { |
| try { |
| await requestJSON("/api/logout", { |
| method: "POST", |
| body: JSON.stringify({}), |
| }); |
| window.location.reload(); |
| } catch (error) { |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| async function handleTaskSubmit(event) { |
| event.preventDefault(); |
| if (!requireAuth()) { |
| return; |
| } |
| const categoryId = document.getElementById("taskCategoryId").value; |
| const title = document.getElementById("taskName").value.trim(); |
| const dueAt = document.getElementById("taskDueAt").value; |
| try { |
| const payload = await requestJSON(`/api/categories/${categoryId}/tasks`, { |
| method: "POST", |
| body: JSON.stringify({ |
| title, |
| due_at: dueAt, |
| }), |
| }); |
| const column = document.querySelector(`[data-category-id="${categoryId}"]`); |
| const list = column.querySelector(".task-list"); |
| const card = taskTemplate(payload.task, state.authenticated); |
| list.prepend(card); |
| ensureEmptyState(column); |
| renderProgress(); |
| closeModal(taskModal); |
| taskForm.reset(); |
| setDefaultDueAt(); |
| showToast("任务已添加"); |
| } catch (error) { |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| async function handleRenameSubmit(event) { |
| event.preventDefault(); |
| if (!requireAuth()) { |
| return; |
| } |
| const categoryId = document.getElementById("renameCategoryId").value; |
| const name = document.getElementById("renameCategoryName").value.trim(); |
| try { |
| await requestJSON(`/api/categories/${categoryId}`, { |
| method: "PATCH", |
| body: JSON.stringify({ name }), |
| }); |
| const column = document.querySelector(`[data-category-id="${categoryId}"]`); |
| if (column) { |
| const title = column.querySelector(".column-title"); |
| if (title) { |
| title.textContent = name; |
| } |
| } |
| closeModal(renameModal); |
| showToast("分类名称已更新"); |
| } catch (error) { |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| async function toggleTask(taskId, checked, input) { |
| if (!requireAuth()) { |
| input.checked = !checked; |
| return; |
| } |
| try { |
| await requestJSON(`/api/tasks/${taskId}`, { |
| method: "PATCH", |
| body: JSON.stringify({ completed: checked }), |
| }); |
| const card = document.querySelector(`[data-task-id="${taskId}"]`); |
| if (card) { |
| card.dataset.completed = checked ? "true" : "false"; |
| card.classList.toggle("task-complete", checked); |
| renderProgress(); |
| } |
| showToast(checked ? "任务已完成" : "任务已恢复"); |
| } catch (error) { |
| input.checked = !checked; |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| async function deleteTask(taskId) { |
| if (!requireAuth()) { |
| return; |
| } |
| try { |
| await requestJSON(`/api/tasks/${taskId}`, { |
| method: "DELETE", |
| body: JSON.stringify({}), |
| }); |
| const card = document.querySelector(`[data-task-id="${taskId}"]`); |
| const column = card && card.closest(".todo-column"); |
| if (card) { |
| card.remove(); |
| } |
| if (column) { |
| ensureEmptyState(column); |
| } |
| showToast("任务已删除"); |
| } catch (error) { |
| showToast(error.message, "error"); |
| } |
| } |
|
|
| function bindBoardEvents() { |
| boardGrid.addEventListener("click", (event) => { |
| const addButton = event.target.closest("[data-open-add]"); |
| if (addButton) { |
| if (!requireAuth()) { |
| return; |
| } |
| document.getElementById("taskCategoryId").value = addButton.dataset.openAdd; |
| const column = addButton.closest(".todo-column"); |
| const title = column ? column.querySelector(".column-title").textContent : "添加提醒"; |
| document.getElementById("taskModalTitle").textContent = `添加到 ${title}`; |
| openModal(taskModal); |
| setDefaultDueAt(); |
| return; |
| } |
|
|
| const menuTrigger = event.target.closest("[data-menu-trigger]"); |
| if (menuTrigger) { |
| const panel = document.querySelector(`[data-menu-panel="${menuTrigger.dataset.menuTrigger}"]`); |
| document.querySelectorAll(".menu-panel").forEach((item) => { |
| if (item !== panel) { |
| item.classList.remove("is-open"); |
| } |
| }); |
| if (panel) { |
| panel.classList.toggle("is-open"); |
| } |
| return; |
| } |
|
|
| const renameButton = event.target.closest("[data-rename-category]"); |
| if (renameButton) { |
| if (!requireAuth()) { |
| return; |
| } |
| const categoryId = renameButton.dataset.renameCategory; |
| const column = document.querySelector(`[data-category-id="${categoryId}"]`); |
| const currentName = column ? column.querySelector(".column-title").textContent.trim() : ""; |
| document.getElementById("renameCategoryId").value = categoryId; |
| document.getElementById("renameCategoryName").value = currentName; |
| closeMenus(); |
| openModal(renameModal); |
| return; |
| } |
|
|
| const deleteButton = event.target.closest("[data-delete-task]"); |
| if (deleteButton) { |
| deleteTask(deleteButton.dataset.deleteTask); |
| } |
| }); |
|
|
| boardGrid.addEventListener("change", (event) => { |
| const checkbox = event.target.closest("[data-toggle-task]"); |
| if (checkbox) { |
| toggleTask(checkbox.dataset.toggleTask, checkbox.checked, checkbox); |
| } |
| }); |
| } |
|
|
| function closeMenus() { |
| document.querySelectorAll(".menu-panel.is-open").forEach((panel) => { |
| panel.classList.remove("is-open"); |
| }); |
| } |
|
|
| document.addEventListener("click", (event) => { |
| if (!event.target.closest(".menu-wrap")) { |
| closeMenus(); |
| } |
|
|
| const closeTarget = event.target.closest("[data-close-modal]"); |
| if (closeTarget) { |
| const modal = document.getElementById(closeTarget.dataset.closeModal); |
| closeModal(modal); |
| } |
|
|
| if (event.target.classList.contains("modal-backdrop")) { |
| closeModal(event.target); |
| } |
| }); |
|
|
| loginForm.addEventListener("submit", handleLogin); |
| taskForm.addEventListener("submit", handleTaskSubmit); |
| renameForm.addEventListener("submit", handleRenameSubmit); |
| bindBoardEvents(); |
|
|
| if (openLoginButton) { |
| openLoginButton.addEventListener("click", () => openModal(loginModal)); |
| } |
|
|
| if (logoutButton) { |
| logoutButton.addEventListener("click", handleLogout); |
| } |
|
|
| if (!state.authenticated && state.loginRequired) { |
| openModal(loginModal); |
| } |
|
|
| renderClock(); |
| renderProgress(); |
| window.setInterval(renderClock, 1000); |
| window.setInterval(renderProgress, 30000); |
| setDefaultDueAt(); |
| })(); |
|
|