notes / app.js
bot
add paste image support
2f60066
Raw
History Blame Contribute Delete
52.2 kB
import { Editor } from "@tiptap/core";
import TiptapImage from "@tiptap/extension-image";
import TextAlign from "@tiptap/extension-text-align";
import { Markdown } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { stringify as stringifyUuid } from "uuid";
const uiStorageKey = "memo-ui-state-v1";
if (new URLSearchParams(window.location.search).get("device") === "mobile") {
document.documentElement.classList.add("force-mobile");
}
const starterNotes = [
{
id: createId(),
folder: "notes",
body: "欢迎使用备忘录\n\n左侧选择文件夹,中间选择笔记,右侧直接编辑。第一行会自动作为标题。",
createdAt: Date.now() - 1000 * 60 * 60 * 24,
updatedAt: Date.now() - 1000 * 60 * 12,
version: 1
},
{
id: createId(),
folder: "notes",
body: "待办清单\n\n- 记录想法\n- 整理项目\n- 做一个真正好用的 Web 版笔记",
createdAt: Date.now() - 1000 * 60 * 60 * 3,
updatedAt: Date.now() - 1000 * 60 * 35,
version: 1
}
];
const authStatus = await getAuthStatus();
const state = authStatus.authenticated
? await loadState()
: hydrateState({ notes: [], folders: defaultFolders() });
let saveTimer = null;
let saveTimerNoteId = null;
let saveInFlightNoteId = null;
let queuedSaveNoteId = null;
let saveStatusTimer = null;
let tiptapEditor = null;
let syncingEditor = false;
const saveDelay = 700;
const maxNoteBodyLength = 10 * 1024 * 1024;
const maxPastedImageDimension = 1600;
const pastedImageQuality = 0.82;
const AlignedImage = TiptapImage.extend({
addAttributes() {
return {
...this.parent?.(),
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align") || "center",
renderHTML: (attributes) => ({
"data-align": attributes.align || "center"
})
}
};
}
});
const els = {
authGate: document.querySelector("#authGate"),
authForm: document.querySelector("#authForm"),
authTitle: document.querySelector("#authTitle"),
authPassword: document.querySelector("#authPassword"),
authSubmit: document.querySelector("#authSubmit"),
authMessage: document.querySelector("#authMessage"),
passwordGate: document.querySelector("#passwordGate"),
passwordForm: document.querySelector("#passwordForm"),
currentPassword: document.querySelector("#currentPassword"),
nextPassword: document.querySelector("#nextPassword"),
confirmPassword: document.querySelector("#confirmPassword"),
cancelPassword: document.querySelector("#cancelPassword"),
submitPassword: document.querySelector("#submitPassword"),
passwordMessage: document.querySelector("#passwordMessage"),
folderGate: document.querySelector("#folderGate"),
folderForm: document.querySelector("#folderForm"),
folderTitle: document.querySelector("#folderTitle"),
folderName: document.querySelector("#folderName"),
cancelFolder: document.querySelector("#cancelFolder"),
submitFolder: document.querySelector("#submitFolder"),
folderMessage: document.querySelector("#folderMessage"),
folderList: document.querySelector("#folderList"),
newFolder: document.querySelector("#newFolder"),
listTitle: document.querySelector("#listTitle"),
listCount: document.querySelector("#listCount"),
noteList: document.querySelector("#noteList"),
newNote: document.querySelector("#newNote"),
deleteNote: document.querySelector("#deleteNote"),
previewToggle: document.querySelector("#previewToggle"),
changePassword: document.querySelector("#changePassword"),
logout: document.querySelector("#logout"),
searchInput: document.querySelector("#searchInput"),
mobileBack: document.querySelector("#mobileBack"),
mobileBackLabel: document.querySelector("#mobileBackLabel"),
mobileTitle: document.querySelector("#mobileTitle"),
mobileSearchInput: document.querySelector("#mobileSearchInput"),
mobileSearchWrap: document.querySelector("#mobileSearchWrap"),
mobileEdit: document.querySelector("#mobileEdit"),
mobileEditorMenuToggle: document.querySelector("#mobileEditorMenuToggle"),
mobileEditorFormatMenu: document.querySelector("#mobileEditorFormatMenu"),
mobilePreviewToggle: document.querySelector("#mobilePreviewToggle"),
mobileSave: document.querySelector("#mobileSave"),
mobileMore: document.querySelector("#mobileMore"),
mobileActionMenu: document.querySelector("#mobileActionMenu"),
mobileMenuPreview: document.querySelector("#mobileMenuPreview"),
mobileMenuSave: document.querySelector("#mobileMenuSave"),
mobileMenuDelete: document.querySelector("#mobileMenuDelete"),
mobileNoteFolderSelect: document.querySelector("#mobileNoteFolderSelect"),
appShell: document.querySelector(".app-shell"),
editor: document.querySelector("#editor"),
noteFolderSelect: document.querySelector("#noteFolderSelect"),
editorMenuToggle: document.querySelector("#editorMenuToggle"),
editorFormatMenu: document.querySelector("#editorFormatMenu"),
editorPreviewToggle: document.querySelector("#editorPreviewToggle"),
saveNote: document.querySelector("#saveNote"),
saveStatus: document.querySelector("#saveStatus"),
editedAt: document.querySelector("#editedAt"),
markdownPreview: document.querySelector("#markdownPreview"),
editorPane: document.querySelector(".editor-pane")
};
initTiptapEditor();
render();
if (authStatus.authenticated) {
hideAuthGate();
} else {
showAuthGate(authStatus.configured);
}
window.addEventListener("beforeunload", flushPendingSave);
els.authForm.addEventListener("submit", async (event) => {
event.preventDefault();
const password = els.authPassword.value;
els.authSubmit.disabled = true;
els.authMessage.textContent = "";
try {
const path = authStatus.configured ? "/api/auth/login" : "/api/auth/setup";
const response = await fetch(path, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({ password })
});
if (!response.ok) {
const message = response.status === 401 ? "密码不正确" : "密码至少需要 8 位";
els.authMessage.textContent = message;
return;
}
location.reload();
} finally {
els.authSubmit.disabled = false;
}
});
els.newNote.addEventListener("click", async () => {
const now = Date.now();
const note = {
id: createId(),
folder: getWriteFolderId(),
body: "",
createdAt: now,
updatedAt: now,
version: 1
};
state.notes.unshift(note);
if (state.activeFolder === "recent") state.activeFolder = note.folder;
state.selectedId = note.id;
state.mobileView = "editor";
render();
els.editor.focus();
try {
setSaveStatus("保存中");
const saved = await createNote(note);
Object.assign(note, saved);
setSaveStatus("已保存", "ok");
renderListOnly();
renderEditor();
} catch (error) {
setSaveStatus("保存失败", "error");
console.error("Failed to create note.", error);
}
});
els.deleteNote.addEventListener("click", async () => {
const current = getSelectedNote();
if (!current) return;
const visible = getVisibleNotes();
const index = visible.findIndex((note) => note.id === current.id);
state.notes = state.notes.filter((note) => note.id !== current.id);
const next = visible[index + 1] || visible[index - 1];
state.selectedId = next && state.notes.some((note) => note.id === next.id) ? next.id : null;
render();
try {
await deleteNote(current.id);
setSaveStatus("已删除", "ok");
} catch (error) {
state.notes.unshift(current);
state.selectedId = current.id;
setSaveStatus("删除失败", "error");
render();
console.error("Failed to delete note.", error);
}
});
els.searchInput.addEventListener("input", (event) => {
state.query = event.target.value;
const visible = getVisibleNotes();
if (!visible.some((note) => note.id === state.selectedId)) {
state.selectedId = visible[0]?.id || null;
}
saveUiState();
render();
});
els.mobileSearchInput.addEventListener("input", (event) => {
if (state.mobileView === "folders") {
state.folderQuery = event.target.value;
} else {
state.query = event.target.value;
const visible = getVisibleNotes();
if (!visible.some((note) => note.id === state.selectedId)) {
state.selectedId = visible[0]?.id || null;
}
}
saveUiState();
render();
});
els.mobileBack.addEventListener("click", () => {
if (state.mobileView === "editor") {
state.mobileView = "list";
} else if (state.mobileView === "list") {
state.mobileView = "folders";
}
saveUiState();
render();
});
els.mobileEdit.addEventListener("click", () => {
els.newNote.click();
});
els.mobileSave.addEventListener("click", () => {
if (!getSelectedNote()) return;
saveSelectedNote({ immediate: true });
});
els.mobilePreviewToggle.addEventListener("click", togglePreviewMode);
els.editorMenuToggle.addEventListener("click", (event) => {
event.stopPropagation();
toggleEditorMenu("desktop");
});
els.mobileEditorMenuToggle.addEventListener("click", (event) => {
event.stopPropagation();
toggleEditorMenu("mobile");
});
[...document.querySelectorAll("[data-editor-command]")].forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
runEditorCommand(button.dataset.editorCommand);
});
});
els.mobileMore.addEventListener("click", (event) => {
event.stopPropagation();
els.mobileActionMenu.hidden = !els.mobileActionMenu.hidden;
});
els.mobileMenuPreview.addEventListener("click", () => {
els.mobileActionMenu.hidden = true;
togglePreviewMode();
});
els.mobileMenuSave.addEventListener("click", () => {
els.mobileActionMenu.hidden = true;
if (getSelectedNote()) saveSelectedNote({ immediate: true });
});
els.mobileMenuDelete.addEventListener("click", () => {
els.mobileActionMenu.hidden = true;
els.deleteNote.click();
});
document.addEventListener("click", (event) => {
if (!els.mobileActionMenu.hidden && !event.target.closest("#mobileActions")) {
els.mobileActionMenu.hidden = true;
}
if (!event.target.closest(".editor-menu-wrap")) {
els.editorFormatMenu.hidden = true;
}
if (!event.target.closest(".mobile-editor-menu-wrap")) {
els.mobileEditorFormatMenu.hidden = true;
}
});
els.newFolder.addEventListener("click", () => {
openFolderGate({
mode: "create",
name: getUniqueFolderName("新建文件夹")
});
});
els.noteFolderSelect.addEventListener("change", (event) => {
moveSelectedNoteToFolder(event.target.value);
});
els.saveNote.addEventListener("click", () => {
if (!getSelectedNote()) return;
saveSelectedNote({ immediate: true });
});
els.mobileNoteFolderSelect.addEventListener("change", (event) => {
els.mobileActionMenu.hidden = true;
moveSelectedNoteToFolder(event.target.value);
});
els.previewToggle.addEventListener("click", togglePreviewMode);
els.editorPreviewToggle.addEventListener("click", togglePreviewMode);
els.logout.addEventListener("click", async () => {
await fetch("/api/auth/logout", { method: "POST" });
location.reload();
});
els.changePassword.addEventListener("click", async () => {
showPasswordGate();
});
els.cancelPassword.addEventListener("click", hidePasswordGate);
els.cancelFolder.addEventListener("click", hideFolderGate);
els.folderForm.addEventListener("submit", async (event) => {
event.preventDefault();
const mode = els.folderForm.dataset.mode;
const folderId = els.folderForm.dataset.folderId;
const name = els.folderName.value.trim();
if (!name) {
els.folderMessage.textContent = "请输入文件夹名称";
return;
}
els.submitFolder.disabled = true;
els.folderMessage.textContent = "";
try {
if (mode === "rename" && folderId) {
await submitFolderRename(folderId, name);
} else {
await submitFolderCreate(name);
}
} finally {
els.submitFolder.disabled = false;
}
});
els.passwordForm.addEventListener("submit", async (event) => {
event.preventDefault();
const currentPassword = els.currentPassword.value;
const nextPassword = els.nextPassword.value;
const confirmPassword = els.confirmPassword.value;
els.passwordMessage.textContent = "";
if (nextPassword !== confirmPassword) {
els.passwordMessage.textContent = "两次输入的新密码不一致";
return;
}
els.submitPassword.disabled = true;
try {
const response = await fetch("/api/auth/password", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ currentPassword, nextPassword })
});
if (!response.ok) {
const message = response.status === 401 ? "当前密码不正确" : "新密码至少需要 8 位";
els.passwordMessage.textContent = message;
return;
}
hidePasswordGate();
window.alert("密码已修改");
} catch (error) {
els.passwordMessage.textContent = "修改失败,请稍后再试";
console.error("Failed to change password.", error);
} finally {
els.submitPassword.disabled = false;
}
});
function moveSelectedNoteToFolder(nextFolder) {
const note = getSelectedNote();
if (!note) return;
if (!state.folders.some((folder) => folder.id === nextFolder)) return;
note.folder = nextFolder;
note.updatedAt = Date.now();
state.activeFolder = nextFolder;
saveSelectedNote({ immediate: true });
render();
}
function togglePreviewMode() {
state.previewMode = !state.previewMode;
saveUiState();
hideEditorMenus();
renderEditor();
renderMobileChrome();
}
function toggleEditorMenu(target) {
const note = getSelectedNote();
if (!note || state.previewMode) return;
const menu = target === "mobile" ? els.mobileEditorFormatMenu : els.editorFormatMenu;
const otherMenu = target === "mobile" ? els.editorFormatMenu : els.mobileEditorFormatMenu;
otherMenu.hidden = true;
menu.hidden = !menu.hidden;
els.editorMenuToggle.classList.toggle("active", !els.editorFormatMenu.hidden);
els.mobileEditorMenuToggle.classList.toggle("active", !els.mobileEditorFormatMenu.hidden);
renderEditorMenuState();
}
function hideEditorMenus() {
els.editorFormatMenu.hidden = true;
els.mobileEditorFormatMenu.hidden = true;
els.editorMenuToggle.classList.remove("active");
els.mobileEditorMenuToggle.classList.remove("active");
}
function runEditorCommand(command) {
if (!tiptapEditor || state.previewMode || !getSelectedNote()) return;
const align = {
alignLeft: "left",
alignCenter: "center",
alignRight: "right"
}[command];
if (align) {
setCurrentAlignment(align);
renderEditorMenuState();
return;
}
const chain = tiptapEditor.chain().focus();
const commands = {
paragraph: () => chain.setParagraph().run(),
heading1: () => chain.toggleHeading({ level: 1 }).run(),
heading2: () => chain.toggleHeading({ level: 2 }).run(),
bold: () => chain.toggleBold().run(),
italic: () => chain.toggleItalic().run(),
bulletList: () => chain.toggleBulletList().run(),
orderedList: () => chain.toggleOrderedList().run()
};
commands[command]?.();
renderEditorMenuState();
}
function setCurrentAlignment(align) {
if (isImageSelected()) {
setImageAlignment(align);
return;
}
tiptapEditor.chain().focus().setTextAlign(align).run();
}
function setImageAlignment(align) {
if (!isImageSelected()) return;
tiptapEditor.chain().focus().updateAttributes("image", { align }).run();
}
function renderEditorMenuState(editor = tiptapEditor) {
if (!editor) return;
const disabled = !getSelectedNote() || state.previewMode;
els.editorMenuToggle.disabled = disabled;
els.mobileEditorMenuToggle.disabled = disabled;
if (disabled) hideEditorMenus();
const activeCommands = new Set();
if (editor.isActive("paragraph")) activeCommands.add("paragraph");
if (editor.isActive("heading", { level: 1 })) activeCommands.add("heading1");
if (editor.isActive("heading", { level: 2 })) activeCommands.add("heading2");
if (editor.isActive("bold")) activeCommands.add("bold");
if (editor.isActive("italic")) activeCommands.add("italic");
if (editor.isActive("bulletList")) activeCommands.add("bulletList");
if (editor.isActive("orderedList")) activeCommands.add("orderedList");
const imageAlign = getSelectedImageAlign();
const textAlign = editor.getAttributes("paragraph").textAlign || editor.getAttributes("heading").textAlign;
const align = imageAlign || textAlign || "left";
activeCommands.add(`align${align[0].toUpperCase()}${align.slice(1)}`);
document.querySelectorAll("[data-editor-command]").forEach((button) => {
const isActive = activeCommands.has(button.dataset.editorCommand);
button.classList.toggle("active", isActive);
button.disabled = disabled;
});
}
function isImageSelected() {
return tiptapEditor?.state.selection.node?.type.name === "image";
}
function getSelectedImageAlign() {
if (!isImageSelected()) return "";
return tiptapEditor.state.selection.node.attrs.align || "center";
}
function initTiptapEditor() {
tiptapEditor = new Editor({
element: els.editor,
extensions: [
StarterKit,
AlignedImage.configure({
allowBase64: true,
HTMLAttributes: {
class: "memo-image"
}
}),
TextAlign.configure({
types: ["heading", "paragraph"]
}),
Markdown
],
content: "",
editorProps: {
handlePaste(_view, event) {
const files = [...(event.clipboardData?.files || [])].filter((file) => file.type.startsWith("image/"));
if (!files.length) return false;
event.preventDefault();
insertPastedImages(files);
return true;
}
},
onUpdate({ editor }) {
if (syncingEditor) return;
const note = getSelectedNote();
if (!note) return;
const nextBody = normalizeEditorHtml(editor.getHTML());
if (nextBody.length > maxNoteBodyLength) {
setSaveStatus("内容已达上限", "error");
return;
}
note.body = nextBody;
note.updatedAt = Date.now();
saveSelectedNote();
renderListOnly();
updateEditorMeta(note);
renderMarkdownPreview(note);
renderEditorMenuState(editor);
},
onSelectionUpdate({ editor }) {
renderEditorMenuState(editor);
}
});
}
async function insertPastedImages(files) {
if (!tiptapEditor || !getSelectedNote()) return;
setSaveStatus("处理图片");
for (const file of files) {
try {
const dataUrl = await imageFileToDataUrl(file);
const currentLength = normalizeEditorHtml(tiptapEditor.getHTML()).length;
if (currentLength + dataUrl.length > maxNoteBodyLength) {
setSaveStatus("图片太大", "error");
continue;
}
tiptapEditor
.chain()
.focus()
.insertContent([
{
type: "image",
attrs: {
src: dataUrl,
alt: file.name || "粘贴的图片",
title: file.name || null,
align: "center"
}
},
{ type: "paragraph" }
])
.run();
} catch (error) {
setSaveStatus("图片粘贴失败", "error");
console.error("Failed to paste image.", error);
}
}
}
async function imageFileToDataUrl(file) {
if (file.type === "image/gif") {
return await readFileAsDataUrl(file);
}
const image = await loadImage(file);
const scale = Math.min(
1,
maxPastedImageDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height)
);
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas is unavailable.");
context.drawImage(image, 0, 0, width, height);
URL.revokeObjectURL(image.src);
const webp = canvas.toDataURL("image/webp", pastedImageQuality);
if (webp.startsWith("data:image/webp")) return webp;
return canvas.toDataURL("image/jpeg", pastedImageQuality);
}
function loadImage(file) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = URL.createObjectURL(file);
});
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "n") {
event.preventDefault();
els.newNote.click();
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
event.preventDefault();
els.searchInput.focus();
els.searchInput.select();
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
if (getSelectedNote()) saveSelectedNote({ immediate: true });
}
if (event.key === "Delete" && !els.editor.contains(document.activeElement)) {
els.deleteNote.click();
}
if (event.key === "Escape") {
hideEditorMenus();
}
});
async function loadState() {
try {
const response = await fetch("/api/state");
if (!response.ok) throw new Error(`State API returned ${response.status}`);
return hydrateState(await response.json());
} catch (error) {
console.warn("Falling back to starter notes because the API is unavailable.", error);
return hydrateState({ notes: starterNotes, folders: defaultFolders() });
}
}
async function getAuthStatus() {
try {
const response = await fetch("/api/auth/status");
if (!response.ok) throw new Error(`Auth API returned ${response.status}`);
return await response.json();
} catch (error) {
console.warn("Auth API unavailable.", error);
return { configured: false, authenticated: false };
}
}
function showAuthGate(configured) {
els.authGate.setAttribute("aria-busy", "false");
els.authTitle.textContent = configured ? "登录" : "设置密码";
els.authPassword.autocomplete = configured ? "current-password" : "new-password";
els.authPassword.placeholder = configured ? "密码" : "设置至少 8 位密码";
els.authSubmit.textContent = configured ? "登录" : "创建";
els.authGate.hidden = false;
els.authPassword.focus();
}
function hideAuthGate() {
els.authGate.hidden = true;
els.authGate.setAttribute("aria-busy", "false");
}
function showPasswordGate() {
els.passwordForm.reset();
els.passwordMessage.textContent = "";
els.passwordGate.hidden = false;
els.currentPassword.focus();
}
function hidePasswordGate() {
els.passwordGate.hidden = true;
els.passwordForm.reset();
els.passwordMessage.textContent = "";
}
function openFolderGate({ mode, name, folderId = "" }) {
els.folderForm.dataset.mode = mode;
els.folderForm.dataset.folderId = folderId;
els.folderTitle.textContent = mode === "rename" ? "重命名文件夹" : "新建文件夹";
els.submitFolder.textContent = mode === "rename" ? "保存" : "创建";
els.folderName.value = name || "";
els.folderMessage.textContent = "";
els.folderGate.hidden = false;
requestAnimationFrame(() => {
els.folderName.focus();
els.folderName.select();
});
}
function hideFolderGate() {
els.folderGate.hidden = true;
els.folderForm.reset();
els.folderForm.dataset.mode = "";
els.folderForm.dataset.folderId = "";
els.folderMessage.textContent = "";
}
function saveSelectedNote({ immediate = false } = {}) {
const note = getSelectedNote();
if (!note) return;
queueNoteSave(note.id, { immediate });
}
function flushPendingSave() {
saveUiState();
if (!saveTimer) return;
const noteId = saveTimerNoteId;
clearTimeout(saveTimer);
saveTimer = null;
saveTimerNoteId = null;
if (noteId) sendNote(noteId, { keepalive: true });
}
function queueNoteSave(noteId, { immediate = false } = {}) {
saveUiState();
if (saveTimer) clearTimeout(saveTimer);
saveTimerNoteId = noteId;
if (immediate) {
saveTimer = null;
saveTimerNoteId = null;
sendNote(noteId);
return;
}
saveTimer = setTimeout(() => {
const queuedNoteId = saveTimerNoteId;
saveTimer = null;
saveTimerNoteId = null;
if (queuedNoteId) sendNote(queuedNoteId);
}, saveDelay);
setSaveStatus("未保存");
}
async function sendNote(noteId, { keepalive = false } = {}) {
const note = state.notes.find((entry) => entry.id === noteId);
if (!note) return;
if (saveInFlightNoteId) {
queuedSaveNoteId = noteId;
return;
}
setSaveStatus("保存中");
const snapshot = {
body: note.body,
folder: note.folder,
updatedAt: note.updatedAt
};
saveInFlightNoteId = noteId;
try {
const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, {
method: "PATCH",
keepalive,
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
folder: note.folder,
body: note.body,
updatedAt: note.updatedAt,
version: note.version
})
});
const payload = await response.json().catch(() => ({}));
if (response.status === 409 && payload.note) {
handleNoteConflict(note, payload.note);
return;
}
if (!response.ok) {
throw new Error(payload.error || `Save API returned ${response.status}`);
}
const noteChangedSinceSend =
note.body !== snapshot.body ||
note.folder !== snapshot.folder ||
note.updatedAt !== snapshot.updatedAt;
if (noteChangedSinceSend) {
note.version = payload.version;
} else {
Object.assign(note, payload);
}
setSaveStatus(noteChangedSinceSend ? "继续保存中" : "已保存", noteChangedSinceSend ? "" : "ok");
renderListOnly();
renderEditor();
} catch (error) {
setSaveStatus("保存失败", "error");
console.error("Failed to save note.", error);
} finally {
saveInFlightNoteId = null;
if (queuedSaveNoteId) {
const nextNoteId = queuedSaveNoteId;
queuedSaveNoteId = null;
sendNote(nextNoteId);
}
}
}
async function createNote(note) {
const response = await fetch("/api/notes", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(note)
});
if (!response.ok) throw new Error(`Create note API returned ${response.status}`);
return await response.json();
}
async function deleteNote(noteId) {
const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, { method: "DELETE" });
if (!response.ok) throw new Error(`Delete note API returned ${response.status}`);
}
async function createFolder(folder) {
const response = await fetch("/api/folders", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(folder)
});
if (!response.ok) throw new Error(`Create folder API returned ${response.status}`);
return await response.json();
}
async function updateFolder(folderId, patch) {
const response = await fetch(`/api/folders/${encodeURIComponent(folderId)}`, {
method: "PATCH",
headers: {
"content-type": "application/json"
},
body: JSON.stringify(patch)
});
const payload = await response.json().catch(() => ({}));
if (response.status === 409 && payload.folder) {
return { status: "conflict", folder: payload.folder };
}
if (!response.ok) throw new Error(`Update folder API returned ${response.status}`);
return payload;
}
async function removeFolder(folderId) {
const response = await fetch(`/api/folders/${encodeURIComponent(folderId)}`, { method: "DELETE" });
if (!response.ok) throw new Error(`Delete folder API returned ${response.status}`);
return await response.json();
}
function handleNoteConflict(localNote, serverNote) {
setSaveStatus("有冲突", "conflict");
const useServer = window.confirm("这条笔记已在其他设备修改。确定载入服务器版本吗?取消则保留本机内容,稍后可再次保存。");
if (useServer) {
Object.assign(localNote, serverNote);
setSaveStatus("已载入服务器版本", "ok");
render();
return;
}
localNote.version = serverNote.version;
setSaveStatus("保留本机内容", "conflict");
}
function setSaveStatus(text, type = "") {
els.saveStatus.textContent = text;
els.saveStatus.className = `save-status${type ? ` is-${type}` : ""}`;
if (saveStatusTimer) clearTimeout(saveStatusTimer);
if (type === "ok") {
saveStatusTimer = setTimeout(() => {
els.saveStatus.textContent = "";
els.saveStatus.className = "save-status";
}, 1800);
}
}
function hydrateState(data) {
const folders = Array.isArray(data.folders) && data.folders.length ? data.folders : defaultFolders();
const notes = Array.isArray(data.notes)
? data.notes.map((note) => ({ ...note, version: Number(note.version || 1) }))
: [];
const ui = loadUiState();
const selectedId = notes.some((note) => note.id === ui.selectedId) ? ui.selectedId : notes[0]?.id || null;
const selectedNote = notes.find((note) => note.id === selectedId);
const validFolders = new Set(["all", "recent", ...folders.map((folder) => folder.id)]);
let activeFolder = validFolders.has(ui.activeFolder)
? ui.activeFolder
: selectedNote?.folder || "all";
if (selectedNote && activeFolder !== "all" && activeFolder !== "recent" && activeFolder !== selectedNote.folder) {
activeFolder = selectedNote.folder;
}
if (selectedNote && activeFolder === "recent" && !isRecentNote(selectedNote)) {
activeFolder = selectedNote.folder;
}
const mobileView = ["folders", "list", "editor"].includes(ui.mobileView)
? ui.mobileView
: selectedId ? "editor" : "folders";
return {
notes,
folders,
activeFolder,
selectedId,
query: "",
folderQuery: "",
mobileView,
previewMode: Boolean(ui.previewMode)
};
}
function loadUiState() {
try {
return JSON.parse(localStorage.getItem(uiStorageKey)) || {};
} catch {
return {};
}
}
function saveUiState() {
localStorage.setItem(uiStorageKey, JSON.stringify({
activeFolder: state.activeFolder,
selectedId: state.selectedId,
mobileView: state.mobileView,
previewMode: state.previewMode
}));
}
function render() {
els.searchInput.value = state.query;
renderFolders();
renderListOnly();
renderEditor();
renderMobileChrome();
}
function renderFolders() {
const now = Date.now();
const recentCutoff = now - 1000 * 60 * 60 * 24 * 7;
const allFolder = { id: "all", name: "所有笔记", icon: "▣", count: state.notes.length };
const recentFolder = {
id: "recent",
name: "最近编辑",
icon: "clock-3",
count: state.notes.filter((note) => note.updatedAt >= recentCutoff).length
};
allFolder.icon = "file-text";
const folderQuery = state.mobileView === "folders" ? state.folderQuery.trim().toLowerCase() : "";
const userFolders = state.folders
.filter((folder) => !folderQuery || folder.name.toLowerCase().includes(folderQuery))
.map((folder) => ({
id: folder.id,
name: folder.name,
icon: "folder",
count: state.notes.filter((note) => note.folder === folder.id).length,
manageable: folder.id !== "notes"
}));
const smartFolders = [allFolder, recentFolder].filter((folder) => (
!folderQuery || folder.name.toLowerCase().includes(folderQuery)
));
els.folderList.replaceChildren(...[...smartFolders, ...userFolders].map(renderFolderItem));
}
function renderListOnly() {
const visible = getVisibleNotes();
if (!visible.some((note) => note.id === state.selectedId)) {
state.selectedId = visible[0]?.id || null;
}
const labels = {
all: "所有笔记",
recent: "最近编辑"
};
const activeFolder = state.folders.find((folder) => folder.id === state.activeFolder);
els.listTitle.textContent = labels[state.activeFolder] || activeFolder?.name || "所有笔记";
els.listCount.textContent = `${visible.length} 个笔记`;
els.deleteNote.disabled = !state.selectedId;
els.noteList.replaceChildren(...renderGroupedNotes(visible));
renderFolders();
}
function renderGroupedNotes(notes) {
const nodes = [];
let currentGroup = "";
notes.forEach((note) => {
const group = formatSectionDate(note.updatedAt);
if (group !== currentGroup) {
currentGroup = group;
const heading = document.createElement("div");
heading.className = "note-section-title";
heading.textContent = group;
nodes.push(heading);
}
nodes.push(renderNoteButton(note));
});
return nodes;
}
function renderFolderItem(folder) {
const item = document.createElement("div");
item.className = `folder-item${folder.manageable ? " can-manage" : ""}`;
const button = document.createElement("button");
button.className = `folder-row${folder.id === state.activeFolder ? " active" : ""}`;
button.type = "button";
button.dataset.folder = folder.id;
const icon = document.createElement("i");
icon.className = "folder-icon";
icon.dataset.lucide = folder.icon;
icon.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "folder-name";
name.textContent = folder.name;
const count = document.createElement("span");
count.className = "folder-count";
count.textContent = folder.count;
button.append(icon, name, count);
button.addEventListener("click", () => {
state.activeFolder = folder.id;
const visible = getVisibleNotes();
state.selectedId = visible[0]?.id || null;
state.mobileView = "list";
saveUiState();
render();
});
item.append(button);
if (folder.manageable) {
const editButton = document.createElement("button");
editButton.className = "folder-edit";
editButton.type = "button";
editButton.setAttribute("aria-label", `重命名${folder.name}`);
editButton.title = "重命名文件夹";
editButton.innerHTML = '<i data-lucide="pencil" aria-hidden="true"></i>';
editButton.addEventListener("click", (event) => {
event.stopPropagation();
openFolderGate({
mode: "rename",
folderId: folder.id,
name: folder.name
});
});
item.append(editButton);
const deleteButton = document.createElement("button");
deleteButton.className = "folder-delete";
deleteButton.type = "button";
deleteButton.setAttribute("aria-label", `删除${folder.name}`);
deleteButton.title = "删除文件夹";
deleteButton.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
deleteButton.addEventListener("click", (event) => {
event.stopPropagation();
deleteFolder(folder.id);
});
item.append(deleteButton);
}
return item;
}
function renderNoteButton(note) {
const button = document.createElement("button");
button.type = "button";
button.className = `note-card${note.id === state.selectedId ? " selected" : ""}`;
button.dataset.noteId = note.id;
button.setAttribute("aria-label", getTitle(note));
const title = document.createElement("span");
title.className = "note-title";
title.textContent = getTitle(note);
const preview = document.createElement("span");
preview.className = "note-preview";
preview.innerHTML = `<span class="note-date">${formatListDate(note.updatedAt)}</span> ${getPreview(note)}`;
button.append(title, preview);
button.addEventListener("click", () => {
state.selectedId = note.id;
state.mobileView = "editor";
saveUiState();
render();
els.editor.focus();
});
return button;
}
function renderEditor() {
const note = getSelectedNote();
els.editorPane.classList.toggle("empty", !note);
els.saveNote.disabled = !note;
els.mobileSave.disabled = !note;
els.editorMenuToggle.disabled = !note || state.previewMode;
els.mobileEditorMenuToggle.disabled = !note || state.previewMode;
els.editorPreviewToggle.disabled = !note;
els.mobilePreviewToggle.disabled = !note;
els.mobileMore.disabled = !note;
els.mobileMenuSave.disabled = !note;
els.mobileMenuPreview.disabled = !note;
els.mobileMenuDelete.disabled = !note;
els.previewToggle.classList.toggle("active", state.previewMode);
els.editorPreviewToggle.classList.toggle("active", state.previewMode);
els.mobilePreviewToggle.classList.toggle("active", state.previewMode);
els.mobileMenuPreview.classList.toggle("active", state.previewMode);
if (!note) {
els.mobileActionMenu.hidden = true;
hideEditorMenus();
setEditorContent("");
tiptapEditor?.setEditable(false, false);
els.editor.hidden = false;
els.editedAt.textContent = "";
els.markdownPreview.hidden = true;
els.noteFolderSelect.replaceChildren();
els.mobileNoteFolderSelect.replaceChildren();
els.noteFolderSelect.disabled = true;
els.mobileNoteFolderSelect.disabled = true;
return;
}
setEditorContent(note.body);
tiptapEditor?.setEditable(!state.previewMode, false);
els.editor.hidden = state.previewMode;
els.markdownPreview.hidden = !state.previewMode;
renderMarkdownPreview(note);
renderNoteFolderSelect(note);
updateEditorMeta(note);
renderEditorMenuState();
}
function setEditorContent(body) {
if (!tiptapEditor) return;
const currentHtml = normalizeEditorHtml(tiptapEditor.getHTML());
if (currentHtml === normalizeEditorHtml(body) || (!isHtmlNoteBody(body) && currentHtml === normalizeEditorHtml(markdownToHtml(body)))) {
return;
}
syncingEditor = true;
tiptapEditor.commands.setContent(body || "", {
contentType: isHtmlNoteBody(body) ? "html" : "markdown",
emitUpdate: false
});
syncingEditor = false;
}
function renderNoteFolderSelect(note) {
const options = state.folders.map((folder) => {
const option = document.createElement("option");
option.value = folder.id;
option.textContent = folder.name;
option.selected = folder.id === note.folder;
return option;
});
const mobileOptions = options.map((option) => option.cloneNode(true));
els.noteFolderSelect.disabled = false;
els.mobileNoteFolderSelect.disabled = false;
els.noteFolderSelect.replaceChildren(...options);
els.mobileNoteFolderSelect.replaceChildren(...mobileOptions);
}
function renderMobileChrome() {
if (!["folders", "list", "editor"].includes(state.mobileView)) {
state.mobileView = "folders";
}
els.appShell.dataset.mobileView = state.mobileView;
const activeLabel = getActiveFolderLabel();
const selected = getSelectedNote();
if (state.mobileView === "folders") {
els.mobileTitle.textContent = "文件夹";
els.mobileBackLabel.textContent = "返回";
els.mobileBack.classList.add("is-hidden");
els.mobileSearchInput.value = state.folderQuery;
els.mobileSearchInput.placeholder = "搜索文件夹";
} else if (state.mobileView === "list") {
els.mobileTitle.textContent = activeLabel;
els.mobileBackLabel.textContent = "文件夹";
els.mobileBack.classList.remove("is-hidden");
els.mobileSearchInput.value = state.query;
els.mobileSearchInput.placeholder = "搜索";
} else {
els.mobileTitle.textContent = selected ? getTitle(selected) : "笔记";
els.mobileBackLabel.textContent = activeLabel;
els.mobileBack.classList.remove("is-hidden");
els.mobileSearchInput.value = state.query;
els.mobileSearchInput.placeholder = "搜索";
}
if (state.mobileView !== "editor") {
els.mobileActionMenu.hidden = true;
}
renderIcons();
}
function updateEditorMeta(note) {
els.editedAt.dateTime = new Date(note.updatedAt).toISOString();
els.editedAt.textContent = `编辑于 ${formatFullDate(note.updatedAt)}`;
}
function getVisibleNotes() {
const query = state.query.trim().toLowerCase();
return state.notes
.filter((note) => {
if (state.activeFolder === "recent") return isRecentNote(note);
if (state.activeFolder !== "all") return note.folder === state.activeFolder;
return true;
})
.filter((note) => {
if (!query) return true;
return `${getTitle(note)} ${note.body}`.toLowerCase().includes(query);
})
.sort((a, b) => b.updatedAt - a.updatedAt);
}
function isRecentNote(note) {
return note.updatedAt >= Date.now() - 1000 * 60 * 60 * 24 * 7;
}
function getSelectedNote() {
return state.notes.find((note) => note.id === state.selectedId) || null;
}
async function deleteFolder(folderId) {
const folder = state.folders.find((entry) => entry.id === folderId);
if (!folder || folder.id === "notes") return;
const shouldDelete = window.confirm(`删除“${folder.name}”?其中的笔记会移到“备忘录”。`);
if (!shouldDelete) return;
const fallbackFolder = state.folders.find((entry) => entry.id === "notes") || state.folders[0];
state.notes.forEach((note) => {
if (note.folder === folder.id) {
note.folder = fallbackFolder.id;
note.updatedAt = Date.now();
}
});
state.folders = state.folders.filter((entry) => entry.id !== folder.id);
if (state.activeFolder === folder.id) {
state.activeFolder = fallbackFolder.id;
const visible = getVisibleNotes();
state.selectedId = visible[0]?.id || null;
state.mobileView = "list";
}
render();
try {
await removeFolder(folder.id);
setSaveStatus("文件夹已删除", "ok");
const fresh = await loadState();
Object.assign(state, fresh);
render();
} catch (error) {
setSaveStatus("删除失败", "error");
console.error("Failed to delete folder.", error);
const fresh = await loadState();
Object.assign(state, fresh);
render();
}
}
async function submitFolderCreate(name) {
const now = Date.now();
const previousActiveFolder = state.activeFolder;
const previousSelectedId = state.selectedId;
const folder = {
id: createId(),
name,
createdAt: now,
updatedAt: now,
version: 1
};
state.folders.push(folder);
state.activeFolder = folder.id;
state.selectedId = null;
state.mobileView = "list";
render();
hideFolderGate();
try {
const saved = await createFolder(folder);
Object.assign(folder, saved);
setSaveStatus("文件夹已创建", "ok");
render();
} catch (error) {
state.folders = state.folders.filter((entry) => entry.id !== folder.id);
state.activeFolder = previousActiveFolder;
state.selectedId = previousSelectedId;
setSaveStatus("创建失败", "error");
render();
console.error("Failed to create folder.", error);
}
}
async function submitFolderRename(folderId, name) {
const folder = state.folders.find((entry) => entry.id === folderId);
if (!folder || folder.id === "notes") return;
const previousName = folder.name;
const previousUpdatedAt = folder.updatedAt;
const previousVersion = folder.version || 1;
folder.name = name;
folder.updatedAt = Date.now();
folder.version = previousVersion + 1;
render();
hideFolderGate();
try {
const result = await updateFolder(folder.id, {
name: folder.name,
updatedAt: folder.updatedAt,
version: previousVersion
});
if (result.status === "conflict" && result.folder) {
Object.assign(folder, result.folder);
setSaveStatus("文件夹已被其他设备修改", "conflict");
} else {
Object.assign(folder, result);
setSaveStatus("名称已更新", "ok");
}
render();
} catch (error) {
folder.name = previousName;
folder.updatedAt = previousUpdatedAt;
folder.version = previousVersion;
setSaveStatus("重命名失败", "error");
render();
console.error("Failed to rename folder.", error);
}
}
function getActiveFolderLabel() {
if (state.activeFolder === "all") return "所有笔记";
if (state.activeFolder === "recent") return "最近编辑";
return state.folders.find((folder) => folder.id === state.activeFolder)?.name || "笔记";
}
function getWriteFolderId() {
if (state.folders.some((folder) => folder.id === state.activeFolder)) {
return state.activeFolder;
}
return state.folders[0]?.id || "notes";
}
function getUniqueFolderName(baseName) {
const existing = new Set(state.folders.map((folder) => folder.name));
if (!existing.has(baseName)) return baseName;
let index = 2;
while (existing.has(`${baseName} ${index}`)) {
index += 1;
}
return `${baseName} ${index}`;
}
function defaultFolders() {
return [{ id: "notes", name: "备忘录", version: 1 }];
}
function createId() {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
return stringifyUuid(bytes);
}
function renderIcons() {
window.lucide?.createIcons({
attrs: {
"stroke-width": 2
}
});
}
function renderMarkdownPreview(note) {
if (!state.previewMode) return;
els.markdownPreview.innerHTML = isHtmlNoteBody(note.body)
? sanitizeRichHtml(note.body)
: markdownToHtml(note.body);
}
function normalizeEditorHtml(value) {
const html = String(value || "").trim();
return html === "<p></p>" ? "" : html;
}
function isHtmlNoteBody(value) {
return /<\/?(p|h[1-6]|ul|ol|li|blockquote|pre|code|strong|em|s|u|a|img|br|hr)\b/i.test(String(value || ""));
}
function sanitizeRichHtml(html) {
const template = document.createElement("template");
template.innerHTML = String(html || "");
const allowedTags = new Set([
"A",
"BLOCKQUOTE",
"BR",
"CODE",
"DIV",
"EM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"HR",
"IMG",
"LI",
"OL",
"P",
"PRE",
"S",
"STRONG",
"U",
"UL"
]);
[...template.content.querySelectorAll("*")].forEach((node) => {
if (!allowedTags.has(node.tagName)) {
node.replaceWith(...node.childNodes);
return;
}
const href = node.getAttribute("href") || "";
const src = node.getAttribute("src") || "";
const alt = node.getAttribute("alt") || "";
const style = node.getAttribute("style") || "";
const textAlign = style.match(/text-align:\s*(left|center|right)/i)?.[1] || "";
const imageAlign = node.getAttribute("data-align") || node.getAttribute("align") || "";
[...node.attributes].forEach((attribute) => node.removeAttribute(attribute.name));
if (textAlign && ["P", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName)) {
node.setAttribute("style", `text-align: ${textAlign}`);
}
if (node.tagName === "A") {
if (/^(https?:|mailto:)/i.test(href)) {
node.setAttribute("href", href);
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noreferrer");
}
}
if (node.tagName === "IMG") {
if (/^(data:image\/|https?:\/\/)/i.test(src)) {
node.setAttribute("src", src);
node.setAttribute("alt", alt);
if (["left", "center", "right"].includes(imageAlign)) {
node.setAttribute("data-align", imageAlign);
}
} else {
node.remove();
}
}
});
return template.innerHTML;
}
function markdownToHtml(markdown) {
const lines = String(markdown || "").split("\n");
const html = [];
let listType = "";
let inCode = false;
let codeLines = [];
const closeList = () => {
if (!listType) return;
html.push(`</${listType}>`);
listType = "";
};
lines.forEach((line) => {
if (line.trim().startsWith("```")) {
if (inCode) {
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
codeLines = [];
inCode = false;
} else {
closeList();
inCode = true;
}
return;
}
if (inCode) {
codeLines.push(line);
return;
}
const trimmed = line.trim();
if (!trimmed) {
closeList();
return;
}
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
closeList();
html.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
return;
}
const unordered = trimmed.match(/^[-*]\s+(.+)$/);
if (unordered) {
if (listType !== "ul") {
closeList();
listType = "ul";
html.push("<ul>");
}
html.push(`<li>${renderInlineMarkdown(unordered[1])}</li>`);
return;
}
const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
if (ordered) {
if (listType !== "ol") {
closeList();
listType = "ol";
html.push("<ol>");
}
html.push(`<li>${renderInlineMarkdown(ordered[1])}</li>`);
return;
}
if (trimmed.startsWith(">")) {
closeList();
html.push(`<blockquote>${renderInlineMarkdown(trimmed.slice(1).trim())}</blockquote>`);
return;
}
closeList();
html.push(`<p>${renderInlineMarkdown(trimmed)}</p>`);
});
if (inCode) {
html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
}
closeList();
return html.join("");
}
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/!\[([^\]]*)\]\((data:image\/[^)\s]+|https?:\/\/[^)\s]+)\)/g, '<img src="$2" alt="$1">')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getTitle(note) {
const firstLine = getNotePlainText(note).split(/\n/).find((line) => line.trim());
return firstLine ? firstLine.trim().slice(0, 80) : "新建笔记";
}
function getPreview(note) {
const lines = getNotePlainText(note).split(/\n/).map((line) => line.trim()).filter(Boolean);
const text = lines.slice(1).join(" ") || "无附加文本";
return text.slice(0, 140);
}
function getNotePlainText(note) {
if (!isHtmlNoteBody(note.body)) return note.body;
const template = document.createElement("template");
template.innerHTML = sanitizeRichHtml(note.body);
const imageCount = template.content.querySelectorAll("img").length;
const blockSelector = "p,h1,h2,h3,h4,h5,h6,li,blockquote,pre";
const blocks = [...template.content.querySelectorAll(blockSelector)]
.map((node) => node.textContent.trim())
.filter(Boolean);
const text = blocks.length ? blocks.join("\n") : template.content.textContent || "";
return `${text}${imageCount ? `\n${imageCount} 张图片` : ""}`;
}
function formatListDate(value) {
const date = new Date(value);
const today = new Date();
const sameDay = date.toDateString() === today.toDateString();
if (sameDay) {
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
}
function formatFullDate(value) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
function formatSectionDate(value) {
const date = new Date(value);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (date.toDateString() === today.toDateString()) return "今天";
if (date.toDateString() === yesterday.toDateString()) return "昨天";
const sameYear = date.getFullYear() === today.getFullYear();
return date.toLocaleDateString("zh-CN", sameYear
? { month: "long", day: "numeric" }
: { year: "numeric", month: "long", day: "numeric" });
}