\n'; } /* RSS 2.0 订阅文件 */ function buildFeed(posts, opts) { opts = opts || {}; const base = location.href.split('#')[0]; const title = opts.title || '墨记'; const desc = opts.desc || '记录代码、阅读与生活'; const items = posts.map(p => ` ${xmlEsc(p.title)} ${xmlEsc(base + '#/post/' + encodeURIComponent(p.id))} ${xmlEsc('moji:' + p.id)} ${rfc822(p.date)} ${p.tags.map(t => `${xmlEsc(t)}`).join('\n ')} ${xmlEsc(p.summary)} `).join(''); return '\n' + '\n\n' + ' ' + xmlEsc(title) + '\n' + ' ' + xmlEsc(base) + '\n' + ' ' + xmlEsc(desc) + '\n' + ' zh-CN\n' + ' ' + new Date().toUTCString() + '\n' + ' 墨记 · 单文件博客\n' + items + '\n\n\n'; } function exportFeed(posts, tag) { if (!posts.length) { toast('没有可导出的文章'); return; } const name = tag ? 'moji-feed-' + safeName(tag) + '.xml' : 'moji-feed-all.xml'; download(name, buildFeed(posts, { title: tag ? '墨记 · ' + tag : '墨记', desc: tag ? '墨记中标签为「' + tag + '」的文章' : '记录代码、阅读与生活' }), 'application/rss+xml;charset=utf-8'); toast(tag ? '已导出「' + tag + '」的 RSS' : '已导出全部文章的 RSS'); } /* ================= 关于 + 数据管理 ================= */ function renderAbout() { document.title = '关于 · 墨记'; const postCount = loadUserPosts().length; const comments = loadAllComments(); const commentCount = Object.keys(comments).reduce((n, k) => n + (comments[k] || []).length, 0); const revs = loadRevisions(); const revCount = Object.keys(revs).reduce((n, k) => n + (revs[k] || []).length, 0); app.innerHTML = `

关于

这里是一个人的笔记本,主要写前端、工具和一些读书笔记。

这个站点怎么做的

本地存储键名

数据管理

当前:${postCount} 篇自己写的文章、${revCount} 个历史版本、${commentCount} 条留言。 数据只存在这台设备的浏览器里,换浏览器或清缓存就会丢,建议定期导出。

打印视图

订阅

把导出的 RSS 文件放到文章同目录,或直接导入任意阅读器(如 Feedly、Reeder)。

联系方式

邮箱:hello@example.com(换成你自己的就好)

