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 = ``;
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();