const MOODS = { happy: { emoji: "😊", label: "开心", color: "#FFD93D" }, calm: { emoji: "😌", label: "平静", color: "#6BCB77" }, sad: { emoji: "😢", label: "难过", color: "#6C9BCF" }, excited: { emoji: "🤩", label: "兴奋", color: "#FF6B6B" }, tired: { emoji: "😴", label: "疲惫", color: "#A8A8A8" }, love: { emoji: "🥰", label: "幸福", color: "#FF8DC7" }, angry: { emoji: "😤", label: "生气", color: "#E74C3C" }, anxious: { emoji: "😰", label: "焦虑", color: "#9B89B4" }, grateful: { emoji: "🙏", label: "感恩", color: "#F4A460" }, neutral: { emoji: "😐", label: "平淡", color: "#BDC3C7" }, }; const state = { user: null, moods: MOODS, month: null, selectedDate: null, monthEntries: [], dayEntries: [], dayComments: [], myEntryId: null, selectedMood: "neutral", mediaBlocks: [], }; const $ = (sel) => document.querySelector(sel); function showLoading(on) { $("#loading-overlay").classList.toggle("hidden", !on); } function toast(msg, isError = false) { const el = document.createElement("div"); el.className = "toast" + (isError ? " error" : ""); el.textContent = msg; $("#toast-container").appendChild(el); setTimeout(() => el.remove(), 3200); } async function api(path, options = {}) { const res = await fetch(path, { credentials: "include", headers: options.body && !(options.body instanceof FormData) ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers, ...options, }); let data = {}; try { data = await res.json(); } catch { data = {}; } if (!res.ok) throw new Error(data.error || res.statusText || "请求失败"); return data; } function pad(n) { return String(n).padStart(2, "0"); } function todayStr() { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function monthKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; } function parseDate(str) { const [y, m, day] = str.split("-").map(Number); return new Date(y, m - 1, day); } function formatDayLabel(dateStr) { const d = parseDate(dateStr); const w = ["日", "一", "二", "三", "四", "五", "六"]; return `${dateStr} 星期${w[d.getDay()]}`; } function showLogin() { $("#view-login").classList.remove("hidden"); $("#view-app").classList.add("hidden"); } function showApp() { $("#view-login").classList.add("hidden"); $("#view-app").classList.remove("hidden"); $("#user-greeting").textContent = `你好,${state.user.name}`; } function showCalendar() { $("#view-calendar").classList.remove("hidden"); $("#view-day").classList.add("hidden"); } function showDay(dateStr) { state.selectedDate = dateStr; $("#view-calendar").classList.add("hidden"); $("#view-day").classList.remove("hidden"); $("#day-label").textContent = formatDayLabel(dateStr); } function entriesByDate(entries) { const map = new Map(); for (const e of entries) { if (!map.has(e.date)) map.set(e.date, []); map.get(e.date).push(e); } return map; } function renderMoodPicker() { const root = $("#mood-picker"); root.innerHTML = ""; const moods = state.moods; for (const key of Object.keys(moods)) { const m = moods[key]; const btn = document.createElement("button"); btn.type = "button"; btn.className = "mood-option" + (state.selectedMood === key ? " selected" : ""); btn.dataset.mood = key; btn.innerHTML = `${m.emoji}${m.label}`; btn.addEventListener("click", () => { state.selectedMood = key; renderMoodPicker(); }); root.appendChild(btn); } } function renderCalendar() { const root = $("#calendar"); root.innerHTML = ""; const heads = ["一", "二", "三", "四", "五", "六", "日"]; heads.forEach((h) => { const el = document.createElement("div"); el.className = "cal-head"; el.textContent = h; root.appendChild(el); }); const [y, m] = state.month.split("-").map(Number); const first = new Date(y, m - 1, 1); const startPad = (first.getDay() + 6) % 7; const daysInMonth = new Date(y, m, 0).getDate(); const map = entriesByDate(state.monthEntries); const today = todayStr(); for (let i = 0; i < startPad; i++) { const cell = document.createElement("div"); cell.className = "cal-day other"; root.appendChild(cell); } for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${y}-${pad(m)}-${pad(day)}`; const cell = document.createElement("button"); cell.type = "button"; cell.className = "cal-day"; if (dateStr === today) cell.classList.add("today"); if (dateStr === state.selectedDate) cell.classList.add("selected"); const num = document.createElement("span"); num.textContent = String(day); cell.appendChild(num); const list = map.get(dateStr) || []; if (list.length) { const dots = document.createElement("div"); dots.className = "mood-dots"; list.forEach((entry) => { const dot = document.createElement("span"); dot.className = "mood-dot"; const mood = state.moods[entry.mood] || state.moods.neutral; dot.style.background = mood.color; dot.title = `${entry.userName} ${mood.label}`; dots.appendChild(dot); }); cell.appendChild(dots); } cell.addEventListener("click", () => openDay(dateStr)); root.appendChild(cell); } $("#month-label").textContent = `${y}年${m}月`; } function renderContentBlocks(content, target) { target.innerHTML = ""; if (!content || !content.length) { const p = document.createElement("p"); p.className = "empty-state"; p.textContent = "暂无内容"; target.appendChild(p); return; } for (const block of content) { if (block.type === "text" && block.text) { const p = document.createElement("p"); p.className = "block-text"; p.textContent = block.text; target.appendChild(p); } else if (["image", "audio", "video"].includes(block.type)) { const wrap = document.createElement("div"); wrap.className = "block-media"; const url = block.url || (block.key ? `/api/media/${block.key}` : ""); if (block.type === "image") { const img = document.createElement("img"); img.src = url; img.alt = block.name || "image"; img.loading = "lazy"; wrap.appendChild(img); } else if (block.type === "audio") { const audio = document.createElement("audio"); audio.controls = true; audio.src = url; wrap.appendChild(audio); } else { const video = document.createElement("video"); video.controls = true; video.src = url; wrap.appendChild(video); } target.appendChild(wrap); } } } function formatTime(iso) { if (!iso) return ""; const d = new Date(iso); const pad2 = (n) => String(n).padStart(2, "0"); return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } function commentsForEntry(entryId) { return state.dayComments.filter((c) => c.entryId === entryId); } function renderCommentsSection(entry) { const section = document.createElement("div"); section.className = "comments-section"; const title = document.createElement("p"); title.className = "comments-title"; const list = commentsForEntry(entry.id); title.textContent = `评论 (${list.length})`; section.appendChild(title); const ul = document.createElement("ul"); ul.className = "comment-list"; if (!list.length) { const empty = document.createElement("li"); empty.className = "comment-empty"; empty.textContent = "还没有评论,说点什么吧"; ul.appendChild(empty); } for (const c of list) { const li = document.createElement("li"); li.className = "comment-item"; const meta = document.createElement("div"); meta.className = "comment-meta"; meta.innerHTML = `${c.userName}${formatTime(c.createdAt)}`; li.appendChild(meta); const text = document.createElement("p"); text.className = "comment-text"; text.textContent = c.text; li.appendChild(text); if (c.userId === state.user.id) { const del = document.createElement("button"); del.type = "button"; del.className = "btn btn-ghost btn-sm comment-delete"; del.textContent = "删除"; del.addEventListener("click", () => deleteComment(c.id)); li.appendChild(del); } ul.appendChild(li); } section.appendChild(ul); const form = document.createElement("form"); form.className = "comment-form"; form.addEventListener("submit", (ev) => submitComment(ev, entry.id)); const input = document.createElement("input"); input.type = "text"; input.className = "comment-input"; input.placeholder = "写下你的评论…"; input.maxLength = 500; input.required = true; const btn = document.createElement("button"); btn.type = "submit"; btn.className = "btn btn-secondary btn-sm"; btn.textContent = "发送"; form.appendChild(input); form.appendChild(btn); section.appendChild(form); return section; } async function submitComment(ev, entryId) { ev.preventDefault(); const form = ev.target; const input = form.querySelector(".comment-input"); const text = input.value.trim(); if (!text) return; showLoading(true); try { const data = await api("/api/comments", { method: "POST", body: JSON.stringify({ entryId, text }), }); state.dayComments.push(data.comment); renderDayEntries(); toast("评论已发送"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function deleteComment(commentId) { if (!confirm("确定删除这条评论?")) return; showLoading(true); try { await api(`/api/comments?id=${encodeURIComponent(commentId)}`, { method: "DELETE" }); state.dayComments = state.dayComments.filter((c) => c.id !== commentId); renderDayEntries(); toast("评论已删除"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } function renderDayEntries() { const root = $("#day-entries"); root.innerHTML = ""; if (!state.dayEntries.length) { const card = document.createElement("div"); card.className = "card empty-state"; card.textContent = "这一天还没有日记,写下第一篇吧"; root.appendChild(card); } for (const entry of state.dayEntries) { const card = document.createElement("article"); card.className = "card entry-card"; const mood = state.moods[entry.mood] || state.moods.neutral; const head = document.createElement("div"); head.className = "entry-card-header"; head.innerHTML = `${entry.userName}${mood.emoji} ${mood.label}`; card.appendChild(head); const body = document.createElement("div"); renderContentBlocks(entry.content, body); card.appendChild(body); card.appendChild(renderCommentsSection(entry)); root.appendChild(card); } } function contentFromEditor() { const blocks = []; const text = $("#editor-text").value.trim(); if (text) blocks.push({ type: "text", text }); for (const m of state.mediaBlocks) blocks.push({ ...m }); return blocks; } function loadEditorFromEntry(entry) { state.myEntryId = entry ? entry.id : null; state.selectedMood = entry ? entry.mood : "neutral"; state.mediaBlocks = []; $("#editor-text").value = ""; if (entry && Array.isArray(entry.content)) { for (const block of entry.content) { if (block.type === "text") { $("#editor-text").value = block.text || ""; } else if (block.key) { state.mediaBlocks.push({ type: block.type, key: block.key, url: block.url, name: block.name, mimeType: block.mimeType, size: block.size, }); } } } $("#btn-delete").classList.toggle("hidden", !state.myEntryId); renderMoodPicker(); renderMediaBlocks(); } function renderMediaBlocks() { const root = $("#media-blocks"); root.innerHTML = ""; state.mediaBlocks.forEach((block, idx) => { const row = document.createElement("div"); row.className = "media-row"; const label = document.createElement("span"); label.textContent = `${block.type}: ${block.name || block.key}`; row.appendChild(label); const remove = document.createElement("button"); remove.type = "button"; remove.className = "btn btn-ghost btn-sm"; remove.textContent = "移除"; remove.addEventListener("click", () => { state.mediaBlocks.splice(idx, 1); renderMediaBlocks(); }); row.appendChild(remove); root.appendChild(row); }); } async function refreshMonth() { const data = await api(`/api/entries?month=${state.month}`); state.monthEntries = data.entries || []; renderCalendar(); } async function openDay(dateStr) { showDay(dateStr); showLoading(true); try { const data = await api(`/api/entries?date=${dateStr}`); state.dayEntries = data.entries || []; const commentsData = await api(`/api/comments?date=${dateStr}`); state.dayComments = commentsData.comments || []; renderDayEntries(); const mine = state.dayEntries.find((e) => e.userId === state.user.id); loadEditorFromEntry(mine || null); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function trySession() { try { const data = await api("/api/me"); state.user = data.user; if (data.moods) state.moods = data.moods; showApp(); state.month = monthKey(new Date()); await refreshMonth(); showCalendar(); return true; } catch { showLogin(); return false; } } async function onLogin(ev) { ev.preventDefault(); const username = $("#username").value.trim(); const password = $("#password").value; showLoading(true); try { const data = await api("/api/login", { method: "POST", body: JSON.stringify({ username, password }), }); state.user = data.user; await trySession(); toast("登录成功"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function onLogout() { showLoading(true); try { await api("/api/logout", { method: "POST" }); state.user = null; showLogin(); toast("已退出"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function onSave() { if (!state.selectedDate) return; const content = contentFromEditor(); if (!content.length) { toast("请输入文字或添加媒体", true); return; } showLoading(true); try { const data = await api("/api/entries", { method: "POST", body: JSON.stringify({ date: state.selectedDate, mood: state.selectedMood, content, }), }); state.myEntryId = data.entry.id; $("#btn-delete").classList.remove("hidden"); await refreshMonth(); await openDay(state.selectedDate); toast("已保存"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function onDelete() { if (!state.myEntryId || !confirm("确定删除这篇日记?")) return; showLoading(true); try { await api(`/api/entries?id=${encodeURIComponent(state.myEntryId)}`, { method: "DELETE" }); state.myEntryId = null; await refreshMonth(); await openDay(state.selectedDate); toast("已删除"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } async function onFilesSelected(ev) { const files = Array.from(ev.target.files || []); ev.target.value = ""; if (!files.length || !state.selectedDate) return; showLoading(true); try { for (const file of files) { const fd = new FormData(); fd.append("file", file); fd.append("date", state.selectedDate); const uploaded = await api("/api/upload", { method: "POST", body: fd }); state.mediaBlocks.push({ type: uploaded.type, key: uploaded.key, url: uploaded.url, name: uploaded.name, mimeType: uploaded.mimeType, size: uploaded.size, }); } renderMediaBlocks(); toast("上传成功"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } function loadJSZip() { return new Promise((resolve, reject) => { if (window.JSZip) return resolve(window.JSZip); const s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"; s.onload = () => resolve(window.JSZip); s.onerror = () => reject(new Error("JSZip 加载失败")); document.head.appendChild(s); }); } function downloadBlob(blob, filename) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } async function onExport() { showLoading(true); try { const data = await api("/api/export"); const JSZip = await loadJSZip(); const zip = new JSZip(); zip.file("diary-export.json", JSON.stringify(data, null, 2)); const media = zip.folder("media"); const keys = new Set(); for (const entry of data.entries || []) { for (const block of entry.content || []) { if (block.key) keys.add(block.key); } } for (const key of keys) { const res = await fetch(`/api/media/${key}`, { credentials: "include" }); if (!res.ok) continue; const blob = await res.blob(); const safeName = key.replace(/\//g, "_"); media.file(safeName, blob); } const out = await zip.generateAsync({ type: "blob" }); const stamp = new Date().toISOString().slice(0, 10); downloadBlob(out, `diary-backup-${stamp}.zip`); downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }), `diary-export-${stamp}.json`); toast("导出完成"); } catch (err) { toast(err.message, true); } finally { showLoading(false); } } function shiftMonth(delta) { const [y, m] = state.month.split("-").map(Number); const d = new Date(y, m - 1 + delta, 1); state.month = monthKey(d); refreshMonth().catch((e) => toast(e.message, true)); } function bindEvents() { $("#login-form").addEventListener("submit", onLogin); $("#btn-logout").addEventListener("click", onLogout); $("#btn-export").addEventListener("click", onExport); $("#btn-save").addEventListener("click", onSave); $("#btn-delete").addEventListener("click", onDelete); $("#file-input").addEventListener("change", onFilesSelected); $("#btn-back-calendar").addEventListener("click", () => { showCalendar(); renderCalendar(); }); $("#btn-prev-month").addEventListener("click", () => shiftMonth(-1)); $("#btn-next-month").addEventListener("click", () => shiftMonth(1)); } bindEvents(); renderMoodPicker(); trySession();