`; bindAbout(); } function bindAbout() { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'application/json,.json'; fileInput.addEventListener('change', () => { const f = fileInput.files && fileInput.files[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => importData(String(reader.result || '')); reader.readAsText(f); fileInput.value = ''; }); document.getElementById('expBtn').addEventListener('click', () => { exportData(); toast('已开始下载备份文件'); }); document.getElementById('impBtn').addEventListener('click', () => fileInput.click()); document.getElementById('expHtmlBtn').addEventListener('click', () => { download('moji-static-' + todayStr() + '.html', buildStaticHTML(allPosts(), '墨记'), 'text/html;charset=utf-8'); toast('已导出全站静态 HTML'); }); document.getElementById('rssAll').addEventListener('click', () => exportFeed(allPosts(), '')); document.getElementById('clrBtn').addEventListener('click', () => { if (!confirm('会删除自己写的文章、历史版本、留言、草稿和主题设置,确定吗?')) return; if (!confirm('最后确认一次:删除后无法恢复。')) return; [POSTS_KEY, REV_KEY, DRAFT_KEY, COMMENT_KEY, NAME_KEY, THEME_KEY].forEach(k => writeJSONSilent(k, null)); toast('已清空全部本地数据'); route(); }); } function exportData() { const payload = { app: '墨记', version: 2, exportedAt: new Date().toISOString(), posts: loadUserPosts(), revisions: loadRevisions(), comments: loadAllComments() }; download('moji-backup-' + todayStr() + '.json', JSON.stringify(payload, null, 2), 'application/json'); } function importData(text) { let data; try { data = JSON.parse(text); } catch (e) { alert('这不是合法的 JSON 文件'); return; } const posts = Array.isArray(data) ? data : (data && Array.isArray(data.posts) ? data.posts : []); const comments = (data && data.comments && typeof data.comments === 'object' && !Array.isArray(data.comments)) ? data.comments : {}; const revisions = (data && data.revisions && typeof data.revisions === 'object' && !Array.isArray(data.revisions)) ? data.revisions : {}; const commentCount = Object.keys(comments).reduce((n, k) => n + (comments[k] || []).length, 0); const revCount = Object.keys(revisions).reduce((n, k) => n + (revisions[k] || []).length, 0); if (!posts.length && !commentCount && !revCount) { alert('文件里没有可导入的数据'); return; } if (!confirm('将合并导入 ' + posts.length + ' 篇文章、' + revCount + ' 个历史版本、' + commentCount + ' 条留言(同 id 以导入文件为准),继续吗?')) return; const map = new Map(loadUserPosts().map(p => [p.id, p])); posts.forEach(p => { if (!p || !p.id || !p.title) return; map.set(p.id, Object.assign({}, p, { mine: undefined })); }); if (!saveUserPosts([...map.values()])) return; const curRev = loadRevisions(); Object.keys(revisions).forEach(pid => { const incoming = Array.isArray(revisions[pid]) ? revisions[pid] : []; const existing = Array.isArray(curRev[pid]) ? curRev[pid] : []; const seen = new Set(existing.map(r => r && r.ts)); const merged = existing.concat(incoming.filter(r => r && r.ts && !seen.has(r.ts))) .sort((a, b) => b.ts - a.ts).slice(0, REV_LIMIT); if (merged.length) curRev[pid] = merged; }); saveRevisions(curRev); const cur = loadAllComments(); Object.keys(comments).forEach(pid => { const incoming = Array.isArray(comments[pid]) ? comments[pid] : []; const existing = Array.isArray(cur[pid]) ? cur[pid] : []; const seen = new Set(existing.map(c => c && c.id)); cur[pid] = existing.concat(incoming.filter(c => c && c.id && !seen.has(c.id))); }); saveAllComments(cur); toast('导入完成'); route(); } /* ================= 路由 ================= */ function updateNav(hash) { const key = hash.startsWith('#/about') ? 'about' : hash.startsWith('#/stats') ? 'stats' : hash.startsWith('#/tags') ? 'tags' : hash.startsWith('#/archive') ? 'archive' : hash.startsWith('#/search') ? 'search' : (hash.startsWith('#/new') || hash.startsWith('#/edit/')) ? 'new' : hash.startsWith('#/print') ? 'print' : 'home'; document.querySelectorAll('[data-nav]').forEach(a => { a.classList.toggle('active', a.dataset.nav === key); }); } function route() { const hash = location.hash || '#/'; const postMatch = hash.match(/^#\/post\/([^?]+)(?:\?(.*))?$/); const editMatch = hash.match(/^#\/edit\/(.+)$/); const searchMatch = hash.match(/^#\/search(?:\?(.*))?$/); spyHeads = []; spyLinks = []; revOpen = false; setProgressVisible(!!postMatch); if (postMatch) { const id = decodeURIComponent(postMatch[1]); const params = new URLSearchParams(postMatch[2] || ''); renderPost(id, params.get('s')); } else if (editMatch) renderEditor(decodeURIComponent(editMatch[1])); else if (searchMatch) renderSearch(new URLSearchParams(searchMatch[1] || '')); else if (hash.startsWith('#/new')) renderEditor(null); else if (hash.startsWith('#/tags')) renderTags(); else if (hash.startsWith('#/archive')) renderArchive(); else if (hash.startsWith('#/stats')) renderStats(); else if (hash.startsWith('#/print')) renderPrintView(); else if (hash.startsWith('#/about')) renderAbout(); else renderHome(); updateNav(hash); window.scrollTo(0, 0); } /* 标签按钮全局代理:点标签就回首页并带上筛选 */ document.addEventListener('click', e => { const btn = e.target.closest('button[data-tag]'); if (!btn) return; const tag = btn.dataset.tag; state.tag = state.tag === tag ? '' : tag; const isHome = !location.hash || location.hash === '#' || location.hash === '#/'; if (isHome) renderHome(); else location.hash = '#/'; }); /* ================= 主题 ================= */ const themeBtn = document.getElementById('themeBtn'); function applyTheme(t) { document.documentElement.dataset.theme = t; themeBtn.textContent = t === 'dark' ? '☀️' : '🌙'; writeJSONSilent(THEME_KEY, t); } (function initTheme() { let saved = null; try { saved = localStorage.getItem(THEME_KEY); } catch (e) {} const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; applyTheme(saved || (prefersDark ? 'dark' : 'light')); })(); themeBtn.addEventListener('click', () => { applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'); }); /* ================= 滚动相关 ================= */ const toTop = document.getElementById('toTop'); window.addEventListener('scroll', () => { toTop.classList.toggle('show', window.scrollY > 400); updateProgress(); updateSpy(); }); toTop.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' })); window.addEventListener('resize', updateSpy); /* ================= 启动 ================= */ window.addEventListener('hashchange', route); route();