diff --git a/.gitignore b/.gitignore
index 53bfd4f2..35647b84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,9 @@ cnb-secrets.yml
/data/artalk_conf.json
/data/friend_links.json
/data/friend_feeds.json
+
+# 编辑器本地验证用的临时目录(探针 / 日志 / 回收站),绝不入库
+.editor-tmp/
+.editor-trash/
+# Cloudflare 部署自检产物
+blog-admin/.deploy-check/
diff --git a/blog-admin/.dev.vars.example b/blog-admin/.dev.vars.example
index d2486418..73a154f9 100644
--- a/blog-admin/.dev.vars.example
+++ b/blog-admin/.dev.vars.example
@@ -3,3 +3,8 @@
TOKEN_SECRET="local-dev-secret-please-change-me-to-something-long"
ADMIN_PASSWORD="local-dev-password"
+
+# ---- 文章编辑后端(本地起 editor-api 时用)----
+# 提示:wrangler secret 在本地 dev 下没有值,会直接读这个文件
+EDITOR_API_BASE="http://127.0.0.1:8017"
+EDITOR_TOKEN="devtoken"
diff --git a/blog-admin/public/admin/admin.css b/blog-admin/public/admin/admin.css
index 065a098f..3f3ed9ad 100644
--- a/blog-admin/public/admin/admin.css
+++ b/blog-admin/public/admin/admin.css
@@ -504,13 +504,149 @@ textarea.code:focus{outline:none;border-color:var(--accent)}
.modal-head h3{flex:1;font-size:14px;font-weight:700}
.modal-body{padding:18px;overflow-y:auto}
-/* ---------------------------------------------------------- 响应式 */
+/* ---------------------------------------------------------- 文章编辑 */
+
+/* 列表行里的标题:可点,悬停才显色 */
+.po-t{color:var(--text);font-weight:500;cursor:pointer}
+.po-t:hover{color:var(--accent);text-decoration:underline;text-underline-offset:2px}
+.po-meta{display:flex;gap:5px;flex-wrap:wrap;align-items:center;margin-top:4px}
+.po-meta:empty{display:none}
+
+/* 编辑器头部的线上地址 + 真实文件路径 */
+.po-url{
+ display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;
+ margin:-6px 0 14px;font-size:12px;color:var(--muted);
+}
+.po-url .lk{color:var(--accent)}
+.po-file{
+ margin-left:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+ font-size:11px;color:var(--muted);word-break:break-all;
+}
+
+/* 两列字段 */
+.po-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
+.po-grid .field:last-child{margin-bottom:14px}
+
+.po-ck{
+ display:flex;align-items:center;gap:8px;
+ margin:2px 0 4px;font-size:13px;color:var(--text);cursor:pointer;
+}
+
+/* 只读的「其他字段」—— 平时收着,别喧宾夺主 */
+.po-ro{
+ margin-top:14px;padding:11px 13px;
+ background:var(--surface);border:1px solid var(--line);border-radius:6px;
+ font-size:12px;
+}
+.po-ro-k{display:block;margin-bottom:8px;color:var(--muted)}
+.po-ro-i{display:flex;align-items:baseline;gap:10px;padding:3px 0}
+.po-ro-i code{
+ flex:none;min-width:118px;
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+ font-size:11px;color:var(--accent);
+}
+.po-ro-i .t{flex:none;font-size:11px;color:var(--muted)}
+.po-ro-i .v{
+ flex:1;min-width:0;color:var(--muted);
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+}
+
+/* 正文编辑区:等宽 + 够高,写 Markdown 才不憋屈(14px/1.75 对齐 write-server 的阅读密度) */
+.po-body{
+ min-height:52vh;
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Courier New",monospace;
+ font-size:14px;line-height:1.75;
+ tab-size:2;
+}
+.po-hint{margin-left:auto;font-size:11px;color:var(--muted);font-weight:400}
+.po-foot{
+ display:flex;align-items:center;gap:12px;flex-wrap:wrap;
+ margin-top:10px;font-size:12px;color:var(--muted);
+}
+.po-kbd{margin-left:auto;font-size:11px;color:var(--muted)}
+
+/* 文章列表:write-server 式卡片行(标题可点 + hover 出操作) */
+.tag.ok{color:var(--success);background:rgba(57,184,111,.13)}
+.po-list{display:flex;flex-direction:column;gap:8px}
+.po-row{
+ display:flex;align-items:center;gap:14px;
+ padding:12px 16px;background:var(--card);
+ border:1px solid var(--line);border-radius:8px;
+ transition:border-color .15s;
+}
+.po-row:hover{border-color:var(--accent)}
+.po-main{flex:1;min-width:0}
+.po-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.po-sub{margin-top:3px;font-size:12px;color:var(--muted)}
+.po-acts{display:flex;gap:2px;flex:none}
+.po-ic{
+ display:inline-flex;align-items:center;justify-content:center;
+ width:30px;height:30px;
+ border:none;border-radius:6px;cursor:pointer;
+ background:transparent;color:var(--muted);text-decoration:none;
+ transition:background .15s,color .15s;
+}
+.po-ic:hover{background:var(--surface);color:var(--accent)}
+.po-ic.danger:hover{color:var(--danger)}
+@media (min-width:861px){
+ .po-acts{opacity:0;transition:opacity .15s}
+ .po-row:hover .po-acts,.po-row:focus-within .po-acts{opacity:1}
+}
+
+/* 编辑器:紧凑表单(对齐 write-server 的行式布局) */
+.po-frow{display:grid;grid-template-columns:1fr 240px;gap:0 14px}
+.po-frow4{display:grid;grid-template-columns:180px 150px 110px 1fr;gap:0 14px}
+.po-frow .field,.po-frow4 .field{margin-bottom:12px}
+.po-folder{margin:-4px 0 12px;font-size:11px;color:var(--muted);word-break:break-all}
+
+/* 编辑器两栏:左主编辑 + 右快捷插入(对齐 write-server 的撰写页) */
+.po-grid{display:grid;grid-template-columns:minmax(0,1fr) 196px;gap:16px;align-items:start}
+.po-side-t{margin:2px 0 8px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
+.po-sc{
+ display:flex;flex-direction:column;gap:2px;padding:6px;
+ background:var(--card);border:1px solid var(--line);border-radius:8px;
+}
+.po-sc-b{
+ display:flex;align-items:center;gap:9px;width:100%;
+ padding:7px 9px;border:none;border-radius:6px;
+ background:transparent;cursor:pointer;text-align:left;
+ font-size:12px;color:var(--text);opacity:.75;
+ transition:background .15s,color .15s,opacity .15s;
+}
+.po-sc-b:hover{background:var(--surface);color:var(--accent);opacity:1}
+.po-sc-b .g{
+ flex:none;width:18px;text-align:center;
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+ font-size:11px;color:var(--muted);
+}
+.po-sc-b:hover .g{color:var(--accent)}
+.po-note{
+ margin-top:14px;padding:10px 12px;
+ background:var(--surface);border:1px solid var(--line);border-radius:8px;
+ font-size:11px;color:var(--muted);line-height:1.7;
+}
+.po-note h4{margin:0 0 6px;font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
+.po-note ul{margin:0;padding-left:14px}
+
+/* 设置页:schema 驱动的表单 */
+.sf-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}
+.sf-item{margin-bottom:12px;min-width:0}
+.sf-hint{display:block;margin-top:4px;font-size:11px;color:var(--muted)}
+.sf-ck{margin:0}
+.sf-em{font-style:normal;font-size:11px;color:var(--muted)}
+.sf-adv{font-size:13px;color:var(--muted)}
+.sf-adv summary{cursor:pointer;user-select:none}
+.sf-adv summary:hover{color:var(--text)}
@media (max-width:1000px){
.stats{grid-template-columns:repeat(2,1fr)}
.grid2{grid-template-columns:1fr}
.main{padding:0 20px}
.topbar{margin:0 -20px;padding:13px 20px}
+ .po-grid{grid-template-columns:1fr}
+ .po-frow,.po-frow4{grid-template-columns:1fr}
+ .sf-grid{grid-template-columns:1fr}
+ .po-ro-i code{min-width:90px}
}
@media (max-width:760px){
diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js
index f3103619..00910698 100644
--- a/blog-admin/public/admin/admin.js
+++ b/blog-admin/public/admin/admin.js
@@ -55,16 +55,19 @@ const state = {
p: { offset: 0, limit: 30, total: 0 },
u: { offset: 0, limit: 30, total: 0, q: '', rows: [] },
openReps: new Set(),
+ // 文章编辑
+ po: { q: '', page: 1, perPage: 20, total: 0, totalPages: 1, rows: [], busy: false },
+ ed: null, // 打开某篇时:{ id, slug, post, fm, body, dirty, busy, git }
};
const TABS = [
{ id: 'dash', label: '仪表盘', group: '', icon: 'dash' },
{ id: 'comments', label: '评论管理', group: '内容管理', icon: 'comments' },
+ { id: 'posts', label: '文章编辑', group: '内容管理', icon: 'posts' },
{ id: 'pages', label: '页面管理', group: '内容管理', icon: 'pages' },
{ id: 'users', label: '用户管理', group: '内容管理', icon: 'users' },
{ id: 'feeds', label: '订阅源', group: '订阅中心', icon: 'feeds' },
{ id: 'links', label: '友链', group: '订阅中心', icon: 'links' },
- { id: 'images', label: '图片', group: '订阅中心', icon: 'images' },
{ id: 'settings', label: '系统设置', group: '系统', icon: 'settings' },
{ id: 'status', label: '运行状态', group: '系统', icon: 'status' },
];
@@ -75,15 +78,18 @@ const I = (p) =>
const ICONS = {
dash: I(''),
comments: I(''),
+ posts: I(''),
pages: I(''),
users: I(''),
feeds: I(''),
links: I(''),
- images: I(''),
settings: I(''),
status: I(''),
moon: I(''),
out: I(''),
+ eye: I(''),
+ edit: I(''),
+ trash: I(''),
};
// ============================================================ HTTP
@@ -284,13 +290,13 @@ async function go(tab) {
try {
if (tab === 'dash') await viewDash();
else if (tab === 'comments') await viewComments();
+ else if (tab === 'posts') await viewPosts();
else if (tab === 'pages') await viewPages();
else if (tab === 'users') await viewUsers();
else if (tab === 'settings') await viewSettings();
else if (tab === 'status') await viewStatus();
else if (tab === 'feeds') await viewFeeds();
else if (tab === 'links') await viewLinks();
- else if (tab === 'images') await viewImages();
} catch (e) {
if (e.status === 401 || e.status === 403) { logout('会话已失效,请重新登录'); return; }
if (state.tab !== tab) return; // 已切走,别用旧错误覆盖新视图
@@ -642,13 +648,23 @@ function editUser(id) {
}
// ============================================================ 视图:设置
+//
+// 表单化:schema 由后端 /settings/form 下发(唯一事实源在 src/lib/settings-form.ts),
+// 前端只负责画表单和收值,完全不拼 YAML。schema 之外的键放在 extra 里原样传回,
+// 保证「表单化」永远不会静默丢配置。需要手改 YAML 时展开底部「高级」区。
async function viewSettings() {
- const [sites, conf] = await Promise.all([api('/sites'), api('/conf')]);
+ const [sites, form] = await Promise.all([api('/sites'), api('/settings/form')]);
state.sites = sites || [];
- const fe = conf.frontend_conf || {};
-
+ state.sf = {
+ schema: form.schema || [],
+ values: form.values || {},
+ extra: form.extra || {},
+ custom: !!form.custom,
+ advTouched: false,
+ };
if (state.tab !== 'settings') return;
+
const siteRows = state.sites.map((s) =>
'
' +
'' + esc(s.name) + ' ' +
@@ -656,117 +672,108 @@ async function viewSettings() {
'' +
'
').join('') || '暂无站点
';
- const feRows = [
- ['评论分页', (fe.pagination && fe.pagination.pageSize) || 20, '条/页'],
- ['初始加载', fe.countOfCommentsOnPageInit || 20, '条'],
- ['验证码', fe.captchaMode || 'off', ''],
- ['投票', fe.vote ? '开启' : '关闭', ''],
- ['图片上传', fe.imgUpload ? '可用' : '不可用', ''],
- ['语言', fe.locale || 'zh-CN', ''],
- ['表情包', fe.emoticons || '—', ''],
- ['头像源', (fe.gravatar && fe.gravatar.mirror) || '', ''],
- ].map((r) =>
- '' + esc(r[0]) + '' + esc(String(r[1])) + (r[2] ? ' ' + esc(r[2]) + '' : '') + ''
- ).join('');
+ const groups = state.sf.schema.map((g) => {
+ const fields = g.fields.map(sfField).join('');
+ return '' +
+ '' + esc(g.title) + '
' +
+ (g.desc ? '' + esc(g.desc) + '' : '') + '' +
+ '';
+ }).join('');
$('#view').innerHTML =
- head('设置', 'SETTINGS', '站点与前台配置') +
- '' +
- '';
+ head('设置', 'SETTINGS', '站点与前台配置 · 改完点右上角保存',
+ '') +
+ '' +
+ groups +
+ '' +
+ '高级:直接编辑原始 YAML(动了这个就以它为准,表单不再参与保存)
' +
+ '' +
+ ' ';
- api('/settings').then((d) => {
- const box = $('#yamlBox');
- if (!box) return;
- box.innerHTML =
- '' +
- '' +
- '' +
- '' +
- '' +
- '
' +
- '' +
- '保存在 D1 settings 表,改完即刻生效。服务端识别的键:' +
- 'frontend.*(前台配置,含 emoticons 表情包地址)、' +
- 'captcha 验证码、moderator.pending_default 待审、' +
- 'ip_region 归属地、site_default 默认站点名;其余键仅存档。
';
-
- let original = d.yaml || '';
-
- // IP 归属地快捷开关:读写 yaml 里的 ip_region.enabled(与 sidebar 设置页同一份数据)
- var ipOn = /ip_region:[\s\S]*?enabled:\s*true/.test(original);
- var ipRow = document.createElement('div');
- ipRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' +
- 'background:var(--surface);border:1px solid var(--line);border-radius:6px';
- ipRow.innerHTML =
- '' +
- '' +
- '(同 sidebar 设置页的 ip_region.enabled)';
- var txt = $('#yamlTxt');
- txt.parentNode.insertBefore(ipRow, txt);
- ipRow.querySelector('#ipRegionToggle').addEventListener('change', function () {
- var on = this.checked;
- var t = $('#yamlTxt');
- var v = t.value;
- if (/ip_region:[\s\S]*?enabled:\s*(true|false)/.test(v)) {
- v = v.replace(/(ip_region:[\s\S]*?enabled:\s*)(true|false)/, '$1' + on);
- } else {
- v += (v.endsWith('\n') ? '' : '\n') + 'ip_region:\n enabled: ' + on + '\n';
- }
- t.value = v;
- toast('已切换为 ' + (on ? '显示' : '隐藏') + ',保存后生效');
- });
-
- // 人机验证快捷开关(自研「一键验证」):读写 yaml 里的 human_check.enabled,
- // 保存时由服务端同步到 KV(评论提交时读 KV,不占 D1 额度)
- var hcOn = /human_check:[\s\S]*?enabled:\s*true/.test(original);
- var hcRow = document.createElement('div');
- hcRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' +
- 'background:var(--surface);border:1px solid var(--line);border-radius:6px';
- hcRow.innerHTML =
- '' +
- '' +
- '(human_check.enabled)';
- ipRow.parentNode.insertBefore(hcRow, ipRow.nextSibling);
- hcRow.querySelector('#humanCheckToggle').addEventListener('change', function () {
- var on = this.checked;
- var t = $('#yamlTxt');
- var v = t.value;
- if (/human_check:[\s\S]*?enabled:\s*(true|false)/.test(v)) {
- v = v.replace(/(human_check:[\s\S]*?enabled:\s*)(true|false)/, '$1' + on);
- } else {
- v += (v.endsWith('\n') ? '' : '\n') + 'human_check:\n enabled: ' + on + '\n';
- }
- t.value = v;
- toast('人机验证已' + (on ? '开启' : '关闭') + ',保存后生效');
- });
-
- const $r = () => $('#yamlReset');
- if ($r()) $r().onclick = () => { $('#yamlTxt').value = original; toast('已还原'); };
-
- const $s = () => $('#yamlSave');
- if ($s()) $s().onclick = async () => {
- const b = $s(); b.dataset.busy = '1';
- const msg = $('#yamlMsg'); if (msg) msg.textContent = '保存中…';
- try {
- const r = await api('/settings', { method: 'PUT', body: { yaml: $('#yamlTxt').value } });
- original = $('#yamlTxt').value;
- if (msg) msg.textContent = '';
- toast('✓ 已保存' + (r && r.msg ? '' : ''));
- viewSettings(); // 重新拉取,让上方"前台配置"卡片同步刷新
- } catch (e) {
- if (msg) msg.textContent = '';
- toast('✕ ' + e.message, true);
- delete b.dataset.busy;
- }
- };
- }).catch(() => { const b = $('#yamlBox'); if (b) b.innerHTML = '读取失败
'; });
+ const adv = $('#sfAdv');
+ adv.addEventListener('toggle', async () => {
+ const box = $('#sfAdvBox');
+ if (!adv.open || !box || box.dataset.loaded) return;
+ try {
+ const d = await api('/settings');
+ box.innerHTML = '' +
+ '这是服务端实际保存的完整配置。' +
+ '保存后仍会按同一套规则回填(frontend.* / captcha / human_check / moderator / ip_region / site_default)。
';
+ $('#sfYaml').value = d.yaml || '';
+ $('#sfYaml').addEventListener('input', () => { state.sf.advTouched = true; });
+ box.dataset.loaded = '1';
+ } catch (e) {
+ box.innerHTML = '读取失败:' + esc(e.message) + '
';
+ }
+ });
}
+/** 单个表单控件(bool=开关行,select/number/text=标准输入) */
+function sfField(f) {
+ const t = f.type || 'text';
+ const v = state.sf.values[f.path];
+ const id = 'sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_');
+
+ if (t === 'bool') {
+ return '';
+ }
+
+ let ctl;
+ if (t === 'select') {
+ ctl = '';
+ } else if (t === 'number') {
+ ctl = '';
+ } else {
+ ctl = '';
+ }
+ return '';
+}
+
+/** 表单 → 扁平的 { path: value }(类型按 schema 归位) */
+function sfCollect() {
+ const out = {};
+ for (const g of state.sf.schema) {
+ for (const f of g.fields) {
+ const el = document.getElementById('sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_'));
+ if (!el) continue;
+ if ((f.type || 'text') === 'bool') out[f.path] = el.checked;
+ else if ((f.type || 'text') === 'number') out[f.path] = el.value.trim() === '' ? '' : Number(el.value);
+ else out[f.path] = el.value;
+ }
+ }
+ return out;
+}
+
+async function saveSettings() {
+ const b = $('[data-act="sf-save"]');
+ if (b) b.dataset.busy = '1';
+ try {
+ const adv = $('#sfYaml');
+ if (adv && state.sf.advTouched) {
+ await api('/settings', { method: 'PUT', body: { yaml: adv.value } });
+ toast('✓ 已保存(按原始 YAML)');
+ } else {
+ await api('/settings/form', { method: 'PUT', body: { values: sfCollect(), extra: state.sf.extra } });
+ toast('✓ 已保存');
+ }
+ await viewSettings();
+ } catch (e) {
+ toast('✕ ' + e.message, true);
+ const b2 = $('[data-act="sf-save"]');
+ if (b2) delete b2.dataset.busy;
+ }
+}
+
+
function editSite(id) {
const s = state.sites.filter((x) => x.id === id)[0];
if (!s) return;
@@ -1044,48 +1051,629 @@ function editLink(url) {
};
}
-// ============================================================ 视图:图片
+// ============================================================ 视图:文章编辑
+//
+// 全线走 Worker 的 /editor/* 反代:浏览器只跟 Worker 说话,编辑器令牌在 Worker 侧
+// 注入,所以前端不需要任何额外凭据(复用后台已有的登录会话即可)。
+//
+// ★ 定位键用 id(content 下的目录名,全仓唯一),**不是 slug** ——
+// 仓库里有 5 组 slug 撞名的文章,按 slug 打开会静默地打开/保存到另一篇文件上。
+//
+// ★ 交互形态对齐 write-server(用户用惯的那套):
+// · 列表 = 卡片行:标题可点 + 草稿/已发布徽章,hover 出 预览/编辑/删除
+// · 新建 = 直接进编辑器(不再弹窗),输入标题时自动生成时间戳 slug
+// · 编辑器 = 紧凑表单(标题+slug 一行、日期+作者+布局+分类一行、标签一行)
+// + 正文 + 右上角 保存 / 发布
-async function viewImages() {
- if (state.tab !== 'images') return;
- $('#view').innerHTML =
- head('图片管理', 'IMAGES', '随机图 · ' + RSS_API, '');
+/** 界面上可编辑的 front matter 字段(其余键一律只读展示、原样保留) */
+const PO_FM_FIELDS = [
+ { k: 'title', label: '标题' },
+ { k: 'slug', label: 'slug(决定网址 /.html)' },
+ { k: 'date', label: '日期' },
+ { k: 'author', label: '作者' },
+ { k: 'layout', label: '布局' },
+ { k: 'description', label: '摘要' },
+];
+const PO_KNOWN = new Set([...PO_FM_FIELDS.map((f) => f.k), 'categories', 'tags', 'draft']);
- let folders;
+const arrText = (v) => (Array.isArray(v) ? v.join(', ') : v == null ? '' : String(v));
+const textArr = (s) => String(s || '').split(/[,,]/).map((x) => x.trim()).filter(Boolean);
+
+/**
+ * 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。
+ * 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)一字不差 ——
+ * 仓库里 2026-06-22 之后的文章全是这个风格,不要用标题当 slug。
+ */
+function makeSlug(d = new Date()) {
+ const p = (n) => String(n).padStart(2, '0');
+ return (
+ d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) +
+ p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds())
+ );
+}
+
+/** 目录名里的标题段(与 write-server 的 computeDirPath 同一套规则) */
+function titlePartOf(t) {
+ return (
+ String(t || '')
+ .replace(/[^\w一-鿿㐀-䶿]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .toLowerCase() || 'post'
+ );
+}
+
+const poFmtDate = (d) => (d ? String(d).slice(0, 10).replace(/-/g, '/') : '—');
+
+async function viewPosts() {
+ if (state.tab !== 'posts') return;
+ if (state.ed) return renderEditor();
+ await loadPosts();
+}
+
+async function loadPosts() {
+ const po = state.po;
+ let d;
try {
- const d = await rssApi('/api/random-image?list=1');
- folders = d.folders || [];
+ d = await api('/editor/posts?' + qs({ q: po.q, page: po.page, perPage: po.perPage }));
} catch (e) {
- $('#view').innerHTML += '加载失败:' + esc(e.message) + '
';
+ if (state.tab !== 'posts') return;
+ $('#view').innerHTML = head('文章编辑', '', '') +
+ '加载不了文章列表' + esc(e.message) + '
';
+ return;
+ }
+ po.rows = d.posts || [];
+ po.total = d.total || 0;
+ po.totalPages = d.totalPages || 1;
+ po.page = d.page || 1;
+ if (state.tab !== 'posts' || state.ed) return;
+ renderPostList();
+}
+
+function renderPostList() {
+ const po = state.po;
+ const rows = po.rows.map((r) => {
+ const badges = [
+ r.draft ? '草稿' : '已发布',
+ r.slugConflict
+ ? 'URL 冲突'
+ : '',
+ r.parseable === false
+ ? 'FM 异常'
+ : '',
+ ].filter(Boolean).join(' ');
+ const meta = [poFmtDate(r.date), (r.categories || []).join('、')].filter(Boolean).join(' · ');
+ return '' +
+ '
' +
+ '
' +
+ '' + esc(r.title) + '' +
+ badges +
+ '
' +
+ '
' + esc(meta) + '
' +
+ '
' +
+ '
' +
+ (r.url ? '
' + ICONS.eye + '' : '') +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ }).join('');
+
+ $('#view').innerHTML =
+ head('文章编辑', '', '共 ' + nfmt(po.total) + ' 篇 · 直接改仓库 content/posts 下的 Markdown',
+ '' +
+ '') +
+ '' +
+ (po.rows.length
+ ? '' + rows + '
' + pagerPo()
+ : '' + (po.q ? '没有匹配的文章' : '还没有文章') + '' +
+ (po.q ? '换个关键词试试' : '点右上角「+ 新建」写第一篇') + '
');
+
+ const q = $('#poq');
+ let tmr = null;
+ q.oninput = () => {
+ clearTimeout(tmr);
+ tmr = setTimeout(() => { po.q = q.value.trim(); po.page = 1; loadPosts(); }, 420);
+ };
+}
+
+function pagerPo() {
+ const po = state.po;
+ return '';
+}
+
+async function openPost(id) {
+ $('#view').innerHTML = '加载中…
';
+ let post;
+ try {
+ post = await api('/editor/posts/' + encodeURIComponent(id));
+ } catch (e) {
+ if (state.tab !== 'posts') return;
+ $('#view').innerHTML = head('打开失败', '', esc(id)) +
+ '' + esc(e.message) + '
' +
+ '';
+ return;
+ }
+ state.ed = {
+ id: post.id,
+ post,
+ dirty: false,
+ uploading: 0,
+ };
+ renderEditor();
+}
+
+/** 新建:直接进编辑器(对齐 write-server 的「撰写文章」页),第一次保存时才真正创建 */
+function newPost() {
+ state.ed = {
+ isNew: true,
+ id: '',
+ slugTouched: false,
+ post: {
+ id: '',
+ title: '',
+ slug: '',
+ date: new Date().toISOString().slice(0, 10),
+ author: '',
+ layout: 'post',
+ categories: [],
+ tags: [],
+ draft: true,
+ url: '',
+ filePath: '',
+ eol: '\n',
+ bodyLead: '',
+ frontMatter: {},
+ frontMatterRaw: null,
+ frontMatterError: null,
+ content: '',
+ },
+ dirty: false,
+ uploading: 0,
+ };
+ renderEditor();
+}
+
+function renderEditor() {
+ const ed = state.ed;
+ if (!ed) { loadPosts(); return; }
+ const p = ed.post;
+ const orig = p.frontMatter || {};
+ const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError);
+
+ const field = (k, label, cls, ph) =>
+ '';
+
+ const metaForm =
+ '' +
+ field('title', '标题', 'po-f-title', '文章标题') +
+ field('slug', 'Slug(自动生成)', 'po-f-slug', 'YYYYMMDDHHMMSS') +
+ '
' +
+ '' +
+ field('date', '日期', 'po-f-date') +
+ field('author', '作者', 'po-f-author') +
+ field('layout', '布局', 'po-f-layout') +
+ '' +
+ '
' +
+ '' +
+ field('description', '摘要(可选)') +
+ '';
+
+ const extraKeys = Object.keys(orig).filter((k) => !PO_KNOWN.has(k));
+ const extra = extraKeys.length
+ ? '其他字段(只读保留,不会被动过)' +
+ extraKeys.map((k) => {
+ const v = orig[k];
+ const t = Array.isArray(v) ? '列表' : v === null ? '空' : typeof v;
+ const s = Array.isArray(v) ? v.join(', ') : String(v == null ? '' : v);
+ return '
' + esc(k) + '' + esc(t) + '' +
+ '' + esc(truncate(s.replace(/\s+/g, ' '), 70)) + '
';
+ }).join('') + '
'
+ : '';
+
+ const warn = [
+ p.slugConflict
+ ? '这篇文章的 slug「' + esc(p.slug) + '」与另一篇重复。Hugo 用 /:slug 生成网址,' +
+ '撞名时线上只会剩一篇 —— 改 slug 前先确认另一篇要不要一起处理。
'
+ : '',
+ fmBroken
+ ? 'front matter 没法完整解析(' + esc(p.frontMatterError || '没有 front matter') + ')。' +
+ '为了不写坏这篇,字段区已停用,保存时只会动正文。
'
+ : '',
+ ].filter(Boolean).join('');
+
+ $('#view').innerHTML =
+ head(ed.isNew ? '新建文章' : (p.title || ed.id), '', ed.isNew ? '写完点「保存」才真正落盘' : ('正文 ' + String(p.content || '').length + ' 字 · ' + esc(p.id)),
+ '' +
+ (ed.isNew ? '' :
+ '' +
+ '')) +
+ (!ed.isNew && p.url ? '' : '') +
+ warn +
+ '' +
+ '
' +
+ '
' +
+ metaForm + extra +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+
+ const ta = $('#poBody');
+ ta.value = p.content || '';
+ if (fmBroken) {
+ $$('#view [data-fm], #poCats, #poTags, #poDraft').forEach((el) => { el.disabled = true; });
+ }
+
+ // 新建:标题一打字就自动刷新时间戳 slug(用户手动改过 slug 就不再动)
+ const updateFolder = () => {
+ const box = $('#poFolder');
+ if (!box) return;
+ const g = (k) => { const el = $('#view [data-fm="' + k + '"]'); return el ? el.value.trim() : ''; };
+ const d = g('date') || new Date().toISOString().slice(0, 10);
+ const slug = g('slug') || makeSlug();
+ box.textContent = '保存位置:content/posts/' + d.slice(0, 4) + '/' + d.slice(0, 10) + '-' +
+ (titlePartOf(g('title')) || 'post') + '-' + slug + '/index.md';
+ };
+ const titleEl = $('#view [data-fm="title"]');
+ const slugEl = $('#view [data-fm="slug"]');
+ if (ed.isNew && titleEl && slugEl) {
+ if (!slugEl.value) slugEl.value = makeSlug();
+ slugEl.addEventListener('input', () => { ed.slugTouched = true; updateFolder(); markDirty(); });
+ titleEl.addEventListener('input', () => {
+ if (!ed.slugTouched) slugEl.value = makeSlug();
+ updateFolder();
+ });
+ updateFolder();
+ }
+
+ ta.addEventListener('input', markDirty);
+ ta.addEventListener('paste', onPasteImage, true);
+ ['poCats', 'poTags', 'poDraft'].forEach((id) => { const el = $('#' + id); if (el) el.addEventListener('input', markDirty); });
+ $$('#view [data-fm]').forEach((el) => el.addEventListener('input', markDirty));
+
+ // 快捷插入面板(对齐 write-server 的 ShortcutPanel)
+ $('.po-side').addEventListener('click', (e) => {
+ const b = e.target.closest('[data-sc]');
+ if (!b) return;
+ const fn = PO_SHORTCUTS[b.dataset.sc];
+ if (fn) { fn(); markDirty(); }
+ });
+
+ // 插入图片按钮 → 文件选择器(粘贴截图之外的第二条路)
+ const imgBtn = $('[data-act="po-img"]');
+ const imgFile = $('#poImgFile');
+ if (imgBtn && imgFile) {
+ imgBtn.addEventListener('click', () => imgFile.click());
+ imgFile.addEventListener('change', async () => {
+ for (const f of Array.from(imgFile.files || [])) await insertImage(f);
+ imgFile.value = '';
+ });
+ }
+}
+
+/** 选中文字则包裹,没选中则插入「before + 占位 + after」并选中占位(行为照抄 write-server) */
+function poWrap(before, after, placeholder) {
+ const ta = $('#poBody');
+ if (!ta) return;
+ const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart;
+ const e = ta.selectionEnd == null ? s : ta.selectionEnd;
+ const sel = ta.value.slice(s, e) || placeholder;
+ ta.value = ta.value.slice(0, s) + before + sel + after + ta.value.slice(e);
+ requestAnimationFrame(() => {
+ const pos = s + before.length + sel.length + after.length;
+ ta.focus();
+ ta.setSelectionRange(s + before.length, pos);
+ });
+}
+
+/** 在光标处整块插入模板(短代码等多行结构) */
+function poBlock(tpl) {
+ const ta = $('#poBody');
+ if (!ta) return;
+ const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart;
+ ta.value = ta.value.slice(0, s) + tpl + ta.value.slice(s);
+ requestAnimationFrame(() => {
+ ta.focus();
+ ta.setSelectionRange(s + tpl.length, s + tpl.length);
+ });
+}
+
+/**
+ * 快捷插入面板:短代码模板与 write-server 的 ShortcutPanel.tsx 完全一致。
+ * 本博客的 Hugo 短代码在 themes/ying/layouts/shortcodes/(bilibili/grid/video 均实装)。
+ */
+function shortcutPanelHtml() {
+ return '快捷插入
' +
+ '' +
+ Object.keys(PO_SHORTCUTS).map((label) =>
+ ''
+ ).join('') +
+ '
' +
+ '说明
' +
+ '- 选中文字后点按钮可包裹
' +
+ '- 未选中则插入模板 + 占位文字
' +
+ '- 视频需替换 src 中的路径
' +
+ '- B站/YouTube 替换 BV号/ID
' +
+ '
';
+}
+
+const PO_SHORTCUT_GLYPH = {
+ H2: 'H2', H3: 'H3', 粗体: 'B', 代码块: '{}', 行内代码: '`', 引用: '❝',
+ 图片: '▣', 图片网格: '▦', 本地视频: '▶', B站视频: '▶', YouTube: '▶', 链接: '⧉', 分割线: '—',
+};
+
+const PO_SHORTCUTS = {
+ H2: () => poWrap('## ', '\n', '二级标题'),
+ H3: () => poWrap('### ', '\n', '三级标题'),
+ 粗体: () => poWrap('**', '**', '粗体文本'),
+ 代码块: () => poWrap('```\n', '\n```\n', '代码'),
+ 行内代码: () => poWrap('`', '`', 'code'),
+ 引用: () => poWrap('> ', '\n', '引用内容'),
+ 图片: () => poWrap('![', ']()', '描述'),
+ 图片网格: () => poBlock('{{< grid cols="3" >}}\n![描述]()\n![描述]()\n![描述]()\n{{< /grid >}}\n'),
+ 本地视频: () => poBlock('{{< video src="/video/example.mp4" caption="视频说明" >}}\n'),
+ B站视频: () => poBlock('{{< bilibili BV1xx411c7mD >}}\n'),
+ YouTube: () => poBlock('{{< youtube dQw4w9WgXcQ >}}\n'),
+ 链接: () => poWrap('[', ']()', '链接文本'),
+ 分割线: () => poBlock('\n---\n'),
+};
+
+/** 表单 → 当前值(只在保存时调用) */
+function readForm() {
+ const fm = {};
+ $$('#view [data-fm]').forEach((el) => { fm[el.dataset.fm] = el.value; });
+ const d = $('#poDraft');
+ if (d) fm.draft = d.checked;
+ const c = $('#poCats');
+ const t = $('#poTags');
+ if (c) fm.categories = textArr(c.value);
+ if (t) fm.tags = textArr(t.value);
+ return fm;
+}
+
+/**
+ * 只回传**真正变了的**字段。
+ * 这一点很关键:后端是「没动 front matter 就原文照抄」,一旦我们无脑回传全部字段,
+ * 那些值相等的字段也会让深比较失败,于是整块 front matter 被重新序列化 ——
+ * 引号、块标量写法都会变,白产生一堆 diff。
+ */
+function fmPatch() {
+ const ed = state.ed;
+ if (!ed) return {};
+ const orig = ed.post.frontMatter;
+ if (!orig) return {}; // front matter 解析不了 → 一个字段都不碰
+ const now = readForm();
+ const out = {};
+
+ for (const f of PO_FM_FIELDS) {
+ const k = f.k;
+ const has = k in orig;
+ const raw = (now[k] == null ? '' : String(now[k])).trim();
+ if (!has && raw === '') continue; // 原本没有、也没填 → 不新增
+ const before = orig[k] == null ? '' : String(orig[k]).trim();
+ if (raw !== before) out[k] = raw;
+ }
+
+ if ((now.draft === true) !== (orig.draft === true)) out.draft = now.draft === true;
+
+ for (const k of ['categories', 'tags']) {
+ const a = now[k] || [];
+ const b = Array.isArray(orig[k]) ? orig[k] : [];
+ if (JSON.stringify(a) !== JSON.stringify(b)) out[k] = a;
+ }
+ return out;
+}
+
+function markDirty() {
+ const ed = state.ed;
+ if (!ed) return;
+ const changed = Object.keys(fmPatch()).length > 0 || ($('#poBody') && $('#poBody').value !== (ed.post.content || ''));
+ ed.dirty = changed;
+ const s = $('#poStat');
+ if (s) s.textContent = changed ? '● 有未保存的改动' : '尚未修改';
+}
+
+/** 离开编辑器:有未保存改动就先拦一下,别让人默默丢字 */
+function leaveEditor(next) {
+ const ed = state.ed;
+ if (ed && ed.dirty) {
+ confirmBox('有未保存的改动', '正文或字段还没保存,离开就丢了。确定离开?', async () => {
+ state.ed = null;
+ await next();
+ });
+ return;
+ }
+ state.ed = null;
+ next();
+}
+
+/**
+ * 保存当前文章。新建走 POST(第一次保存才落盘),已有走 PUT(只传变更字段)。
+ * 返回 true/false 表示成功与否;opts.silent 不弹 toast,opts.noReload 不重新拉取。
+ */
+async function saveCurrentPost(opts = {}) {
+ const ed = state.ed;
+ if (!ed) return false;
+ const btn = $('[data-act="po-save"]');
+ if (btn) btn.dataset.busy = '1';
+ try {
+ const content = $('#poBody').value;
+ if (ed.isNew) {
+ const fm = readForm();
+ if (!String(fm.title || '').trim()) { toast('✕ 先写个标题再保存', true); return false; }
+ if (!String(fm.slug || '').trim()) fm.slug = makeSlug(); // 兜底,正常已被前端预填
+ const r = await api('/editor/posts', { method: 'POST', body: { frontMatter: fm, content } });
+ toast('✓ 已创建:' + r.id);
+ state.ed = null;
+ await openPost(r.id);
+ } else {
+ await api('/editor/posts/' + encodeURIComponent(ed.id), {
+ method: 'PUT',
+ body: { content, frontMatter: fmPatch() },
+ });
+ if (!opts.silent) toast('✓ 已保存到文件');
+ if (!opts.noReload) await openPost(ed.id); // 以后端磁盘上的内容为准重载,避免界面状态和文件漂移
+ }
+ return true;
+ } catch (e) {
+ toast('✕ ' + e.message, true);
+ return false;
+ } finally {
+ const b2 = $('[data-act="po-save"]');
+ if (b2) delete b2.dataset.busy;
+ }
+}
+
+/** 发布 = 先保存,再弹发布确认(commit + push,会触发线上构建) */
+async function publishCurrentPost() {
+ if (!state.ed) return;
+ if (await saveCurrentPost({ silent: true, noReload: true })) openGitModal();
+}
+
+/** 粘贴图片 → 传到文章同级目录 → 插入相对路径(仓库现行写法就是裸文件名) */
+async function onPasteImage(e) {
+ const ed = state.ed;
+ if (!ed) return;
+ const items = (e.clipboardData && e.clipboardData.items) || [];
+ let f = null;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].kind === 'file' && String(items[i].type).startsWith('image/')) { f = items[i].getAsFile(); break; }
+ }
+ if (!f) return; // 纯文字粘贴走默认行为
+ e.preventDefault();
+ await insertImage(f);
+}
+
+async function insertImage(file) {
+ const ed = state.ed;
+ const ta = $('#poBody');
+ if (!ed || !ta) return;
+ const stat = $('#poStat');
+ const name = file.name || ('paste.' + (String(file.type).split('/')[1] || 'png'));
+ if (stat) stat.textContent = '正在上传 ' + name + ' …';
+
+ let res;
+ try {
+ res = await fetch(API + '/editor/upload?name=' + encodeURIComponent(name) + '&key=' + encodeURIComponent(ed.id), {
+ method: 'POST',
+ headers: { 'Content-Type': file.type || 'image/png', ...(TOKEN ? { Authorization: 'Bearer ' + TOKEN } : {}) },
+ body: file,
+ });
+ } catch (e) {
+ if (stat) stat.textContent = '上传失败';
+ toast('✕ 图片上传失败:' + e.message, true);
+ return;
+ }
+ const txt = await res.text();
+ let d = null;
+ try { d = txt ? JSON.parse(txt) : null; } catch (e) { /* 透传 */ }
+ if (!res.ok || !d || !d.fileName) {
+ if (stat) stat.textContent = '上传失败';
+ toast('✕ ' + ((d && (d.error || d.msg)) || '上传失败 HTTP ' + res.status), true);
return;
}
- const rows = folders.map((f) =>
- ''
- ).join('') || '暂无图片文件夹
';
-
- $('#view').innerHTML +=
- '图片文件夹(' + folders.length + ')
' +
- '' +
- '';
+ // Markdown 里图片必须前后各空一行才是独立段落 —— 统一规范化,避免「图片和文字粘在一起」
+ const at = ta.selectionStart == null ? ta.value.length : ta.selectionStart;
+ const before = ta.value.slice(0, at).replace(/\n+$/, '');
+ const after = ta.value.slice(at).replace(/^\n+/, '');
+ const md = '';
+ ta.value = (before ? before + '\n\n' : '') + md + '\n\n' + after;
+ const pos = (before ? before.length + 2 : 0) + md.length + 2;
+ ta.focus();
+ ta.setSelectionRange(pos, pos);
+ ed.dirty = true;
+ if (stat) stat.textContent = '● 已插入图片 ' + d.fileName + '(' + Math.round((d.size || 0) / 1024) + ' KB),记得保存';
+ markDirty();
}
-function addImage() {
- modal('添加图片',
- '' +
- '' +
- '');
- $('#iSave').onclick = async () => {
- const b = $('#iSave'); b.dataset.busy = '1';
+/** 发布 / 同步:把改动提交并推到主仓(会触发线上构建) */
+async function openGitModal() {
+ modal('发布与同步', '读取仓库状态…
');
+ let st;
+ try {
+ st = await api('/editor/git/status');
+ } catch (e) {
+ $('#modalBody').innerHTML = '读不到仓库状态' + esc(e.message) + '
';
+ return;
+ }
+ const files = (st.files || []).map((f) => '' + esc(f.slice(0, 2).trim() || '·') + '' +
+ '' + esc(f.slice(2).trim().replace(/^"|"$/g, '')) + '
').join('') ||
+ '没有改动
';
+
+ $('#modalBody').innerHTML =
+ '' +
+ '- 分支
- ' + esc(st.branch) + '
' +
+ '- 改动
- ' + (st.dirty ? '' + nfmt(st.changed) + ' 个文件待发布' : '工作区干净') + '
' +
+ '- 最近提交
- ' + esc(st.lastCommit || '—') + '
' +
+ '
' +
+ '待发布文件
' +
+ '' + files + '
' +
+ '' +
+ '发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。
' +
+ '' +
+ '' +
+ '' +
+ '
';
+
+ $('#poSync').onclick = async () => {
+ const b = $('#poSync');
+ b.dataset.busy = '1';
try {
- const urls = $('#iUrls').value.split('\n').map((s) => s.trim()).filter(Boolean);
- const folder = $('#iFolder').value.trim() || 'jiege';
- await rssApi('/api/random-image?folder=' + encodeURIComponent(folder), { method: 'POST', body: { urls } });
- closeModal(); toast('✓ 已添加'); viewImages();
+ const r = await api('/editor/git/sync', { method: 'POST' });
+ toast('✓ 已同步:' + String(r.log || '').split('\n').slice(-1)[0].slice(0, 60));
+ closeModal();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
+
+ $('#poPub').onclick = async () => {
+ const b = $('#poPub');
+ b.dataset.busy = '1';
+ try {
+ const r = await api('/editor/git/publish', { method: 'POST', body: { message: ($('#poMsg') || {}).value || '' } });
+ closeModal();
+ toast('✓ 已发布到 ' + (r.pushed || []).join(' / ') + (r.warning ? '(部分备份远端失败,见控制台)' : ''));
+ if (r.warning) console.warn('[editor] ' + r.warning);
+ clearDirtyFlag();
+ if (state.ed && !state.ed.isNew) await openPost(state.ed.id); // 重新拉一遍,让状态栏回到「尚未修改」
+ } catch (e) {
+ toast('✕ ' + e.message, true);
+ delete b.dataset.busy;
+ }
+ };
+}
+
+/** 发布成功后前端这个「未保存」标记就该清了(文件已经进到 commit 里) */
+function clearDirtyFlag() {
+ if (!state.ed) return;
+ state.ed.dirty = false;
+ const s = $('#poStat');
+ if (s) s.textContent = '尚未修改';
}
// ============================================================ 侧边栏抽屉 / 主题
@@ -1129,12 +1717,61 @@ document.addEventListener('click', async (e) => {
if (act === 'modal-close') { closeModal(); return; }
if (act === 'menu') { toggleSidebar(); return; }
if (act === 'theme') { toggleTheme(); return; }
- if (act === 'tab') { if (state.tab !== el.dataset.id) go(el.dataset.id); return; }
+ if (act === 'tab') {
+ const target = el.dataset.id;
+ if (target === 'posts' && state.tab === 'posts' && state.ed) {
+ // 再点一次「文章编辑」= 从编辑器退回列表
+ leaveEditor(() => go('posts'));
+ return;
+ }
+ if (state.tab === target) return;
+ if (target !== 'posts' && state.ed && state.ed.dirty) {
+ leaveEditor(() => go(target));
+ return;
+ }
+ if (target !== 'posts') state.ed = null;
+ go(target);
+ return;
+ }
if (act === 'logout') {
confirmBox('退出登录', '确定要退出当前会话吗?', () => logout());
return;
}
+ // --- 文章编辑
+ if (act === 'po-reload') { el.dataset.busy = '1'; try { await loadPosts(); } finally { delete el.dataset.busy; } return; }
+ if (act === 'po-prev') { state.po.page = Math.max(1, state.po.page - 1); await loadPosts(); return; }
+ if (act === 'po-next') { state.po.page += 1; await loadPosts(); return; }
+ if (act === 'po-open') { await openPost(el.dataset.id); return; }
+ if (act === 'po-new') { newPost(); return; }
+ if (act === 'po-git') { openGitModal(); return; }
+ if (act === 'po-publish') { await publishCurrentPost(); return; }
+ if (act === 'po-back') { leaveEditor(() => go('posts')); return; }
+ if (act === 'po-save') { await saveCurrentPost(); return; }
+ if (act === 'po-reload-post') {
+ if (!state.ed) return;
+ confirmBox('还原改动', '丢掉当前还没保存的修改,重新从文件读一遍?', async () => {
+ await openPost(state.ed.id);
+ toast('✓ 已从文件重载');
+ });
+ return;
+ }
+ if (act === 'po-del') {
+ // 列表里的删除按钮带 data-id/data-title;编辑器里的只带 act
+ const did = el.dataset.id || (state.ed && state.ed.id) || '';
+ const dtitle = el.dataset.title || (state.ed && state.ed.post && state.ed.post.title) || did;
+ if (!did) return;
+ confirmBox('删除「' + dtitle + '」',
+ '文件会被移到服务器上的回收目录(不进 git,也不影响版本历史),但前台会立刻少一篇。确定删除?',
+ async () => {
+ await api('/editor/posts/' + encodeURIComponent(did), { method: 'DELETE' });
+ toast('✓ 已删除(可在服务器回收目录找回)');
+ state.ed = null;
+ await go('posts');
+ });
+ return;
+ }
+
// --- 评论
if (act === 'c-reload') { el.dataset.busy = '1'; try { await loadComments(); } finally { delete el.dataset.busy; } return; }
if (act === 'c-prev') { state.c.offset = Math.max(0, state.c.offset - state.c.limit); await loadComments(); return; }
@@ -1179,6 +1816,9 @@ document.addEventListener('click', async (e) => {
// --- 站点
if (act === 's-edit') { editSite(id); return; }
+ // --- 设置(表单化)
+ if (act === 'sf-save') { await saveSettings(); return; }
+
// --- 订阅源
if (act === 'f-reload') { el.dataset.busy = '1'; try { await viewFeeds(); } finally { delete el.dataset.busy; } return; }
if (act === 'f-add') { addFeed(); return; }
@@ -1216,10 +1856,6 @@ document.addEventListener('click', async (e) => {
});
return;
}
-
- // --- 图片
- if (act === 'i-reload') { el.dataset.busy = '1'; try { await viewImages(); } finally { delete el.dataset.busy; } return; }
- if (act === 'i-add') { addImage(); return; }
});
// ============================================================ 启动
@@ -1280,6 +1916,21 @@ async function bootstrap() {
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light');
+// Ctrl/⌘ + S 保存当前文章(浏览器默认的「保存网页」在这里没意义,直接接管)
+document.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 's') {
+ if (state.tab === 'posts' && state.ed) {
+ e.preventDefault();
+ saveCurrentPost();
+ }
+ }
+});
+
+// 关标签页前提醒(只在编辑器里有未保存改动时)
+window.addEventListener('beforeunload', (e) => {
+ if (state.ed && state.ed.dirty) { e.preventDefault(); e.returnValue = ''; }
+});
+
(async function boot() {
if (!TOKEN) { showGate(); return; }
try {
diff --git a/blog-admin/src/index.ts b/blog-admin/src/index.ts
index d684bd5d..03926002 100644
--- a/blog-admin/src/index.ts
+++ b/blog-admin/src/index.ts
@@ -10,6 +10,7 @@ import * as P from './routes/public';
import * as U from './routes/user';
import * as A from './routes/admin';
import * as H from './routes/human';
+import * as E from './routes/editor';
const router = new Router();
@@ -31,6 +32,20 @@ router.get('/human/challenge', H.humanChallenge);
router.post('/human/verify', H.humanVerify);
router.get('/human/status', H.humanStatus);
+// ---------------------------------------------------------------- 文章编辑
+// 本 Worker 不碰仓库:只是鉴权后用 X-Editor-Token 反代到 editor-api 容器。
+// 必须挂在 /api/v2 下 —— /api/*(非 v2)已被上面的 RSS 模块整个接走。
+router.get('/editor/health', E.health);
+router.get('/editor/posts', E.listPosts);
+router.post('/editor/posts', E.createPost);
+router.get('/editor/posts/:slug', E.getPost);
+router.put('/editor/posts/:slug', E.savePost);
+router.del('/editor/posts/:slug', E.deletePost);
+router.post('/editor/upload', E.upload);
+router.get('/editor/git/status', E.gitStatus);
+router.post('/editor/git/publish', E.publish);
+router.post('/editor/git/sync', E.sync);
+
// ---------------------------------------------------------------- 评论
router.get('/comments', C.listComments);
router.post('/comments', C.createComment);
@@ -64,6 +79,8 @@ router.post('/sso/exchange', U.ssoExchange);
// ---------------------------------------------------------------- 后台:设置
router.get('/settings', A.settingGet);
router.put('/settings', A.settingApply);
+router.get('/settings/form', A.settingFormGet);
+router.put('/settings/form', A.settingFormApply);
router.get('/settings/template/:locale', A.settingTemplate);
// ---------------------------------------------------------------- 后台:站点
diff --git a/blog-admin/src/lib/settings-form.ts b/blog-admin/src/lib/settings-form.ts
new file mode 100644
index 00000000..3429b215
--- /dev/null
+++ b/blog-admin/src/lib/settings-form.ts
@@ -0,0 +1,204 @@
+/**
+ * 设置页的表单化:schema 驱动。
+ *
+ * 后端是唯一事实源 —— schema、取值、合并、序列化都在这里做,
+ * 前端只负责把 schema 画成表单、把用户改的值原样传回来,完全不碰 YAML。
+ *
+ * 数据流:
+ * GET /settings/form → parseSimpleYaml(config_yaml 或模板) → { schema, values, extra }
+ * PUT /settings/form → mergeForm(values, extra) → toSimpleYaml → 走 settingApply 的
+ * 全部回填逻辑(frontend_conf / captcha / human_check /
+ * moderator / ip_region / site_default),保证行为和手改 YAML 一致。
+ *
+ * ★ `extra` 是 schema 之外的键(admin_users、admin_notify、auth……),
+ * 前端不渲染但会原样传回 —— 这样表单化永远不会静默丢配置。
+ */
+
+export interface FieldDef {
+ /** 配置路径,如 'frontend.pagination.pageSize' */
+ path: string;
+ label: string;
+ type?: 'text' | 'bool' | 'number' | 'select';
+ options?: { v: string; t: string }[];
+ /** 解析结果里没有这个键时的表单默认值 */
+ def?: unknown;
+ /** 生成 YAML 时写在该键上方的注释 */
+ desc?: string;
+ /** 表单里的辅助说明 */
+ hint?: string;
+}
+
+export interface GroupDef {
+ title: string;
+ desc?: string;
+ fields: FieldDef[];
+}
+
+export const SETTINGS_FORM: GroupDef[] = [
+ {
+ title: '站点身份',
+ desc: '评论归属与站点身份',
+ fields: [
+ { path: 'site_default', label: '默认站点名', type: 'text', desc: '默认站点名', hint: '没有匹配到 page 的 site 时用它' },
+ ],
+ },
+ {
+ title: '审核与风控',
+ desc: '什么评论需要人来看一眼',
+ fields: [
+ { path: 'moderator.pending_default', label: '新评论默认待审', type: 'bool', def: false, desc: '默认待审', hint: '开了之后所有新评论都要后台审核才显示' },
+ { path: 'human_check.enabled', label: '人机验证(一键验证)', type: 'bool', def: false, desc: 'human_check 开关(存 KV,评论提交时读)', hint: '读者无感,可疑请求才出验证' },
+ { path: 'captcha.enabled', label: '验证码', type: 'bool', def: false, desc: '启用验证码' },
+ { path: 'ip_region.enabled', label: '显示 IP 归属地', type: 'bool', def: false, desc: '启用 IP 属地展示', hint: '如 CN / US' },
+ ],
+ },
+ {
+ title: '评论列表',
+ desc: '前台评论区的排列与分页',
+ fields: [
+ { path: 'frontend.pagination.pageSize', label: '每页评论数', type: 'number', def: 20, desc: '每页评论数' },
+ { path: 'frontend.countOfCommentsOnPageInit', label: '初始加载条数', type: 'number', def: 20 },
+ { path: 'frontend.nestMax', label: '最大嵌套层数', type: 'number', def: 2, desc: '最大嵌套层数' },
+ {
+ path: 'frontend.nestSort', label: '嵌套排序', type: 'select', def: 'DATE_ASC', desc: '嵌套评论排序规则',
+ options: [
+ { v: 'DATE_ASC', t: '最早优先' },
+ { v: 'DATE_DESC', t: '最新优先' },
+ { v: 'VOTE_UP_DESC', t: '点赞优先' },
+ ],
+ },
+ {
+ path: 'frontend.darkMode', label: '夜间模式', type: 'select', def: 'inherit', desc: '夜间模式',
+ options: [
+ { v: 'inherit', t: '跟随页面' },
+ { v: 'auto', t: '自动' },
+ ],
+ },
+ { path: 'frontend.heightLimit.content', label: '评论内容限高 (px)', type: 'number', def: 300 },
+ { path: 'frontend.heightLimit.children', label: '子评论区限高 (px)', type: 'number', def: 400 },
+ ],
+ },
+ {
+ title: '评论框',
+ desc: '输入区与按钮的文字、开关',
+ fields: [
+ { path: 'frontend.placeholder', label: '占位文字', type: 'text', desc: '评论框占位文字' },
+ { path: 'frontend.noComment', label: '无评论提示文字', type: 'text', desc: '无评论显示文字' },
+ { path: 'frontend.sendBtn', label: '发送按钮文字', type: 'text', desc: '发送按钮文字' },
+ { path: 'frontend.emoticons', label: '表情包地址', type: 'text', desc: '表情包', hint: 'JSON 配置地址,留空关闭' },
+ { path: 'frontend.editorTravel', label: '评论框穿梭', type: 'bool', def: true, desc: '评论框穿梭', hint: '点「回复」时输入框跟过去' },
+ { path: 'frontend.preview', label: '实时预览', type: 'bool', def: true, desc: '编辑器实时预览功能' },
+ { path: 'frontend.vote', label: '投票按钮(赞)', type: 'bool', def: true, desc: '投票按钮' },
+ { path: 'frontend.voteDown', label: '反对按钮(踩)', type: 'bool', def: false, desc: '反对按钮' },
+ { path: 'frontend.listSort', label: '评论排序切换', type: 'bool', def: true, desc: '评论排序功能' },
+ { path: 'frontend.uaBadge', label: 'UA 徽标', type: 'bool', def: false, desc: '用户 UA 徽标', hint: '显示读者的操作系统 / 浏览器' },
+ ],
+ },
+ {
+ title: '头像与其他',
+ fields: [
+ { path: 'frontend.gravatar.mirror', label: '头像源地址', type: 'text', desc: '头像 Gravatar API 地址' },
+ { path: 'frontend.gravatar.params', label: '头像参数', type: 'text', desc: '头像 API 参数', hint: '如 sha256=1&d=mp&s=240' },
+ { path: 'frontend.imgLazyLoad', label: '图片懒加载', type: 'select', def: 'false', desc: '图片懒加载',
+ options: [
+ { v: 'false', t: '关闭' },
+ { v: 'native', t: 'native' },
+ { v: 'data-src', t: 'data-src' },
+ ] },
+ { path: 'frontend.reqTimeout', label: '请求超时 (ms)', type: 'number', def: 15000, desc: '请求超时(毫秒)' },
+ { path: 'frontend.versionCheck', label: '版本检测', type: 'bool', def: true, desc: '版本检测' },
+ ],
+ },
+];
+
+// ------------------------------------------------------------------ 工具
+
+function getByPath(obj: unknown, path: string): unknown {
+ let cur: unknown = obj;
+ for (const k of path.split('.')) {
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
+ cur = (cur as Record)[k];
+ }
+ return cur;
+}
+
+function setByPath(obj: Record, path: string, value: unknown): void {
+ const parts = path.split('.');
+ let cur: Record = obj;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const k = parts[i];
+ if (cur[k] === null || typeof cur[k] !== 'object' || Array.isArray(cur[k])) cur[k] = {};
+ cur = cur[k] as Record;
+ }
+ cur[parts[parts.length - 1]] = value;
+}
+
+function deleteByPath(obj: Record, path: string): void {
+ const parts = path.split('.');
+ let cur: unknown = obj;
+ for (let i = 0; i < parts.length - 1; i++) {
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return;
+ cur = (cur as Record)[parts[i]];
+ }
+ if (cur !== null && typeof cur === 'object' && !Array.isArray(cur)) {
+ delete (cur as Record)[parts[parts.length - 1]];
+ }
+}
+
+function defaultFor(f: FieldDef): unknown {
+ if (f.def !== undefined) return f.def;
+ if (f.type === 'bool') return false;
+ if (f.type === 'number') return '';
+ return '';
+}
+
+/** 把解析好的配置拆成「表单值」+「schema 外的剩余键」 */
+export function extractForm(parsed: Record): {
+ values: Record;
+ extra: Record;
+} {
+ const extra: Record = JSON.parse(JSON.stringify(parsed ?? {}));
+ const values: Record = {};
+ for (const g of SETTINGS_FORM) {
+ for (const f of g.fields) {
+ const v = getByPath(extra, f.path);
+ values[f.path] = v === undefined ? defaultFor(f) : v;
+ deleteByPath(extra, f.path);
+ }
+ }
+ return { values, extra };
+}
+
+/** 表单值 + 剩余键 → 完整配置对象(types 走一层宽松校正,避免写出非法 YAML) */
+export function mergeForm(
+ values: Record,
+ extra: Record,
+): Record {
+ const out: Record = JSON.parse(JSON.stringify(extra ?? {}));
+ for (const g of SETTINGS_FORM) {
+ for (const f of g.fields) {
+ const raw = values ? values[f.path] : undefined;
+ if (raw === undefined) continue;
+ if (f.type === 'bool') {
+ setByPath(out, f.path, raw === true || raw === 'true');
+ } else if (f.type === 'number') {
+ const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
+ setByPath(out, f.path, String(raw).trim() === '' ? (typeof f.def === 'number' ? f.def : 0) : Number.isFinite(n) ? n : f.def ?? 0);
+ } else {
+ setByPath(out, f.path, String(raw));
+ }
+ }
+ }
+ return out;
+}
+
+/** schema 里声明的注释(生成 YAML 时带回去,配置文件不至于变成天书) */
+export function formComments(): Map {
+ const m = new Map();
+ for (const g of SETTINGS_FORM) {
+ for (const f of g.fields) {
+ if (f.desc) m.set(f.path, f.desc);
+ }
+ }
+ return m;
+}
diff --git a/blog-admin/src/lib/simple-yaml.ts b/blog-admin/src/lib/simple-yaml.ts
new file mode 100644
index 00000000..c066a4b6
--- /dev/null
+++ b/blog-admin/src/lib/simple-yaml.ts
@@ -0,0 +1,180 @@
+/**
+ * 极简 YAML 读写 —— 只认「嵌套 map + 标量 + 列表(块式与内联)」,与设置页的需求严格同构。
+ *
+ * ★ 为什么不用真正的 YAML 库:设置页只需要读写 artalk.yml 的一个子集,
+ * 引一整个 yaml 依赖(几十 KB)只为读几个键不值;而且自己写可以保证
+ * 「自己写出去的字符串自己一定读得回来」(toSimpleYaml ↔ parseSimpleYaml 互为逆运算)。
+ *
+ * ★ 必须支持**块式列表**(`- item`):artalk.yml 里 admin_notify.telegram.receivers、
+ * moderator.keywords.files 都是这种写法。老解析器把它们读成空对象,再序列化就丢数据
+ * —— 表单化保存走的是「解析→重序列化」,这个丢法会静默改配置,不能忍。
+ */
+
+export function parseSimpleYaml(text: string): Record {
+ const root: Record = {};
+ type Frame = {
+ indent: number;
+ node: Record | unknown[];
+ /** 裸键先建空对象占位;遇到列表项时升级成数组 */
+ pendingList: boolean;
+ parent?: Record;
+ key?: string;
+ };
+ const stack: Frame[] = [{ indent: -1, node: root, pendingList: false }];
+
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
+ if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue;
+ const indent = rawLine.match(/^\s*/)![0].length;
+ const line = rawLine.trim();
+
+ // ---- 列表项:`- 值` / `- 键: 值` / `-` ----
+ if (/^(-\s+|$)/.test(line)) {
+ const top = stack[stack.length - 1];
+ // 裸键占位 → 升级为数组
+ if (top.pendingList && top.parent && top.key) {
+ const arr: unknown[] = [];
+ top.parent[top.key] = arr;
+ top.node = arr;
+ top.pendingList = false;
+ }
+ if (Array.isArray(top.node)) {
+ const rest = line === '-' ? '' : line.slice(2).trim();
+ if (!rest) {
+ (top.node as unknown[]).push('');
+ } else {
+ const kv = rest.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
+ if (kv) {
+ const item: Record = {};
+ item[kv[1]] = kv[2] ? coerce(kv[2]) : {};
+ (top.node as unknown[]).push(item);
+ } else {
+ (top.node as unknown[]).push(coerce(rest));
+ }
+ }
+ }
+ continue; // 列表项不参与键栈
+ }
+
+ const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
+ if (!m) continue;
+ const key = m[1];
+ let value = m[2];
+
+ // 去掉行内注释(只处理 " #" 且不在引号里的情况)
+ if (value && !/^['"]/.test(value)) {
+ const hash = value.search(/\s+#/);
+ if (hash >= 0) value = value.slice(0, hash).trim();
+ }
+
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
+ const parent = stack[stack.length - 1].node;
+ if (Array.isArray(parent) || typeof parent !== 'object') continue; // 键不能挂在数组上
+
+ if (!value) {
+ const child: Record = {};
+ parent[key] = child;
+ stack.push({ indent, node: child, pendingList: true, parent, key });
+ continue;
+ }
+
+ (parent as Record)[key] = coerce(value);
+ }
+ return root;
+}
+
+function coerce(value: string): unknown {
+ let v = value.trim();
+ if (
+ (v.startsWith('"') && v.endsWith('"') && v.length > 1) ||
+ (v.startsWith("'") && v.endsWith("'") && v.length > 1)
+ ) {
+ v = v.slice(1, -1);
+ }
+ if (v === 'true' || v === 'True' || v === 'yes') return true;
+ if (v === 'false' || v === 'False' || v === 'no') return false;
+ if (v === 'null' || v === '~') return null;
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
+ if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
+ if (v.startsWith('[') && v.endsWith(']')) {
+ return v
+ .slice(1, -1)
+ .split(',')
+ .map((s) => coerce(s.trim()))
+ .filter((s) => s !== '');
+ }
+ return v;
+}
+
+// ------------------------------------------------------------------ 序列化
+
+/** comments:路径(如 "frontend.placeholder")→ 写在该键上方的注释行 */
+export function toSimpleYaml(
+ data: Record,
+ comments?: Map,
+): string {
+ return writeMap(data ?? {}, 0, '', comments ?? new Map());
+}
+
+function writeMap(
+ obj: Record,
+ indent: number,
+ prefix: string,
+ comments: Map,
+): string {
+ const pad = ' '.repeat(indent);
+ let out = '';
+ for (const [k, v] of Object.entries(obj)) {
+ const p = prefix ? prefix + '.' + k : k;
+ const c = comments.get(p) ? '# ' + comments.get(p) + '\n' : '';
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
+ const keys = Object.keys(v as Record);
+ if (keys.length === 0) {
+ out += c + pad + k + ': {}\n';
+ } else {
+ out += c + pad + k + ':\n' + writeMap(v as Record, indent + 1, p, comments);
+ }
+ } else if (Array.isArray(v)) {
+ if (v.length === 0) {
+ out += c + pad + k + ': []\n';
+ } else {
+ // 块式列表(与 artalk.yml 模板的写法一致,解析器已支持)
+ out += c + pad + k + ':\n' + v.map((x) => writeItem(x, indent + 1)).join('');
+ }
+ } else {
+ out += c + pad + k + ': ' + scalarYaml(v) + '\n';
+ }
+ }
+ return out;
+}
+
+function writeItem(v: unknown, indent: number): string {
+ const pad = ' '.repeat(indent);
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
+ const o = v as Record;
+ const keys = Object.keys(o);
+ if (keys.length === 1 && (o[keys[0]] === null || typeof o[keys[0]] !== 'object')) {
+ return pad + '- ' + keys[0] + ': ' + scalarYaml(o[keys[0]]) + '\n';
+ }
+ return pad + '- ' + JSON.stringify(v) + '\n'; // 复杂条目(当前模板里没有)
+ }
+ return pad + '- ' + scalarYaml(v) + '\n';
+}
+
+function scalarYaml(v: unknown): string {
+ if (v === null || v === undefined) return 'null';
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
+ const s = String(v);
+ return needsQuote(s) ? '"' + s.replace(/"/g, "'") + '"' : s;
+}
+
+function needsQuote(s: string): boolean {
+ if (s === '') return true;
+ if (/^\s|\s$/.test(s)) return true; // 首尾空白
+ if (/^(true|false|yes|no|null|~)$/i.test(s)) return true; // 会被解析成布尔/null
+ if (/^-?\d+(\.\d+)?$/.test(s)) return true; // 会被解析成数字
+ if (/^[[{]/.test(s)) return true; // 会被解析成数组/对象
+ if (/\s#/.test(s)) return true; // 会被当成行内注释截断
+ if (/\\/.test(s)) return true; // 反斜杠加引号保护(如 "\n" 分隔符)
+ return false;
+}
diff --git a/blog-admin/src/routes/admin.ts b/blog-admin/src/routes/admin.ts
index d9511a0e..3a8f40ec 100644
--- a/blog-admin/src/routes/admin.ts
+++ b/blog-admin/src/routes/admin.ts
@@ -6,11 +6,17 @@ import {
findOrCreateSite,
findOrCreateUser,
findUserByNameEmail,
+ getFrontendConf,
+ isCaptchaEnabled,
+ isIPRegionEnabled,
+ isPendingDefault,
} from '../lib/db';
-import { setHumanCheck } from '../lib/human';
+import { isHumanCheckEnabled, setHumanCheck } from '../lib/human';
import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook';
import { hashPassword, isAdminRequest } from '../lib/session';
import { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util';
+import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml';
+import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form';
import { SERVER_API_VERSION } from './public';
import type { Ctx } from '../router';
@@ -1049,7 +1055,63 @@ export async function settingApply(ctx: Ctx): Promise {
if (!yaml.trim()) return fail(400, 'yaml is required');
const parsed = parseSimpleYaml(yaml);
+ await persistSettings(ctx, parsed, yaml);
+ return ok({ msg: 'Success' });
+}
+/**
+ * 设置页表单化:GET 返回 schema + 解析好的表单值 + schema 之外的剩余键。
+ * 前端只画表单,完全不碰 YAML;剩余键原样传回,保证表单化不丢配置。
+ */
+export async function settingFormGet(ctx: Ctx): Promise {
+ const deny = await requireAdmin(ctx);
+ if (deny) return deny;
+
+ const stored = await ctx.env.DB.prepare('SELECT value FROM settings WHERE key = ?')
+ .bind('config_yaml')
+ .first<{ value: string }>();
+ const yaml = stored?.value || (await loadTemplate(ctx, 'zh-CN'));
+ const parsed = parseSimpleYaml(yaml);
+ // ★ 表单值必须以「运行时实际生效的配置」为准:
+ // config_yaml 只是落地文本,可能从没保存过、也可能早就过期;
+ // 评论区真正读的是 D1 的 frontend_conf/captcha/moderator/ip_region/site_default
+ // 和 KV 的 human_check。不覆盖的话表单显示一套、评论系统跑另一套。
+ await overlayRuntimeConf(ctx.env, parsed);
+ const { values, extra } = extractForm(parsed);
+ return ok({ schema: SETTINGS_FORM, values, extra, custom: Boolean(stored?.value) });
+}
+
+/** 把运行时实际生效的配置覆盖到解析结果上(只影响表单展示;保存时又会写回 config_yaml,两边收敛) */
+async function overlayRuntimeConf(env: Env, parsed: Record): Promise {
+ const asObj = (v: unknown): Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {};
+
+ // frontend.*:/conf 实际下发的就是 DEFAULT_FRONTEND_CONF ⊕ frontend_conf
+ parsed.frontend = { ...asObj(parsed.frontend), ...(await getFrontendConf(env)) };
+ parsed.captcha = { ...asObj(parsed.captcha), enabled: await isCaptchaEnabled(env) };
+ parsed.moderator = { ...asObj(parsed.moderator), pending_default: await isPendingDefault(env) };
+ parsed.ip_region = { ...asObj(parsed.ip_region), enabled: await isIPRegionEnabled(env) };
+ parsed.site_default = await defaultSiteName(env);
+ parsed.human_check = { ...asObj(parsed.human_check), enabled: await isHumanCheckEnabled(env) };
+}
+
+/** 表单值 → YAML → 与手改 YAML 完全相同的持久化路径 */
+export async function settingFormApply(ctx: Ctx): Promise {
+ const deny = await requireAdmin(ctx);
+ if (deny) return deny;
+
+ const body = await readBody(ctx.req);
+ const values = body.values && typeof body.values === 'object' ? body.values : {};
+ const extra = body.extra && typeof body.extra === 'object' ? body.extra : {};
+ const merged = mergeForm(values, extra);
+ const yaml = toSimpleYaml(merged, formComments());
+ const parsed = parseSimpleYaml(yaml); // 自己写的必须自己能读回来
+ await persistSettings(ctx, parsed, yaml);
+ return ok({ msg: 'Success' });
+}
+
+/** settingApply / settingFormApply 共用的落库与回填逻辑 */
+async function persistSettings(ctx: Ctx, parsed: Record, yaml: string): Promise {
await ctx.env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('config_yaml', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
@@ -1058,13 +1120,14 @@ export async function settingApply(ctx: Ctx): Promise {
.run();
// 回填前端配置
- if (parsed.frontend && typeof parsed.frontend === 'object') {
- await writeSetting(ctx, 'frontend_conf', parsed.frontend);
+ const frontend = parsed.frontend;
+ if (frontend && typeof frontend === 'object' && !Array.isArray(frontend)) {
+ await writeSetting(ctx, 'frontend_conf', frontend);
}
// 验证码
- const captchaMode =
- parsed?.captcha?.mode ?? parsed?.captcha?.captcha_mode ?? parsed?.captcha?.enabled;
+ const cap = parsed.captcha as Record | undefined;
+ const captchaMode = cap?.mode ?? cap?.captcha_mode ?? cap?.enabled;
const captchaOn =
captchaMode === true || captchaMode === 'always' || captchaMode === 'once' || captchaMode === 'on';
await writeSetting(ctx, 'captcha', { enabled: captchaOn, mode: captchaMode ?? 'off' });
@@ -1080,16 +1143,15 @@ export async function settingApply(ctx: Ctx): Promise {
}
// 审核
- const pendingDefault =
- parsed?.moderator?.pending_default ??
- parsed?.moderator?.pendingDefault ??
- parsed?.moderator?.pending;
+ const mod = parsed.moderator as Record | undefined;
+ const pendingDefault = mod?.pending_default ?? mod?.pendingDefault ?? mod?.pending;
if (pendingDefault !== undefined) {
await writeSetting(ctx, 'moderator', { pendingDefault: toBool(pendingDefault) });
}
// IP 归属地
- const ipRegion = parsed?.ip_region?.enabled ?? parsed?.ip_region?.ip_region_enabled;
+ const ipr = parsed.ip_region as Record | undefined;
+ const ipRegion = ipr?.enabled ?? ipr?.ip_region_enabled;
if (ipRegion !== undefined) {
await writeSetting(ctx, 'ip_region', { enabled: toBool(ipRegion) });
}
@@ -1100,8 +1162,6 @@ export async function settingApply(ctx: Ctx): Promise {
await writeSetting(ctx, 'site_default', siteDefault.trim());
await findOrCreateSite(ctx.env, siteDefault.trim(), ctx.env.SITE_URL);
}
-
- return ok({ msg: 'Success' });
}
export async function settingTemplate(ctx: Ctx): Promise {
@@ -1134,67 +1194,6 @@ function toBool(v: unknown): boolean {
return s === 'true' || s === '1' || s === 'on' || s === 'yes' || s === 'always';
}
-/**
- * 极简 YAML 解析:只认「嵌套 map + 标量 + 标量列表」,
- * 足够读出设置页里我们关心的那几个键;其余内容原样保留在 config_yaml 里。
- */
-export function parseSimpleYaml(text: string): Record {
- const root: Record = {};
- const stack: { indent: number; node: Record }[] = [{ indent: -1, node: root }];
-
- for (const rawLine of text.split(/\r?\n/)) {
- if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue;
- const indent = rawLine.match(/^\s*/)![0].length;
- const line = rawLine.trim();
-
- const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
- if (!m) continue;
- const key = m[1];
- let value = m[2];
-
- // 去掉行内注释(只处理 " #" 且不在引号里的情况)
- if (value && !/^['"]/.test(value)) {
- const hash = value.search(/\s+#/);
- if (hash >= 0) value = value.slice(0, hash).trim();
- }
-
- while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
- const parent = stack[stack.length - 1].node;
-
- if (!value) {
- const child: Record = {};
- parent[key] = child;
- stack.push({ indent, node: child });
- continue;
- }
-
- parent[key] = coerce(value);
- }
- return root;
-}
-
-function coerce(value: string): unknown {
- let v = value.trim();
- if (
- (v.startsWith('"') && v.endsWith('"') && v.length > 1) ||
- (v.startsWith("'") && v.endsWith("'") && v.length > 1)
- ) {
- v = v.slice(1, -1);
- }
- if (v === 'true' || v === 'True' || v === 'yes') return true;
- if (v === 'false' || v === 'False' || v === 'no') return false;
- if (/^-?\d+$/.test(v)) return parseInt(v, 10);
- if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
- if (v.startsWith('[') && v.endsWith(']')) {
- return v
- .slice(1, -1)
- .split(',')
- .map((s) => coerce(s.trim()))
- .filter((s) => s !== '');
- }
- return v;
-}
-
// ==================================================================== 版本
export async function version(): Promise {
diff --git a/blog-admin/src/routes/editor.ts b/blog-admin/src/routes/editor.ts
new file mode 100644
index 00000000..9d00da4f
--- /dev/null
+++ b/blog-admin/src/routes/editor.ts
@@ -0,0 +1,149 @@
+/**
+ * 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。
+ *
+ * 为什么要绕这一跳,而不是让浏览器直连后端:
+ * 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
+ * 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
+ * 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。
+ *
+ * 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
+ * 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
+ * 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
+ */
+import type { Ctx } from '../router';
+import { fail, json } from '../lib/util';
+import { isAdminRequest } from '../lib/session';
+
+async function guard(ctx: Ctx): Promise {
+ if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) {
+ return fail(403, '需要管理员权限');
+ }
+ if (!ctx.env.EDITOR_API_BASE) {
+ return fail(503, '后端未配置:缺少 EDITOR_API_BASE');
+ }
+ if (!ctx.env.EDITOR_TOKEN) {
+ return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)');
+ }
+ return null;
+}
+
+function target(ctx: Ctx, path: string, withQuery = false): string {
+ const root = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
+ return root + path + (withQuery && ctx.url.search ? ctx.url.search : '');
+}
+
+/** 发到后端并原样把 JSON 与状态码带回来 */
+async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise {
+ const deny = await guard(ctx);
+ if (deny) return deny;
+
+ let res: Response;
+ try {
+ res = await fetch(target(ctx, path, withQuery), {
+ ...init,
+ headers: {
+ 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
+ ...(init.headers || {}),
+ },
+ });
+ } catch (e) {
+ // 后端容器挂了 / DNS 不通 / 被墙,都要说人话,别让前端看到 Failed to fetch
+ return fail(502, '编辑后端不可达:' + (e instanceof Error ? e.message : String(e)));
+ }
+
+ const text = await res.text();
+ let data: unknown = text;
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch {
+ /* 后端没返回 JSON 就原样透传 */
+ }
+ return json(data, { status: res.status });
+}
+
+/** 把浏览器发来的 JSON 原样转发(体积上限交给后端把关) */
+async function relayJson(ctx: Ctx, path: string, method: string): Promise {
+ let body: string;
+ try {
+ body = await ctx.req.text();
+ } catch {
+ return fail(400, '读取请求体失败');
+ }
+ return relay(ctx, path, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: body || undefined,
+ });
+}
+
+// ---------------------------------------------------------------- 文章
+
+export const listPosts = (ctx: Ctx) => relay(ctx, '/posts', { method: 'GET' }, true);
+
+export const getPost = (ctx: Ctx) =>
+ relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'GET' });
+
+export const createPost = (ctx: Ctx) => relayJson(ctx, '/posts', 'POST');
+
+export const savePost = (ctx: Ctx) =>
+ relayJson(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), 'PUT');
+
+export const deletePost = (ctx: Ctx) =>
+ relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'DELETE' });
+
+// ---------------------------------------------------------------- 图片
+
+/**
+ * 图片走**原始二进制**直传(不分片、不组 multipart):
+ * POST /api/v2/editor/upload?name=xxx.png&slug=<文章 slug>
+ * Content-Type: image/png
+ * Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。
+ */
+export async function upload(ctx: Ctx): Promise {
+ const deny = await guard(ctx);
+ if (deny) return deny;
+
+ const ct = ctx.req.headers.get('Content-Type') || '';
+ if (!ct.startsWith('image/')) return fail(400, '只接受图片');
+
+ try {
+ const res = await fetch(target(ctx, '/upload', true), {
+ method: 'POST',
+ headers: {
+ 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
+ 'Content-Type': ct,
+ },
+ body: ctx.req.body,
+ });
+ const text = await res.text();
+ let data: unknown = text;
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch {
+ /* 透传 */
+ }
+ return json(data, { status: res.status });
+ } catch (e) {
+ return fail(502, '上传失败:' + (e instanceof Error ? e.message : String(e)));
+ }
+}
+
+// ---------------------------------------------------------------- git
+
+export const gitStatus = (ctx: Ctx) => relay(ctx, '/git/status', { method: 'GET' });
+
+export const publish = (ctx: Ctx) => relayJson(ctx, '/git/publish', 'POST');
+
+export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' });
+
+/** 健康检查:让后台能显示「编辑后端是否在线」 */
+export async function health(ctx: Ctx): Promise {
+ if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) return fail(403, '需要管理员权限');
+ if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' });
+ try {
+ const res = await fetch(target(ctx, '/health'), { method: 'GET' });
+ return json({ ...(await res.json() as object), reachable: true });
+ } catch (e) {
+ return json({ ok: false, reachable: false, reason: e instanceof Error ? e.message : String(e) });
+ }
+}
diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts
index 32c38538..785acab3 100644
--- a/blog-admin/src/types.ts
+++ b/blog-admin/src/types.ts
@@ -27,6 +27,19 @@ export interface Env {
PENDING_DEFAULT: string;
IMG_UPLOAD_ENABLED?: string;
+ // ---- 文章编辑(反代到自建的 editor-api 容器)----
+ /**
+ * 后端基址,例如 https://post.usj.cc/editor-api。
+ * 留空 = 该模块自动禁用(所有 /editor/* 接口返 503)。
+ * 走 [vars] 即可,不是密钥。
+ */
+ EDITOR_API_BASE?: string;
+ /**
+ * 与 editor-api 容器约定的共享令牌。
+ * ★ 必须走 `wrangler secret put EDITOR_TOKEN`,不要写进 wrangler.toml。
+ */
+ EDITOR_TOKEN?: string;
+
// ---- 邮件通知(可选,走 Resend 的 HTTP API;Workers 没有 TCP,发不了 SMTP)
// 两个都走 `wrangler secret put`,不要写进 wrangler.toml 的 [vars]
RESEND_API_KEY?: string;
diff --git a/blog-admin/tools/cf_backup.mjs b/blog-admin/tools/cf_backup.mjs
new file mode 100644
index 00000000..e9ccf060
--- /dev/null
+++ b/blog-admin/tools/cf_backup.mjs
@@ -0,0 +1,26 @@
+// 部署前备份 artalk-cf 线上脚本 + 记录回滚点
+import { writeFileSync } from 'node:fs';
+
+const TOKEN = process.env.CLOUDFLARE_API_TOKEN;
+const ACC = '17a33f8df3d45222b272ae6bf991c913';
+const NAME = 'artalk-cf';
+const API = 'https://api.cloudflare.com/client/v4';
+const H = { Authorization: 'Bearer ' + TOKEN };
+
+const dep = await fetch(`${API}/accounts/${ACC}/workers/scripts/${NAME}/deployments`, { headers: H });
+const depJson = await dep.json();
+const latest = depJson?.result?.deployments?.[0];
+console.log('最新部署 id:', latest?.id);
+console.log('版本 id :', latest?.versions?.[0]?.version_id);
+console.log('创建时间 :', latest?.created_on);
+
+const script = await fetch(`${API}/accounts/${ACC}/workers/scripts/${NAME}`, { headers: H });
+const buf = Buffer.from(await script.arrayBuffer());
+writeFileSync('E:/GitHub/secrets-backup/artalk-cf-backup.multipart', buf);
+console.log('备份字节数:', buf.length);
+
+if (latest?.versions?.[0]?.version_id) {
+ writeFileSync('E:/GitHub/secrets-backup/artalk-cf-rollback-point.txt',
+ `version_id=${latest.versions[0].version_id}\ndeployment_id=${latest.id}\nat=${latest.created_on}\n`);
+ console.log('回滚点已记录 → E:/GitHub/secrets-backup/artalk-cf-rollback-point.txt');
+}
diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml
index 08a1ea39..1a868d5c 100644
--- a/blog-admin/wrangler.toml
+++ b/blog-admin/wrangler.toml
@@ -44,6 +44,9 @@ PENDING_DEFAULT = "false"
SCF_PROXY_URL = "https://scfapi.usj.cc"
FEEDS_URL = ""
FEISHU_WEBHOOK_URL = ""
+# 文章编辑后端(自建 editor-api 容器,Worker 只做鉴权+反代)。
+# 留空 = 模块自动禁用(/api/v2/editor/* 一律 503)。令牌另走 secret,别写这里。
+EDITOR_API_BASE = "https://post.usj.cc/editor-api"
# ⚠️ 下面两个是机密,不要写在这里,用命令写入(详见「部署清单.md」第 4 步):
# npx wrangler secret put TOKEN_SECRET
diff --git a/deploy/editor-api/bootstrap.sh b/deploy/editor-api/bootstrap.sh
new file mode 100644
index 00000000..8c30cc3c
--- /dev/null
+++ b/deploy/editor-api/bootstrap.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# =====================================================================
+# editor-api 一键部署 / 迁移脚本
+# =====================================================================
+# 用途:在一台全新机器上(或从快过期的机器上迁走)用一条命令把
+# 「在线编辑文章」的后端跑起来。所有状态都在两个目录里:
+# $BLOG_DIR = 博客仓库的 git 工作区(内容就在这里)
+# $APP_DIR = compose + .env(只有这一个文件是手写的)
+# 迁移 = 复制这两个目录 / 或在新机器上重跑本脚本。
+#
+# 用法:
+# CNB_TOKEN=xxx EDITOR_TOKEN=yyy bash bootstrap.sh
+#
+# 必填环境变量:
+# CNB_TOKEN CNB 访问令牌(用户名固定 cnb)。只用于 git clone 与 push。
+# EDITOR_TOKEN 与 Cloudflare Worker 里 EDITOR_TOKEN **完全相同**的共享令牌
+# (Worker 转发时会带上,后端用它鉴权;浏览器永远看不到)
+#
+# 可选环境变量(都有默认值):
+# CNB_URL 默认 https://cnb.cool/zqlit/blog.git
+# GH_URL 默认 git@github.com:zqlit/blog.git(配了 GH_SSH_KEY 才会推它)
+# GH_SSH_KEY 可选:GitHub 部署私钥的**内容**。给了才会把 gh 加进推送远端,
+# 否则 PUSH_REMOTES 自动降级成只推 origin(CNB 主仓)
+# BLOG_DIR /srv/blog 仓库工作区
+# APP_DIR /srv/editor-api compose + .env
+# TRASH_DIR /srv/editor-trash 删除文章的回收目录(故意放在仓库外)
+# BIND_PORT 8017 只绑 127.0.0.1
+# PUSH_REMOTES origin,gh
+# GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL / BLOG_BASE
+# =====================================================================
+set -euo pipefail
+
+say() { printf '\033[1;36m==> %s\033[0m\n' "$*"; }
+die() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; }
+
+: "${CNB_TOKEN:?必须设置 CNB_TOKEN(CNB 访问令牌,用户名固定 cnb)}"
+: "${EDITOR_TOKEN:?必须设置 EDITOR_TOKEN(与 Cloudflare Worker 里的一致)}"
+
+CNB_URL="${CNB_URL:-https://cnb.cool/zqlit/blog.git}"
+GH_URL="${GH_URL:-git@github.com:zqlit/blog.git}"
+BLOG_DIR="${BLOG_DIR:-/srv/blog}"
+APP_DIR="${APP_DIR:-/srv/editor-api}"
+TRASH_DIR="${TRASH_DIR:-/srv/editor-trash}"
+BIND_PORT="${BIND_PORT:-8017}"
+GIT_AUTHOR_NAME="${GIT_AUTHOR_NAME:-blog-editor}"
+GIT_AUTHOR_EMAIL="${GIT_AUTHOR_EMAIL:-editor@usj.cc}"
+BLOG_BASE="${BLOG_BASE:-}"
+PUSH_REMOTES="${PUSH_REMOTES:-origin,gh}"
+
+# 没有 GitHub 私钥就只推主仓 —— 宁可少推一个备份,也不要让每次发布都报错
+if [ -z "${GH_SSH_KEY:-}" ]; then
+ PUSH_REMOTES="origin"
+fi
+
+command -v docker >/dev/null || die "没有 docker"
+docker compose version >/dev/null 2>&1 || die "没有 docker compose"
+
+say "1/5 准备工作目录"
+mkdir -p "$BLOG_DIR" "$APP_DIR" "$TRASH_DIR"
+# compose 的 volume 必须写绝对路径,这里统一转成绝对路径
+BLOG_DIR="$(cd "$BLOG_DIR" && pwd)"
+APP_DIR="$(cd "$APP_DIR" && pwd)"
+TRASH_DIR="$(cd "$TRASH_DIR" && pwd)"
+
+say "2/5 拉取/更新博客仓库 → $BLOG_DIR"
+AUTH_URL="$(printf '%s' "$CNB_URL" | sed -E "s#^https://#https://cnb:${CNB_TOKEN}@#")"
+if [ -d "$BLOG_DIR/.git" ]; then
+ git -C "$BLOG_DIR" remote set-url origin "$AUTH_URL"
+ git -C "$BLOG_DIR" fetch origin main
+ git -C "$BLOG_DIR" checkout main
+ git -C "$BLOG_DIR" reset --hard origin/main
+else
+ git clone --branch main "$AUTH_URL" "$BLOG_DIR"
+fi
+
+# gh 备份远端:只有给了私钥才配
+if [ -n "${GH_SSH_KEY:-}" ]; then
+ install -d -m 700 ~/.ssh
+ printf '%s\n' "$GH_SSH_KEY" > ~/.ssh/blog_editor_github
+ chmod 600 ~/.ssh/blog_editor_github
+ ssh-keyscan -t rsa,ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true
+ git -C "$BLOG_DIR" remote remove gh 2>/dev/null || true
+ git -C "$BLOG_DIR" remote add gh "$GH_URL"
+ git -C "$BLOG_DIR" config core.sshCommand "ssh -i ~/.ssh/blog_editor_github -o StrictHostKeyChecking=accept-new"
+fi
+
+# 容器里要能 commit —— 顺手把身份写进仓库配置(不改全局)
+git -C "$BLOG_DIR" config user.name "$GIT_AUTHOR_NAME"
+git -C "$BLOG_DIR" config user.email "$GIT_AUTHOR_EMAIL"
+
+say "3/5 写入 $APP_DIR/.env"
+umask 077
+# 用 printf 而不是 heredoc:令牌里若含 $ 或反引号,heredoc 会被 shell 展开
+{
+ printf 'EDITOR_TOKEN=%s\n' "$EDITOR_TOKEN"
+ printf 'BLOG_DIR=%s\n' "$BLOG_DIR"
+ printf 'TRASH_DIR=%s\n' "$TRASH_DIR"
+ printf 'BIND_PORT=%s\n' "$BIND_PORT"
+ printf 'PUSH_REMOTES=%s\n' "$PUSH_REMOTES"
+ printf 'GIT_AUTHOR_NAME=%s\n' "$GIT_AUTHOR_NAME"
+ printf 'GIT_AUTHOR_EMAIL=%s\n' "$GIT_AUTHOR_EMAIL"
+ printf 'BLOG_BASE=%s\n' "$BLOG_BASE"
+} > "$APP_DIR/.env"
+
+say "4/5 生成 compose 并启动容器"
+cat > "$APP_DIR/docker-compose.yml" <process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+EOF
+
+cd "$APP_DIR"
+docker compose up -d --build
+
+say "5/5 健康检查"
+for i in $(seq 1 30); do
+ if curl -fsS -H "X-Editor-Token: $EDITOR_TOKEN" "http://127.0.0.1:$BIND_PORT/health" >/tmp/editor-health.json 2>/dev/null; then
+ cat /tmp/editor-health.json; echo
+ say "部署完成。接下来:"
+ echo " · 宿主机 nginx(1Panel 站点)加反代:location ^~ / { proxy_pass http://127.0.0.1:$BIND_PORT; ... }"
+ echo " · Cloudflare Worker 变量:EDITOR_API_BASE=https://<指向本机的域名> EDITOR_TOKEN=$EDITOR_TOKEN"
+ echo " · 推送远端:PUSH_REMOTES=$PUSH_REMOTES"
+ exit 0
+ fi
+ sleep 2
+done
+die "健康检查失败,看日志:cd $APP_DIR && docker compose logs --tail=80"
diff --git a/docker-compose.editor.yml b/docker-compose.editor.yml
new file mode 100644
index 00000000..1a3155c2
--- /dev/null
+++ b/docker-compose.editor.yml
@@ -0,0 +1,48 @@
+# editor-api —— 在线编辑文章的轻量后端
+#
+# 用法(在服务器上):
+# 1. 把博客仓库 checkout 到 /srv/blog(git 工作区,分支 main)
+# 2. 在同目录放一个 .env,写入 EDITOR_TOKEN=openssl rand -hex 32 生成的值
+# 3. docker compose -f docker-compose.editor.yml up -d --build
+# (回收目录会落在 /srv/editor-trash,记得先 mkdir -p /srv/editor-trash)
+# 4. nginx 加 /editor-api/ 反代(见 部署清单.md),Worker 配 EDITOR_API_BASE / EDITOR_TOKEN
+#
+# ★ 端口只绑 127.0.0.1 —— 公网扫不到 8017,只有同机的 nginx 能转发进来。
+# 这是整个安全模型的一半;另一半是「令牌只存在 Worker secret 里,浏览器拿不到」。
+
+services:
+ editor-api:
+ build: ./editor-api
+ container_name: editor-api
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:8017:8017"
+ environment:
+ # 令牌:必须显式给,没给容器会直接退出(server.mjs 里故意的)
+ EDITOR_TOKEN: ${EDITOR_TOKEN:?请在同目录 .env 里设置 EDITOR_TOKEN}
+ BLOG_ROOT: /blog
+ TRASH_DIR: /app/trash
+ # 容器内监听 0.0.0.0 没关系 —— 外面那层端口映射已经把它锁在宿主机回环上了
+ BIND_HOST: 0.0.0.0
+ GIT_BRANCH: main
+ PUSH_REMOTES: origin,gh
+ GIT_AUTHOR_NAME: blog-editor
+ GIT_AUTHOR_EMAIL: editor@usj.cc
+ MAX_UPLOAD_MB: "20"
+ GIT_PATHS: content,static
+ volumes:
+ # 博客仓库的 git 工作区(不是镜像里带的,改完才能 commit + push)
+ - /srv/blog:/blog
+ # 回收目录放仓库外面,避免在 git status 里冒出来
+ - /srv/editor-trash:/app/trash
+ healthcheck:
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8017/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ logging:
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
diff --git a/editor-api/Dockerfile b/editor-api/Dockerfile
new file mode 100644
index 00000000..fbbb5860
--- /dev/null
+++ b/editor-api/Dockerfile
@@ -0,0 +1,28 @@
+# editor-api 镜像 —— 零 npm 依赖,所以没有 package.json / 锁文件 / npm ci
+#
+# 为什么这么小:
+# · base 用 alpine
+# · 代码只用 node 内置模块(node:http / node:fs / node:crypto),不需要 npm install
+# · git 是必须的:发布 = 在仓库工作区里 add + commit + push
+#
+# 仓库本体不进镜像 —— 挂载进去(compose 里 /blog)。这样 git pull 不用重建镜像。
+
+FROM node:22-alpine
+
+# git:发布用。ca-certificates:https 远端(CNB / GitHub)要它
+RUN apk add --no-cache git ca-certificates
+
+WORKDIR /app
+ENV NODE_ENV=production
+
+COPY server.mjs ./
+COPY src/ ./src/
+
+# 回收目录(删除的文章挪到这里,在仓库外,不进 git)
+RUN mkdir -p /app/trash
+
+EXPOSE 8017
+
+# 没配 EDITOR_TOKEN 时 server.mjs 会自己 process.exit(1),容器随即退出 ——
+# 这是刻意的:宁可起不来,也不能以无鉴权状态对公网服务
+CMD ["node", "server.mjs"]
diff --git a/editor-api/README.md b/editor-api/README.md
new file mode 100644
index 00000000..b9d63bed
--- /dev/null
+++ b/editor-api/README.md
@@ -0,0 +1,110 @@
+# editor-api —— 只做「在线编辑文章」的轻量后端
+
+给 `api.200181.xyz/admin` 的「文章编辑」tab 当后端。**零 npm 依赖**(只用 node 内置模块),
+所以没有 `npm ci`、没有锁文件、没有供应链风险,镜像也小。
+
+## 它做什么 / 不做什么
+
+只做这一件事:读写 `content/posts//<目录>/index.md`,外加把图片放进文章同级目录,
+以及把改动 commit + push 触发上线。
+
+**不做**:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。
+
+## 三层结构
+
+```
+浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
+ /api/v2/editor/* 127.0.0.1:8017
+ 只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git
+```
+
+- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。
+- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。
+- 鉴权是复用后台已有的管理员登录(`isAdminRequest`),不用再造一套账号。
+
+## 环境变量
+
+| 变量 | 默认 | 说明 |
+|---|---|---|
+| `EDITOR_TOKEN` | **必填** | 编辑器令牌。没设直接拒绝启动。`openssl rand -hex 32` 生成 |
+| `BLOG_ROOT` | `/blog` | 博客仓库(git 工作区)路径 |
+| `PORT` | `8017` | 监听端口 |
+| `BIND_HOST` | `0.0.0.0` | 容器内监听地址;靠 compose 的端口映射限成 127.0.0.1 |
+| `TRASH_DIR` | `/app/trash` | 删除文章的回收目录(**在仓库外**,不进 git) |
+| `GIT_BRANCH` | `main` | 工作分支 |
+| `PUSH_REMOTES` | `origin,gh` | 依次推送;主仓失败才算发布失败,备份仓失败只警告 |
+| `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` | `blog-editor` | 自动提交的作者 |
+| `DEFAULT_AUTHOR` | 空 | 新建文章时 front matter `author` 的默认值 |
+| `MAX_UPLOAD_MB` | `20` | 单张图片上限 |
+| `GIT_PATHS` | `content,static` | `git add` 的范围(不会把别的东西误提交) |
+| `BLOG_BASE` | 空 | 博客对外地址(如 `https://usj.cc`)。配了列表/编辑页才返回绝对链接,「预览」按钮才能直接开新窗口 |
+
+## slug 生成规则(与 write-server 一致)
+
+新文章不填 slug 时自动生成 `YYYYMMDDHHMMSS`(本地时间 14 位),
+目录名 `-<标题段>-`——与 write-server 的 `makeSlug`(`src/lib/bot/helpers.ts`)
+和 `computeDirPath`(`src/lib/bot/sessions.ts`)一字不差。仓库里 2026-06 之后
+的文章全是这个风格。**不要**用标题当 slug。
+
+## 接口
+
+除 `GET /health` 外一律要 `X-Editor-Token`,没有就 401。
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/health` | 健康检查(免鉴权) |
+| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) |
+| POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` |
+| GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) |
+| PUT | `/posts/:id` | 保存,body `{content, frontMatter}` |
+| DELETE | `/posts/:id` | 移到回收目录 |
+| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 |
+| GET | `/git/status` | 分支 / 改动文件 / 最近提交 |
+| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push |
+| POST | `/git/sync` | pull --rebase --autostash |
+
+### 为什么用目录名当 id,不用 slug
+
+仓库里**真实存在 5 组 slug 撞名**的文章(`20210901`、`20211122`、`20211128`、`20211223`、`20240602`)。
+Hugo 的 permalink 是 `/:slug`,撞名时线上必有一篇被另一篇覆盖 —— 也就是说这 10 篇里有 5 篇
+**线上本来就打不开**。如果按 slug 定位,编辑器会静默地打开/保存到另一篇文件上,直接毁数据。
+
+所以:id 用目录名(文件系统保证唯一、单段路径),slug 降级为展示字段 + `slugConflict` 告警;
+拿撞名的 slug 来查会返回 **409 并列出候选篇目**,绝不猜。
+
+## 三条不会写坏老文章的底线
+
+1. **没动 front matter → 原文一个字节都不重写。**
+ 判断方式是「把前端传来的字段合并进已解析对象,再和已解析对象深比较」;相等就原样照抄
+ `frontMatterRaw`。这样解析器对冷门语法理解有偏差也无所谓。前端也配合:只回传**真正改了**的字段。
+2. **换行风格原样保留**(CRLF/LF)。仓库 `.gitattributes` 是 `* text=auto`,仓库存 LF、
+ Windows 工作区是 CRLF,统一化会产生整文件 diff。
+3. **front matter 与正文间的空行原样保留**。语料里 111 篇有空行、19 篇没有;
+ 正文剥掉前导空行给编辑框,回写时按原文件的风格还原。
+
+## 测试
+
+```bash
+# ① 纯函数往返:split/join/parse/stringify 对全部真实文章逐字节还原
+node test/frontmatter-roundtrip.mjs
+
+# ② 保存往返(最重要):每篇都「读出来原样存回去」,断言 sha256 不变;测完自动还原
+BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs
+
+# ③ 接口端到端(对真实仓库跑,测完自动还原)
+BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端
+node test/api-e2e.mjs http://127.0.0.1:8017 devtoken
+```
+
+## 本地联调
+
+```bash
+# 1) 后端
+EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
+
+# 2) Worker(blog-admin 目录)
+npx wrangler dev --port 8799
+# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
+```
+
+部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。
diff --git a/editor-api/server.mjs b/editor-api/server.mjs
new file mode 100644
index 00000000..8ef1dc1e
--- /dev/null
+++ b/editor-api/server.mjs
@@ -0,0 +1,256 @@
+/**
+ * editor-api —— 只做「在线编辑文章」的轻量后端。
+ *
+ * 设计要点:
+ * · 零 npm 依赖,只用 node 内置模块 —— 镜像小、没有供应链风险、也不用 npm ci
+ * · 所有接口(除 /health)强制 X-Editor-Token,没有令牌一律 401。
+ * 前端永远拿不到这个令牌 —— 它只存在于 Cloudflare Worker 的环境变量里,
+ * 浏览器只跟 Worker 说话,Worker 鉴权通过后再加上令牌转发过来。
+ * · 端口只绑到宿主机的 127.0.0.1,由 nginx 反代出去,公网扫不到。
+ *
+ * 环境变量见 README.md。
+ */
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { makePosts } from './src/posts.mjs';
+import { makeGit } from './src/git.mjs';
+
+// ------------------------------------------------------------------ 配置
+
+const env = process.env;
+const cfg = {
+ port: Number(env.PORT || 8017),
+ host: env.BIND_HOST || '0.0.0.0',
+ token: env.EDITOR_TOKEN || '',
+ repoRoot: path.resolve(env.BLOG_ROOT || '/blog'),
+ contentDir: path.resolve(env.BLOG_ROOT || '/blog', 'content', 'posts'),
+ trashDir: path.resolve(env.TRASH_DIR || '/app/trash'),
+ branch: env.GIT_BRANCH || 'main',
+ pushRemotes: (env.PUSH_REMOTES || 'origin,gh').split(',').map((s) => s.trim()).filter(Boolean),
+ authorName: env.GIT_AUTHOR_NAME || 'blog-editor',
+ authorEmail: env.GIT_AUTHOR_EMAIL || 'editor@localhost',
+ defaultAuthor: env.DEFAULT_AUTHOR || '',
+ maxUpload: Number(env.MAX_UPLOAD_MB || 20) * 1024 * 1024,
+ gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean),
+ // 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。
+ blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''),
+};
+
+if (!cfg.token) {
+ console.error('[editor-api] 致命:没有设置 EDITOR_TOKEN。拒绝以无鉴权状态启动。');
+ process.exit(1);
+}
+
+const posts = makePosts(cfg);
+const git = makeGit({ ...cfg, repoRoot: cfg.repoRoot, paths: cfg.gitPaths });
+
+// ------------------------------------------------------------------ HTTP 小工具
+
+function json(res, status, data) {
+ const body = JSON.stringify(data);
+ res.writeHead(status, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'no-store',
+ 'Content-Length': Buffer.byteLength(body),
+ });
+ res.end(body);
+}
+
+function readBody(req, limit) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ req.on('data', (c) => {
+ size += c.length;
+ if (size > limit) {
+ reject(Object.assign(new Error('请求体超过上限 ' + Math.round(limit / 1048576) + 'MB'), { status: 413 }));
+ req.destroy();
+ return;
+ }
+ chunks.push(c);
+ });
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ req.on('error', reject);
+ });
+}
+
+async function readJson(req, limit = 2 * 1024 * 1024) {
+ const buf = await readBody(req, limit);
+ if (!buf.length) return {};
+ try {
+ return JSON.parse(buf.toString('utf8'));
+ } catch {
+ throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 });
+ }
+}
+
+/** 定长比较,避免令牌被逐字节试探 */
+function safeEqual(a, b) {
+ const ba = Buffer.from(String(a));
+ const bb = Buffer.from(String(b));
+ if (ba.length !== bb.length) return false;
+ return crypto.timingSafeEqual(ba, bb);
+}
+
+const safeName = (s) =>
+ String(s || '')
+ .replace(/[\\/]/g, '')
+ .replace(/[^\w\u4e00-\u9fff.-]+/g, '_')
+ .slice(0, 120);
+
+// ------------------------------------------------------------------ 路由
+
+const routes = [];
+const route = (method, pattern, handler) => {
+ routes.push({ method, segs: pattern.split('/').filter(Boolean), handler });
+};
+
+const R = '';
+
+const health = () => ({
+ ok: true,
+ repo: cfg.repoRoot,
+ branch: cfg.branch,
+ remotes: cfg.pushRemotes,
+ time: new Date().toISOString(),
+});
+
+route('GET', '/health', health);
+
+route('GET', '/posts', async ({ query }) => posts.listPosts({
+ q: query.get('q') || '',
+ page: Number(query.get('page') || 1),
+ perPage: Number(query.get('perPage') || 20),
+}));
+
+route('POST', '/posts', async ({ req }) => {
+ const body = await readJson(req);
+ if (!body.frontMatter || !body.frontMatter.title) {
+ throw Object.assign(new Error('缺少标题'), { status: 400 });
+ }
+ return posts.createPost(body);
+});
+
+route('GET', `/posts/${R}`, async ({ params }) => {
+ const post = posts.getPost(params[0]);
+ if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
+ return post;
+});
+
+route('PUT', `/posts/${R}`, async ({ req, params }) => {
+ const body = await readJson(req, 4 * 1024 * 1024);
+ const out = posts.savePost(params[0], body);
+ if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
+ return { ok: true, ...out };
+});
+
+route('DELETE', `/posts/${R}`, async ({ params }) => {
+ const out = posts.deletePost(params[0]);
+ if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
+ return { ok: true, ...out };
+});
+
+// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
+route('POST', '/upload', async ({ req, query }) => {
+ const type = req.headers['content-type'] || '';
+ if (!type.startsWith('image/')) {
+ throw Object.assign(new Error('只接受图片'), { status: 400 });
+ }
+ const buf = await readBody(req, cfg.maxUpload);
+ if (!buf.length) throw Object.assign(new Error('空文件'), { status: 400 });
+
+ const ext = (safeName(query.get('name')).split('.').pop() || 'png').toLowerCase().slice(0, 6);
+ const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
+ const fileName = base + '.' + ext;
+
+ const target = posts.imageTargetDir(query.get('key') || '');
+ fs.mkdirSync(target.dir, { recursive: true });
+ fs.writeFileSync(path.join(target.dir, fileName), buf);
+
+ return {
+ ok: true,
+ fileName,
+ url: target.bare ? fileName : (target.urlPrefix || '/') + fileName,
+ size: buf.length,
+ dir: path.relative(cfg.repoRoot, target.dir).split(path.sep).join('/'),
+ };
+});
+
+route('GET', '/git/status', async () => git.status());
+route('POST', '/git/publish', async ({ req }) => {
+ const body = await readJson(req);
+ const out = await git.publish(body.message);
+ if (!out.ok) {
+ return { ...out, __status: out.conflict ? 409 : 500 };
+ }
+ return out;
+});
+route('POST', '/git/sync', async () => {
+ const out = await git.sync();
+ return out.ok ? out : { ...out, __status: out.conflict ? 409 : 500 };
+});
+
+// ------------------------------------------------------------------ 分发
+
+function match(method, pathname) {
+ const segs = pathname.split('/').filter(Boolean).map(decodeURIComponent);
+ for (const r of routes) {
+ if (r.method !== method) continue;
+ if (r.segs.length !== segs.length) continue;
+ const params = [];
+ let ok = true;
+ for (let i = 0; i < r.segs.length; i++) {
+ if (r.segs[i] === R) params.push(segs[i]);
+ else if (r.segs[i] !== segs[i]) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) return { handler: r.handler, params };
+ }
+ return null;
+}
+
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://localhost');
+
+ try {
+ // /health 免鉴权,供健康检查用;其余一律先验令牌
+ if (url.pathname === '/health' && req.method === 'GET') {
+ json(res, 200, health());
+ return;
+ }
+
+ if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
+ json(res, 401, { error: '未授权' });
+ return;
+ }
+
+ const hit = match(req.method, url.pathname);
+ if (!hit) {
+ json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });
+ return;
+ }
+
+ const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params });
+ const status = data && data.__status ? data.__status : 200;
+ if (data && data.__status) delete data.__status;
+ json(res, status, data);
+ } catch (e) {
+ const status = e.status || 500;
+ if (status >= 500) console.error('[editor-api]', req.method, url.pathname, e);
+ json(res, status, { error: e.message || '服务端错误' });
+ }
+});
+
+server.headersTimeout = 30_000;
+server.requestTimeout = 120_000;
+
+server.listen(cfg.port, cfg.host, () => {
+ console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port);
+ console.log('[editor-api] 仓库 ' + cfg.repoRoot);
+ console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', '));
+ console.log('[editor-api] 回收目录 ' + cfg.trashDir);
+});
diff --git a/editor-api/src/frontmatter.mjs b/editor-api/src/frontmatter.mjs
new file mode 100644
index 00000000..2bd71a23
--- /dev/null
+++ b/editor-api/src/frontmatter.mjs
@@ -0,0 +1,332 @@
+/**
+ * 极简 YAML front matter 子集:解析 + 序列化(零依赖)。
+ *
+ * 为什么不用 gray-matter / js-yaml:
+ * 这个后端只服务「编辑自己的 Hugo 文章」这一件事,语料是固定的 100 多篇
+ * Markdown。为此拖进一棵依赖树不值得,镜像也能小一圈。
+ *
+ * 覆盖的形态(Hugo front matter 实际会用到的全部):
+ * key: 标量 key: '单引号' key: "双引号"
+ * key: 123 / true key: [a, b] key: []
+ * key: (空值 → null)
+ * - a
+ * - b (列表,缩进两格)
+ * key: | / |- / |+ / > / >- / >+ (块标量)
+ * key:
+ * sub: 1 (一层嵌套 map,Hugo 的 params 会用到)
+ *
+ * 遇到覆盖不到的结构一律 **抛错**,由调用方降级为「原文照存」——
+ * 宁可少解析,绝不猜错后把用户文章写坏。
+ */
+
+const OPEN = /^---[ \t]*\n/;
+
+/**
+ * 把整篇文本切成 front matter 原文 / 正文 / 原文件换行风格。
+ *
+ * ★ 换行符必须原样保留:仓库 `.gitattributes` 是 `* text=auto`,
+ * 仓库里存的是 LF,但 Windows 工作区检出是 CRLF。如果写回时统一成 LF,
+ * 在 Windows 侧就会产生「整文件换行变更」的巨型 diff。所以这里记住原风格,
+ * 交给 joinFrontMatter 还原。
+ *
+ * body **保留**它前面的空行,这样 join 出来的结果与原文件逐字节相同。
+ */
+export function splitFrontMatter(text) {
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
+ const norm = text.replace(/\r\n/g, '\n');
+
+ const open = OPEN.exec(norm);
+ if (!open) return { raw: null, body: norm, eol };
+
+ const rest = norm.slice(open[0].length);
+ const lines = rest.split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].trim() === '---') {
+ return {
+ raw: lines.slice(0, i).join('\n'),
+ body: lines.slice(i + 1).join('\n'),
+ eol,
+ };
+ }
+ }
+ return { raw: null, body: norm, eol };
+}
+
+/** 组装回整篇文本(raw 为 null 表示本来就没有 front matter) */
+export function joinFrontMatter(raw, body, eol = '\n') {
+ const s = raw == null ? body : '---\n' + raw.replace(/[ \t]+$/, '') + '\n---\n' + body;
+ return eol === '\n' ? s : s.replace(/\n/g, eol);
+}
+
+// ---------------------------------------------------------------- 解析
+
+export function parse(raw) {
+ const lines = raw.split('\n');
+ const out = {};
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+ if (line.trim() === '' || line.trimStart().startsWith('#')) {
+ i++;
+ continue;
+ }
+ if (/^[ \t]/.test(line)) {
+ throw new Error('顶层出现意外缩进: ' + JSON.stringify(line));
+ }
+ const m = /^([^:\s][^:]*):(.*)$/.exec(line);
+ if (!m) throw new Error('无法解析的行: ' + JSON.stringify(line));
+
+ const key = m[1].trim();
+ const rest = m[2].trim();
+ i++;
+
+ // 空值:看后面有没有缩进块
+ if (rest === '') {
+ const block = [];
+ while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) {
+ block.push(lines[i]);
+ i++;
+ }
+ while (block.length && block[block.length - 1].trim() === '') block.pop();
+ if (!block.length) {
+ out[key] = null;
+ } else if (/^-[ \t]?/.test(block[0].trimStart())) {
+ out[key] = parseList(block);
+ } else {
+ out[key] = parseMap(block);
+ }
+ continue;
+ }
+
+ // 块标量
+ if (/^[|>][+-]?$/.test(rest)) {
+ const block = [];
+ while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) {
+ block.push(lines[i]);
+ i++;
+ }
+ out[key] = parseBlockScalar(block, rest);
+ continue;
+ }
+
+ out[key] = parseScalar(rest);
+ }
+
+ return out;
+}
+
+function dedent(block) {
+ let min = Infinity;
+ for (const l of block) {
+ if (l.trim() === '') continue;
+ const n = l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
+ if (n < min) min = n;
+ }
+ if (!isFinite(min)) min = 0;
+ return block.map((l) => (l.trim() === '' ? '' : l.replace(/\t/g, ' ').slice(min)));
+}
+
+function parseBlockScalar(block, header) {
+ const lines = dedent(block);
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
+
+ let text;
+ if (header[0] === '|') {
+ text = lines.join('\n');
+ } else {
+ // 折叠:单个换行变空格,空行保留为换行
+ let acc = '';
+ for (const l of lines) {
+ if (l === '') {
+ acc += '\n';
+ continue;
+ }
+ if (acc !== '' && !acc.endsWith('\n')) acc += ' ';
+ acc += l;
+ }
+ text = acc;
+ }
+
+ const chomp = header[1];
+ if (chomp === '-') return text.replace(/\n+$/, '');
+ if (chomp === '+') return text + '\n';
+ return text.replace(/\n+$/, '') + '\n';
+}
+
+function parseList(block) {
+ const lines = dedent(block);
+ const out = [];
+ for (let i = 0; i < lines.length; i++) {
+ const l = lines[i];
+ if (l.trim() === '') continue;
+ const m = /^-[ \t]?(.*)$/.exec(l);
+ if (!m) throw new Error('列表中出现了非列表项: ' + JSON.stringify(l));
+ if (/^[ \t]/.test(l)) throw new Error('不支持多级列表');
+ out.push(parseScalar(m[1]));
+ }
+ return out;
+}
+
+function parseMap(block) {
+ const lines = dedent(block);
+ const out = {};
+ for (const l of lines) {
+ if (l.trim() === '') continue;
+ const m = /^([^:\s][^:]*):(.*)$/.exec(l);
+ if (!m) throw new Error('嵌套 map 中出现无法解析的行: ' + JSON.stringify(l));
+ out[m[1].trim()] = parseScalar(m[2].trim());
+ }
+ return out;
+}
+
+function parseScalar(s) {
+ s = s.trim();
+ if (s === '' || s === '~' || s === 'null') return null;
+ if (s === 'true') return true;
+ if (s === 'false') return false;
+ if (/^'.*'$/.test(s)) return s.slice(1, -1).replace(/''/g, "'");
+ if (/^".*"$/.test(s)) {
+ try {
+ return JSON.parse(s);
+ } catch {
+ // JSON.parse 不认 YAML 专属的 \Uxxxxxxxx(8 位)与 \xXX 转义 ——
+ // 仓库里就有这种标题(Telegram bot 写入的 emoji,如 \U0001F605)。
+ // 先把它们解成真实字符,再交给 JSON.parse 处理其余转义(\n、\" 等)。
+ const pre = s
+ .slice(1, -1)
+ .replace(/\\U([0-9a-fA-F]{8})/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
+ .replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCodePoint(parseInt(h, 16)));
+ try {
+ return JSON.parse('"' + pre + '"');
+ } catch {
+ return pre;
+ }
+ }
+ }
+ if (s.startsWith('[') && s.endsWith(']')) return parseInlineList(s);
+ if (/^-?\d+$/.test(s)) return parseInt(s, 10);
+ if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
+ return s;
+}
+
+function parseInlineList(s) {
+ const inner = s.slice(1, -1).trim();
+ if (!inner) return [];
+ const out = [];
+ let cur = '';
+ let quote = null;
+ for (const ch of inner) {
+ if (quote) {
+ cur += ch;
+ if (ch === quote) quote = null;
+ continue;
+ }
+ if (ch === "'" || ch === '"') {
+ quote = ch;
+ cur += ch;
+ continue;
+ }
+ if (ch === ',') {
+ out.push(parseScalar(cur));
+ cur = '';
+ continue;
+ }
+ cur += ch;
+ }
+ if (cur.trim() !== '') out.push(parseScalar(cur));
+ return out;
+}
+
+// ---------------------------------------------------------------- 序列化
+
+const PLAIN_OK = /^[^\s\-?:,[\]{}#&*!|>'"%@`][^:#\n]*$/;
+
+function toYamlScalar(v) {
+ if (v === null || v === undefined) return '';
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ if (typeof v === 'number') return String(v);
+
+ const s = String(v);
+ if (s === '') return "''";
+ // 这些形态不引起来会被 YAML 当成别的类型
+ if (/^(true|false|null|~|yes|no|on|off)$/i.test(s)) return "'" + s + "'";
+ if (/^-?\d+(\.\d+)?$/.test(s)) return s; // 纯数字:保持裸写,与现有语料一致
+ if (s.includes('\n')) return null; // 交给调用方走块标量
+ if (PLAIN_OK.test(s) && !s.startsWith(' ') && !s.endsWith(' ')) return s;
+ return "'" + s.replace(/'/g, "''") + "'";
+}
+
+export function stringify(obj) {
+ const out = [];
+ for (const key of Object.keys(obj)) {
+ const v = obj[key];
+
+ if (v === null || v === undefined) {
+ out.push(key + ':');
+ continue;
+ }
+
+ if (Array.isArray(v)) {
+ if (v.length === 0) {
+ out.push(key + ': []');
+ continue;
+ }
+ out.push(key + ':');
+ for (const item of v) {
+ const one = toYamlScalar(item);
+ if (one === null) throw new Error('列表项不支持多行内容: ' + key);
+ out.push(' - ' + one);
+ }
+ continue;
+ }
+
+ if (typeof v === 'object') {
+ out.push(key + ':');
+ for (const sub of Object.keys(v)) {
+ const one = toYamlScalar(v[sub]);
+ if (one === null) throw new Error('嵌套对象不支持多行内容: ' + key + '.' + sub);
+ out.push(' ' + sub + ': ' + one);
+ }
+ continue;
+ }
+
+ if (typeof v === 'string' && v.includes('\n')) {
+ // 块标量必须带上正确的 chomping 记号,否则值会变:
+ // | clip —— 保留结尾的一个换行(YAML 默认,Hugo 语料里的 >- 折叠块就是这个)
+ // |- strip —— 结尾不要换行
+ // |+ keep —— 保留全部结尾换行
+ // 漏了这一步,「值 = "xxx\n"」会被写成「值 = "xxx"」,是实打实的语义改动。
+ const trailing = (/\n+$/.exec(v) || [''])[0].length;
+ const header = trailing === 0 ? '|-' : trailing === 1 ? '|' : '|+';
+ out.push(key + ': ' + header);
+ for (const line of v.replace(/\n+$/, '').split('\n')) {
+ out.push(' ' + line);
+ }
+ continue;
+ }
+
+ const one = toYamlScalar(v);
+ if (one === null) throw new Error('无法序列化: ' + key);
+ out.push(key + ': ' + one);
+ }
+ return out.join('\n');
+}
+
+/** 深比较:用来判断「用户到底动没动 front matter」 */
+export function deepEqual(a, b) {
+ if (a === b) return true;
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
+ return String(a) === String(b);
+ }
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ const ka = Object.keys(a);
+ const kb = Object.keys(b);
+ if (ka.length !== kb.length) return false;
+ for (const k of ka) {
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
+ if (!deepEqual(a[k], b[k])) return false;
+ }
+ return true;
+}
diff --git a/editor-api/src/git.mjs b/editor-api/src/git.mjs
new file mode 100644
index 00000000..ec680362
--- /dev/null
+++ b/editor-api/src/git.mjs
@@ -0,0 +1,123 @@
+/**
+ * git 操作:状态、发布(提交 + 推两端)、同步。
+ *
+ * 所有 git 调用串行化——两个并发的 commit/push 撞在一起会互相踩工作区,
+ * 单用户场景下也值得防一手(用户手快连点两次「发布」就会遇到)。
+ */
+import { execFile } from 'node:child_process';
+
+function run(cwd, args, { allowFailure = false } = {}) {
+ return new Promise((resolve, reject) => {
+ execFile('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
+ const out = (stdout || '') + (stderr || '');
+ if (err && !allowFailure) {
+ const e = new Error(out.trim() || err.message);
+ e.stdout = stdout;
+ e.stderr = stderr;
+ e.code = err.code;
+ reject(e);
+ return;
+ }
+ resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out, stdout, stderr });
+ });
+ });
+}
+
+export function makeGit(cfg) {
+ const { repoRoot, branch, pushRemotes, authorName, authorEmail, paths } = cfg;
+
+ // 串行队列
+ let tail = Promise.resolve();
+ function serial(task) {
+ const next = tail.then(task, task);
+ tail = next.catch(() => {});
+ return next;
+ }
+
+ const identity = [
+ '-c', 'user.name=' + authorName,
+ '-c', 'user.email=' + authorEmail,
+ // 容器里通常没有 GPG,且我们不需要签名
+ '-c', 'commit.gpgsign=false',
+ ];
+
+ async function status() {
+ // core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义,
+ // 否则发布弹层里满屏都是看不懂的编码
+ const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]);
+ const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']);
+ const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
+ const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean);
+ return {
+ branch: br.out.trim(),
+ dirty: files.length > 0,
+ changed: files.length,
+ files: files.slice(0, 50),
+ lastCommit: head.out.trim(),
+ };
+ }
+
+ async function publish(message) {
+ return serial(async () => {
+ const log = [];
+
+ const add = await run(repoRoot, ['add', '--', ...paths]);
+ log.push(add.out.trim());
+
+ // 先提交(没有暂存内容就跳过)
+ const diff = await run(repoRoot, ['diff', '--cached', '--quiet'], { allowFailure: true });
+ if (diff.code === 1) {
+ const msg = message || ('编辑: ' + new Date().toISOString().slice(0, 16).replace('T', ' '));
+ const c = await run(repoRoot, [...identity, 'commit', '-m', msg]);
+ log.push(c.out.trim());
+ } else if (diff.code !== 0) {
+ throw new Error('检查暂存区失败: ' + diff.out.trim());
+ } else {
+ log.push('(没有需要提交的改动)');
+ }
+
+ // 先拉再推,减少被拒概率
+ const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true });
+ log.push(pull.out.trim());
+ if (!pull.ok && /conflict|CONFLICT|nothing to rebase|Automatic merge failed/i.test(pull.out)) {
+ await run(repoRoot, ['rebase', '--abort'], { allowFailure: true });
+ return { ok: false, conflict: true, log: log.join('\n'), error: '远程有冲突,已放弃合并。请先「同步」后手动处理。' };
+ }
+
+ const pushed = [];
+ const failed = [];
+ for (const remote of pushRemotes) {
+ const p = await run(repoRoot, ['push', remote, 'HEAD:' + branch], { allowFailure: true });
+ log.push(p.out.trim());
+ if (p.ok) pushed.push(remote);
+ else failed.push(remote + ': ' + p.out.trim().split('\n').slice(-2).join(' '));
+ }
+
+ if (!pushed.length) {
+ return { ok: false, log: log.join('\n'), error: '推送失败:' + failed.join(' | ') };
+ }
+
+ return {
+ ok: true,
+ pushed,
+ // 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
+ warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null,
+ log: log.join('\n'),
+ };
+ });
+ }
+
+ async function sync() {
+ return serial(async () => {
+ const pull = await run(repoRoot, ['pull', '--rebase', '--autostash', 'origin', branch], { allowFailure: true });
+ if (pull.ok) return { ok: true, log: pull.out.trim() };
+ if (/conflict|CONFLICT|Automatic merge failed/i.test(pull.out)) {
+ await run(repoRoot, ['rebase', '--abort'], { allowFailure: true });
+ return { ok: false, conflict: true, error: '存在冲突,已放弃合并' };
+ }
+ return { ok: false, error: pull.out.trim() };
+ });
+ }
+
+ return { status, publish, sync };
+}
diff --git a/editor-api/src/posts.mjs b/editor-api/src/posts.mjs
new file mode 100644
index 00000000..ff9ecd53
--- /dev/null
+++ b/editor-api/src/posts.mjs
@@ -0,0 +1,318 @@
+/**
+ * 文章读写:扫目录、解析、保存、新建、删除。
+ *
+ * 目录约定(与仓库现状一致):
+ * content/posts//<任意目录名>/index.md
+ * —— 目录名只为人眼好看,真正决定 URL 的是 front matter 里的 slug
+ * (hugo.toml: [permalinks] posts = "/:slug",且 uglyURLs = true → /.html)
+ *
+ * ★ 定位键用**目录名**,不用 slug。
+ * 原因是仓库里真实存在 5 组 slug 撞名的文章(Hugo 是 /:slug,撞名的那一篇
+ * 在线上已经被另一篇覆盖了)。如果按 slug 打开,编辑器会静默地打开/保存
+ * **另一篇**文件 —— 这是能直接毁数据的。目录名由文件系统保证唯一,且是
+ * 单段路径(不含 /),当 API id 最稳。slug 只作为展示字段 + 撞名告警。
+ *
+ * ★ 保存时的核心安全策略:
+ * 用户只改正文、没动 front matter 字段时,front matter 原文**一个字节都不重写**。
+ * 这样即使解析器对某些冷门语法理解有偏差,也永远不会把老文章写坏。
+ * 判断方式是「合并 incoming 到已解析对象,再和已解析对象深比较」——
+ * 用合并而不是替换,是为了防止前端只回传部分字段时把 pid / ai_comment 这类
+ * 它不认识的键弄丢。
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ splitFrontMatter,
+ joinFrontMatter,
+ parse,
+ stringify,
+ deepEqual,
+} from './frontmatter.mjs';
+
+export function makePosts(cfg) {
+ const { contentDir, trashDir, defaultAuthor, repoRoot, blogBase } = cfg;
+
+ /** 带 HTTP 状态码的错误,避免所有业务错误都退化成 500 */
+ const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
+
+ /** 文章线上地址:配了 BLOG_BASE 就是绝对地址(列表「预览」按钮直接新窗口打开) */
+ const blogUrl = (slug) => (blogBase ? String(blogBase).replace(/\/+$/, '') : '') + '/' + slug + '.html';
+
+ function isDir(p) {
+ try {
+ return fs.statSync(p).isDirectory();
+ } catch {
+ return false;
+ }
+ }
+
+ /** 找出所有 content/posts///index.md */
+ function findPostDirs() {
+ const out = [];
+ if (!isDir(contentDir)) return out;
+ for (const year of fs.readdirSync(contentDir)) {
+ if (!/^\d{4}$/.test(year)) continue;
+ const yearDir = path.join(contentDir, year);
+ if (!isDir(yearDir)) continue;
+ for (const name of fs.readdirSync(yearDir)) {
+ const dir = path.join(yearDir, name);
+ if (isDir(dir) && fs.existsSync(path.join(dir, 'index.md'))) out.push(dir);
+ }
+ }
+ return out;
+ }
+
+ function slugFromDirName(dirName) {
+ const m = /^\d{4}-\d{2}-\d{2}-(.+)$/.exec(dirName);
+ return m ? m[1] : dirName;
+ }
+
+ function readIndex(dir) {
+ const filePath = path.join(dir, 'index.md');
+ const text = fs.readFileSync(filePath, 'utf8');
+ const { raw, body, eol } = splitFrontMatter(text);
+ let fm = null;
+ let fmError = null;
+ if (raw != null) {
+ try {
+ fm = parse(raw);
+ } catch (e) {
+ fmError = e.message;
+ }
+ }
+
+ // front matter 与正文之间的空行:语料里两种写法都存在(111 篇有空行、19 篇没有)。
+ // 正文给前端时要剥掉它(否则编辑框顶上永远空一行),但**必须记下来**,
+ // 回写时原样还原 —— 否则那 19 篇一保存就会被平白多插一个空行。
+ const lead = (/^\n*/.exec(body) || [''])[0];
+ return { filePath, text, fmRaw: raw, fm, fmError, body, bodyLead: lead, eol };
+ }
+
+ function toListItem(dir) {
+ const info = readIndex(dir);
+ const name = path.basename(dir);
+ const fm = info.fm || {};
+ return {
+ id: name, // ← API 定位键(目录名,文件系统保证唯一)
+ slug: String(fm.slug || slugFromDirName(name)),
+ title: fm.title || name,
+ date: fm.date ? String(fm.date) : '',
+ draft: fm.draft === true,
+ tags: Array.isArray(fm.tags) ? fm.tags : [],
+ categories: Array.isArray(fm.categories) ? fm.categories : [],
+ author: fm.author || '',
+ dirName: name,
+ relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'),
+ parseable: !info.fmError,
+ url: blogUrl(String(fm.slug || slugFromDirName(name))),
+ };
+ }
+
+ function listPosts({ q = '', page = 1, perPage = 20 } = {}) {
+ let rows = findPostDirs().map(toListItem);
+
+ // slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
+ const seen = new Map();
+ for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1);
+ for (const r of rows) r.slugConflict = (seen.get(r.slug) || 0) > 1;
+
+ const needle = String(q || '').trim().toLowerCase();
+ if (needle) {
+ rows = rows.filter((r) =>
+ [r.title, r.slug, r.author, ...(r.tags || []), ...(r.categories || [])]
+ .join(' ')
+ .toLowerCase()
+ .includes(needle),
+ );
+ }
+ rows.sort((a, b) => String(b.date).localeCompare(String(a.date)) || a.title.localeCompare(b.title));
+
+ const total = rows.length;
+ const size = Math.max(1, Math.min(200, Number(perPage) || 20));
+ const pages = Math.max(1, Math.ceil(total / size));
+ const cur = Math.min(Math.max(1, Number(page) || 1), pages);
+ return {
+ total,
+ totalPages: pages,
+ page: cur,
+ posts: rows.slice((cur - 1) * size, cur * size),
+ };
+ }
+
+ /**
+ * 把 id 解析成一个真实目录。
+ * 依次尝试:目录名 → 仓库相对路径 → slug(仅当唯一)。
+ * slug 撞名时**抛 409 而不是随便挑一个** —— 挑错了就是在改别的文章。
+ */
+ function locate(key) {
+ const k = String(key || '').trim();
+ if (!k) return null;
+ const all = findPostDirs();
+
+ const byName = all.filter((d) => path.basename(d) === k);
+ if (byName.length === 1) return byName[0];
+
+ const byRel = all.filter(
+ (d) => path.relative(repoRoot || path.resolve(contentDir, '..', '..'), d).split(path.sep).join('/') === k,
+ );
+ if (byRel.length === 1) return byRel[0];
+
+ const bySlug = all.filter((d) => toListItem(d).slug === k);
+ if (bySlug.length === 1) return bySlug[0];
+ if (bySlug.length > 1) {
+ throw bad(
+ 'slug「' + k + '」有多篇重复,无法确定改哪一篇:\n' +
+ bySlug.map((d) => ' · ' + path.basename(d)).join('\n'),
+ 409,
+ );
+ }
+ return null;
+ }
+
+ function getPost(key) {
+ const dir = locate(key);
+ if (!dir) return null;
+ const item = toListItem(dir);
+ const info = readIndex(dir);
+ return {
+ ...item,
+ filePath: info.filePath,
+ eol: info.eol,
+ frontMatter: info.fm,
+ frontMatterRaw: info.fmRaw,
+ frontMatterError: info.fmError,
+ // content 是给编辑框看的(没有前导空行);bodyLead 是回写时还原原文件用的
+ content: info.body.slice(info.bodyLead.length),
+ bodyLead: info.bodyLead,
+ url: blogUrl(item.slug),
+ };
+ }
+
+ /** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */
+ function pickDefined(obj) {
+ const out = {};
+ if (!obj || typeof obj !== 'object') return out;
+ for (const k of Object.keys(obj)) {
+ if (obj[k] !== undefined) out[k] = obj[k];
+ }
+ return out;
+ }
+
+ function savePost(key, patch = {}) {
+ const post = getPost(key);
+ if (!post) return null;
+ // 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格
+ if (typeof post.filePath !== 'string' || !post.eol) {
+ throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 });
+ }
+
+ const incoming = pickDefined(patch.frontMatter);
+ const merged = { ...(post.frontMatter || {}), ...incoming };
+
+ let fmText;
+ if (post.frontMatter && deepEqual(merged, post.frontMatter)) {
+ // 没动 front matter → 原文照抄,零风险
+ fmText = post.frontMatterRaw;
+ } else if (post.frontMatter === null) {
+ throw bad('这篇的 front matter 无法解析,为避免写坏,请只改正文(本次未提供字段变更)');
+ } else {
+ fmText = stringify(merged);
+ }
+
+ const body = patch.content !== undefined ? String(patch.content) : post.content;
+ // 前导空行:用户自己敲了就用他的,没敲就沿用原文件的风格(原文没有空行就不给加)
+ const typed = (/^\n*/.exec(body) || [''])[0];
+ const lead = typed.length ? typed : (post.bodyLead ?? '');
+ const text = joinFrontMatter(fmText, lead + body.slice(typed.length), post.eol);
+ fs.writeFileSync(post.filePath, text, 'utf8');
+
+ // 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章)
+ const after = getPost(post.dirName);
+ return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url };
+ }
+
+ /**
+ * 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。
+ * 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)完全一致 ——
+ * 仓库里 2026-06-22 之后的所有文章都是这个风格,别再用标题转拼音那套。
+ */
+ function makeSlug(d = new Date()) {
+ const p = (n) => String(n).padStart(2, '0');
+ return (
+ d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) +
+ p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds())
+ );
+ }
+
+ /** 目录名里的标题段:与 write-server 的 computeDirPath/titlePart 同一套规则 */
+ function titlePartOf(title) {
+ return (
+ String(title || '')
+ .replace(/[^\w一-鿿㐀-䶿]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .toLowerCase() || 'post'
+ );
+ }
+
+ function createPost({ frontMatter = {}, content = '' } = {}) {
+ const title = String(frontMatter.title || '').trim();
+ if (!title) throw bad('缺少标题');
+ const slug = String(frontMatter.slug || '').trim() || makeSlug();
+ if (!slug) throw bad('缺少 slug(且无法自动生成)');
+
+ // slug 必须全仓唯一 —— 否则就是新造一篇线上打不开的文章
+ const clash = listPosts({ perPage: 200 }).posts.find((r) => r.slug === slug);
+ if (clash) throw bad('slug「' + slug + '」已被占用(' + clash.dirName + '),请换一个', 409);
+
+ const date = String(frontMatter.date || new Date().toISOString().slice(0, 10));
+ const ymd = date.slice(0, 10);
+ const year = ymd.slice(0, 4);
+
+ const fm = {
+ title,
+ date,
+ slug,
+ author: String(frontMatter.author || defaultAuthor || ''),
+ layout: String(frontMatter.layout || 'post'),
+ categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [],
+ tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [],
+ draft: frontMatter.draft === true,
+ };
+
+ // 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>-
+ const dirName = ymd + '-' + titlePartOf(title) + '-' + slug;
+ const dir = path.join(contentDir, year, dirName);
+ if (fs.existsSync(dir)) throw bad('目录已存在: ' + dirName, 409);
+
+ fs.mkdirSync(dir, { recursive: true });
+ const filePath = path.join(dir, 'index.md');
+ fs.writeFileSync(filePath, joinFrontMatter(stringify(fm), '\n' + String(content).replace(/^\n+/, ''), '\n'), 'utf8');
+
+ return { id: dirName, slug, dirPath: dir, dirName, url: blogUrl(slug) };
+ }
+
+ /** 删除 = 移到仓库外的回收目录(不进 git,也不误伤版本历史) */
+ function deletePost(key) {
+ const post = getPost(key);
+ if (!post) return null;
+ const dir = path.dirname(post.filePath);
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const dest = path.join(trashDir, stamp + '-' + path.basename(dir));
+ fs.mkdirSync(trashDir, { recursive: true });
+ fs.renameSync(dir, dest);
+ return { id: post.dirName, slug: post.slug, movedTo: dest };
+ }
+
+ /** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */
+ function imageTargetDir(key) {
+ if (key) {
+ const post = getPost(key);
+ if (post) return { dir: path.dirname(post.filePath), bare: true };
+ throw bad('图片要挂到哪篇文章?找不到: ' + key, 404);
+ }
+ const year = new Date().getFullYear().toString();
+ return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' };
+ }
+
+ return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, contentDir };
+}
diff --git a/editor-api/test/api-e2e.mjs b/editor-api/test/api-e2e.mjs
new file mode 100644
index 00000000..11d7fd76
--- /dev/null
+++ b/editor-api/test/api-e2e.mjs
@@ -0,0 +1,238 @@
+/**
+ * editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。
+ *
+ * node test/api-e2e.mjs [baseUrl] [token]
+ *
+ * 重点验证「不写坏老文章」这条底线:
+ * ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键)
+ * ② 原样回存 → 整个文件必须**逐字节不变**
+ * ③ 改 front matter 字段 → 其余字段必须原样保留
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+
+const BASE = process.argv[2] || 'http://127.0.0.1:8017';
+const TOKEN = process.argv[3] || 'devtoken';
+const ROOT = path.resolve(import.meta.dirname, '../..');
+
+const TARGET_SLUG = '20251121';
+const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md');
+// ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名,
+// 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍,
+// 顺带验证 URL 编码链路。
+const TARGET_ID = path.basename(path.dirname(TARGET_FILE));
+const P = (id) => '/posts/' + encodeURIComponent(id);
+
+const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
+const read = (p) => fs.readFileSync(p, 'utf8');
+
+let pass = 0;
+const fails = [];
+function check(name, cond, extra = '') {
+ if (cond) {
+ pass++;
+ console.log(' ✅ ' + name);
+ } else {
+ fails.push(name + (extra ? ' → ' + extra : ''));
+ console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
+ }
+}
+
+async function call(method, p, body, raw) {
+ const res = await fetch(BASE + p, {
+ method,
+ headers: {
+ 'X-Editor-Token': TOKEN,
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ },
+ body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
+ });
+ const text = await res.text();
+ let data;
+ try {
+ data = text ? JSON.parse(text) : null;
+ } catch {
+ data = { raw: text };
+ }
+ return { status: res.status, data };
+}
+
+const original = read(TARGET_FILE);
+const originalSha = sha(TARGET_FILE);
+
+console.log('\n== ① 鉴权 ==');
+{
+ const noToken = await fetch(BASE + '/posts');
+ check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status);
+ const health = await fetch(BASE + '/health');
+ check('/health 免鉴权', health.status === 200, '实际 ' + health.status);
+ const bad = await call('GET', '/posts');
+ check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status);
+}
+
+console.log('\n== ② 读取 ==');
+let post;
+{
+ const r = await call('GET', P(TARGET_ID));
+ post = r.data;
+ check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug);
+ check('返回 id = 目录名', post.id === TARGET_ID, String(post.id));
+ check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || ''));
+ check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid));
+ check('title 正确', post.title === '除草日记', String(post.title));
+}
+
+console.log('\n== ③ 原样回存 → 必须逐字节不变 ==');
+{
+ const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
+ check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
+ check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16));
+}
+
+console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 ==');
+{
+ const body = post.content + '\n\n(测试追加的一行,稍后还原)\n';
+ const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body });
+ check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
+
+ const now = read(TARGET_FILE);
+ const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n');
+ check('front matter 区块逐字节不变', fmOf(now) === fmOf(original));
+ check('正文确实写进去了', now.includes('测试追加的一行'));
+ check('冷门键 ai_comment 仍在', now.includes('ai_comment:'));
+ check('pid 仍在', now.includes('pid: 133'));
+
+ // 还原
+ const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
+ check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16));
+}
+
+console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 ==');
+{
+ const fm = { ...post.frontMatter, title: '除草日记(测试改名)' };
+ const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content });
+ check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
+
+ const after = (await call('GET', P(TARGET_ID))).data;
+ check('title 已更新', after.title === '除草日记(测试改名)', String(after.title));
+ check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid));
+ check('ai_comment 未丢', 'ai_comment' in after.frontMatter);
+ check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment);
+ check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1);
+
+ // 还原。注意:一旦真的改过 front matter,整块会被按规范重排
+ // (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。
+ // 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。
+ await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
+ const back = (await call('GET', P(TARGET_ID))).data;
+ check('还原后 front matter 值与原值一致',
+ JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter),
+ JSON.stringify(back.frontMatter).slice(0, 160));
+ check('还原后正文与原文一致', back.content === post.content);
+ check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n'));
+}
+
+console.log('\n== ⑥ 新建 → 删除(走回收站)==');
+{
+ const slug = 'e2e-tmp-' + Date.now().toString(36);
+ const created = await call('POST', '/posts', {
+ frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true },
+ content: '# 标题\n\n正文。\n',
+ });
+ check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160));
+
+ const dir = created.data?.dirPath;
+ check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir));
+
+ const got = await call('GET', '/posts/' + slug);
+ check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章');
+
+ const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' });
+ check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status);
+
+ const del = await call('DELETE', '/posts/' + slug);
+ check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120));
+ check('目录已移走', !!dir && !fs.existsSync(dir));
+ if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true });
+}
+
+console.log('\n== ⑦ 图片上传 ==');
+{
+ const png = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+ );
+ const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), {
+ method: 'POST',
+ headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' },
+ body: png,
+ });
+ const data = await res.json();
+ check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140));
+ if (data.fileName) {
+ const f = path.join(path.dirname(TARGET_FILE), data.fileName);
+ check('文件落盘', fs.existsSync(f));
+ check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url));
+ if (fs.existsSync(f)) fs.unlinkSync(f);
+ }
+ const bad = await fetch(BASE + '/upload?name=x.txt', {
+ method: 'POST',
+ headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' },
+ body: 'hi',
+ });
+ check('非图片被拒绝', bad.status === 400, String(bad.status));
+}
+
+console.log('\n== ⑧ git 状态 ==');
+{
+ const r = await call('GET', '/git/status');
+ check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140));
+ console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60));
+}
+
+console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 ==');
+{
+ // 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901
+ const r = await call('GET', '/posts/20210901');
+ check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status);
+ check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120));
+ check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200);
+ check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404);
+}
+
+console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 ==');
+{
+ // 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff
+ const list = (await call('GET', '/posts?perPage=200')).data.posts;
+ let tested = 0;
+ for (const item of list) {
+ const r = await call('GET', P(item.id));
+ if (r.status !== 200 || !r.data?.filePath) continue;
+ const file = r.data.filePath;
+ const before = read(file);
+ // 只挑「--- 之后紧接正文」的那种
+ if (/\n---\r?\n[^\r\n]/.test(before) === false) continue;
+
+ await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content });
+ check('无空行文章原样回存字节不变:' + item.id,
+ sha(file) === crypto.createHash('sha256').update(before).digest('hex'));
+ fs.writeFileSync(file, before);
+ if (++tested >= 3) break;
+ }
+ if (!tested) console.log(' (语料里没找到这种写法,跳过)');
+}
+
+// 收尾:确保目标文件恢复原样
+if (sha(TARGET_FILE) !== originalSha) {
+ fs.writeFileSync(TARGET_FILE, original);
+ check('测试后强制还原目标文件', true);
+}
+
+console.log('\n──────────────────────────────');
+console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项');
+if (fails.length) {
+ console.log('失败明细:');
+ for (const f of fails) console.log(' · ' + f);
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/editor-api/test/frontmatter-roundtrip.mjs b/editor-api/test/frontmatter-roundtrip.mjs
new file mode 100644
index 00000000..ca5c770e
--- /dev/null
+++ b/editor-api/test/frontmatter-roundtrip.mjs
@@ -0,0 +1,93 @@
+/**
+ * front matter 解析器回归测试:拿仓库里全部真实文章跑无损往返。
+ *
+ * node test/frontmatter-roundtrip.mjs [contentDir]
+ *
+ * 断言三件事:
+ * 1. split → join 能**逐字节**还原原文件(说明切分没吃掉任何字符)
+ * 2. parse → stringify → parse 后对象与首次解析**深相等**(说明丢了不信息)
+ * 3. 记录所有解析失败的样本,便于判断是否需要补语法
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { splitFrontMatter, joinFrontMatter, parse, stringify, deepEqual } from '../src/frontmatter.mjs';
+
+const root = process.argv[2] || path.resolve(import.meta.dirname, '../../content/posts');
+
+function walk(dir, out = []) {
+ for (const name of fs.readdirSync(dir)) {
+ const p = path.join(dir, name);
+ const st = fs.statSync(p);
+ if (st.isDirectory()) walk(p, out);
+ else if (name.endsWith('.md')) out.push(p);
+ }
+ return out;
+}
+
+const files = walk(root);
+let ok = 0;
+const byteFail = [];
+const parseFail = [];
+const roundFail = [];
+
+for (const f of files) {
+ const text = fs.readFileSync(f, 'utf8');
+ const rel = path.relative(root, f);
+
+ const { raw, body, eol } = splitFrontMatter(text);
+ if (raw == null) {
+ parseFail.push([rel, '没有 front matter']);
+ continue;
+ }
+
+ // 1. 逐字节还原(含原始换行风格)
+ if (joinFrontMatter(raw, body, eol) !== text) byteFail.push(rel);
+
+ // 2. 解析 + 往返
+ let first;
+ try {
+ first = parse(raw);
+ } catch (e) {
+ parseFail.push([rel, e.message]);
+ continue;
+ }
+
+ let second;
+ try {
+ second = parse(stringify(first));
+ } catch (e) {
+ roundFail.push([rel, 'stringify/parse 失败: ' + e.message]);
+ continue;
+ }
+
+ if (!deepEqual(first, second)) {
+ const diff = Object.keys(first).concat(Object.keys(second)).filter(
+ (k, i, a) => a.indexOf(k) === i && !deepEqual(first[k], second[k]),
+ );
+ roundFail.push([rel, '字段不一致: ' + diff.join(', ')]);
+ continue;
+ }
+
+ ok++;
+}
+
+const total = files.length;
+console.log('样本总数 :', total);
+console.log('解析 + 往返通过 :', ok);
+console.log('切分不还原 :', byteFail.length);
+console.log('解析失败 :', parseFail.length);
+console.log('往返不一致 :', roundFail.length);
+
+const show = (title, arr) => {
+ if (!arr.length) return;
+ console.log('\n--- ' + title + ' ---');
+ for (const [f, why] of arr.slice(0, 12)) console.log(' ' + f + ' || ' + why);
+ if (arr.length > 12) console.log(' … 另有 ' + (arr.length - 12) + ' 条');
+};
+
+show('切分不还原(必须为 0)', byteFail.map((f) => [f, 'join != 原文']));
+show('解析失败', parseFail);
+show('往返不一致', roundFail);
+
+const bad = byteFail.length + parseFail.length + roundFail.length;
+process.exit(bad === 0 ? 0 : 1);
diff --git a/editor-api/test/save-roundtrip.mjs b/editor-api/test/save-roundtrip.mjs
new file mode 100644
index 00000000..aa88d2cf
--- /dev/null
+++ b/editor-api/test/save-roundtrip.mjs
@@ -0,0 +1,106 @@
+/**
+ * savePost 全量往返测试 —— 对仓库里**每一篇**真实文章跑一遍「读出来原样存回去」。
+ *
+ * 为什么要有这个:frontmatter-roundtrip 只测 split/join 这两个纯函数,
+ * 但真正会写坏文章的是 getPost → savePost 这条链路(中间的 content 剥了前导换行、
+ * 回写时又要还原,任何一处不对称都会在部分文章上产生脏 diff)。
+ * 所以这里必须用真文件、真篇数跑,不能只挑一篇(19/130 的写法差异就藏在里面)。
+ *
+ * 测试期间会**真实改写文件**,但每篇测完都立刻按原字节还原;
+ * 结尾再逐篇核对 sha256,任何一篇没还原都会让脚本失败并指名道姓。
+ *
+ * 跑法:node test/save-roundtrip.mjs
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { makePosts } from '../src/posts.mjs';
+import { splitFrontMatter } from '../src/frontmatter.mjs';
+
+const repoRoot = path.resolve(process.env.BLOG_ROOT || path.join(import.meta.dirname, '..', '..'));
+const contentDir = path.join(repoRoot, 'content', 'posts');
+const trashDir = path.join(repoRoot, '.editor-tmp', 'trash');
+
+const posts = makePosts({ contentDir, trashDir, repoRoot, defaultAuthor: '' });
+
+const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
+const fmBlockOf = (text) => {
+ const { raw } = splitFrontMatter(text);
+ if (raw == null) return null;
+ return '---\n' + raw + '\n---';
+};
+
+const list = posts.listPosts({ perPage: 500 });
+console.log('待测文章: ' + list.total + ' 篇 (仓库 ' + repoRoot + ')\n');
+
+let pass = 0;
+let fail = 0;
+const failures = [];
+
+/** 记录每篇的原始字节,最后统一核对还原情况 */
+const originals = new Map();
+
+for (const item of list.posts) {
+ // 用 id(目录名)而不是 slug —— 仓库里有 slug 撞名,按 slug 会拿到 409 或别的文章
+ const post = posts.getPost(item.id);
+ if (!post || typeof post.filePath !== 'string') {
+ fail++;
+ failures.push(item.id + ' → getPost 拿不到 filePath');
+ continue;
+ }
+ const file = post.filePath;
+ const before = fs.readFileSync(file);
+ originals.set(file, before);
+ const beforeHash = crypto.createHash('sha256').update(before).digest('hex');
+
+ let mark = '✓';
+
+ // ---- ① 原样回存:必须逐字节不变(这是最重要的一条)----
+ posts.savePost(item.id, { content: post.content, frontMatter: post.frontMatter });
+ const afterHash = sha(file);
+ if (afterHash !== beforeHash) {
+ mark = '✗';
+ failures.push(item.id + ' → 「原样回存」字节变了 ' + beforeHash.slice(0, 12) + ' ≠ ' + afterHash.slice(0, 12));
+ }
+
+ // ---- ② 只改正文:front matter 区块必须逐字节不变 ----
+ const midText = fs.readFileSync(file, 'utf8');
+ const fmBefore = fmBlockOf(midText);
+ posts.savePost(item.id, { content: post.content + '\n\n\n' });
+ const editedText = fs.readFileSync(file, 'utf8');
+ if (fmBefore !== fmBlockOf(editedText)) {
+ mark = '✗';
+ failures.push(item.id + ' → 改正文时 front matter 被改动');
+ }
+ if (!editedText.includes('')) {
+ mark = '✗';
+ failures.push(item.id + ' → 正文没写进去');
+ }
+
+ // ---- ③ 立刻还原成原字节 ----
+ fs.writeFileSync(file, before);
+
+ if (mark === '✓') pass++;
+ else fail++;
+ if (mark === '✗') console.log(mark + ' ' + item.slug);
+}
+
+// ---- ④ 总核对:全仓库不允许残留任何被改动的文章 ----
+console.log('\n--- 还原核对 ---');
+let notRestored = 0;
+for (const [file, buf] of originals) {
+ const now = fs.readFileSync(file);
+ if (!now.equals(buf)) {
+ notRestored++;
+ console.log(' ✗ 未还原: ' + file);
+ }
+}
+if (notRestored === 0) console.log(' ✓ ' + originals.size + ' 篇全部还原为原始字节');
+
+console.log('\n================================');
+console.log('通过 ' + pass + ' / 失败 ' + fail + ' 未还原 ' + notRestored);
+if (failures.length) {
+ console.log('\n失败明细:');
+ for (const f of failures) console.log(' · ' + f);
+}
+process.exit(fail || notRestored ? 1 : 0);
diff --git a/在线编辑器集成评估.md b/在线编辑器集成评估.md
new file mode 100644
index 00000000..0f6222a4
--- /dev/null
+++ b/在线编辑器集成评估.md
@@ -0,0 +1,176 @@
+# 在线编辑文章 · 集成到 api.200181.xyz/admin
+
+> 日期:2026-10-04(定稿)
+> 结论:**已实现并本地全链路验证通过。**
+> 形态:前端长在现有 `api.200181.xyz/admin` 面板里(新增「文章编辑」tab),
+> 后端是一个**零 npm 依赖的轻量 Docker 容器**(`editor-api/`),替代臃肿的 `write-server`。
+
+---
+
+## 0. 一句话
+
+把 `write-server/` 里**只有「文章在线编辑」这一件事**剥出来,做成 200 行级的轻量后端
+容器;`/admin` 面板加一个 tab,Worker 只做「登录鉴权 + 注入令牌转发」。
+
+---
+
+## 1. 最终架构
+
+```
+浏览器
+ │ (只用后台已有的管理员登录态,不需要任何新凭据)
+ ▼
+Cloudflare Worker api.200181.xyz blog-admin/src/routes/editor.ts
+ /api/v2/editor/* 只做两件事:
+ │ ① isAdminRequest() 鉴权
+ │ X-Editor-Token 只存在这里 ② 注入 X-Editor-Token 反代
+ ▼
+nginx post.usj.cc /editor-api/ ──► 127.0.0.1:8017
+ editor-api 容器(零 npm 依赖)
+ 读写 /srv/blog/content/posts/**,
+ 图片落文章同级目录,
+ 发布 = git add/commit/pull --rebase/push
+```
+
+三层各自的职责被切得很干净:
+
+| 层 | 文件 | 职责 | 故意不做的 |
+|---|---|---|---|
+| 前端 | `blog-admin/public/admin/admin.js`(新增 ~450 行) | 列表 / 编辑 / 图片粘贴 / 保存 / 发布 | 不碰令牌、不直连后端 |
+| Worker | `blog-admin/src/routes/editor.ts`(新增,150 行) | 鉴权 + 反代,10 条路由 | 不碰仓库 |
+| 后端 | `editor-api/`(新增目录) | 读写 Markdown + git | 不做账号、评论、AI、部署编排 |
+
+---
+
+## 2. 顺带发现的安全问题(write-server 生产环境)
+
+评估过程中实测发现 **`write-server` 线上完全没有鉴权**(只读核实,未做任何写操作):
+
+| 现象 | 证据 |
+|---|---|
+| 后端公网裸奔 | `23.254.236.47:8016` 直接返回 200 |
+| 文章全量泄露 | `https://post.usj.cc/api/posts` 无凭据返回全部 133 篇 |
+| `X-Auth-User` 无签名 | 只读明文用户名,可任意伪造 |
+| 写操作全裸 | PUT / DELETE / upload / deploy / ai 全部无鉴权、无 middleware |
+| nginx 未加 auth_basic | — |
+
+**这正是这次重写的动机**:不是把 write-server 搬个家,而是换成一个「默认安全」的小东西——
+令牌只在 Worker 里、端口只绑回环、没配令牌直接拒绝启动。
+
+---
+
+## 3. 改动清单
+
+**新增**
+- `editor-api/server.mjs` + `editor-api/src/{frontmatter,posts,git}.mjs` —— 零依赖后端
+- `editor-api/test/{frontmatter-roundtrip,save-roundtrip,api-e2e}.mjs` —— 三个测试
+- `editor-api/{Dockerfile,README.md}`、`docker-compose.editor.yml`
+- `blog-admin/src/routes/editor.ts` —— Worker 反代
+- `blog-admin/.dev.vars.example` 的编辑器两项
+
+**修改**
+- `blog-admin/src/index.ts` —— 注册 10 条 `/editor/*` 路由
+- `blog-admin/src/types.ts` —— Env 加 `EDITOR_API_BASE` / `EDITOR_TOKEN`
+- `blog-admin/wrangler.toml` —— `[vars]` 加 `EDITOR_API_BASE`
+- `blog-admin/public/admin/admin.js` —— 新增「文章编辑」tab(+约 450 行)
+- `blog-admin/public/admin/admin.css` —— 编辑器样式(+约 80 行)
+- `.gitignore` —— 加 `.editor-tmp/` / `.editor-trash/`
+
+---
+
+## 4. 测试结论(全部通过)
+
+| 测试 | 结果 | 说明 |
+|---|---|---|
+| `frontmatter-roundtrip.mjs` | **130/130** | split/join 逐字节还原 + parse/stringify 深相等 |
+| `save-roundtrip.mjs` | **130/130** | 每篇「读出来原样存回去」sha256 不变,测完全部还原 |
+| `api-e2e.mjs` | **44/44** | 鉴权/读取/回存/只改正文/改字段/新建删除/上传/git/撞名/无空行 |
+| Worker 反代链路(`.editor-tmp/verify-relay.mjs`) | **26/26** | 真实走 wrangler dev,含鉴权 403 / 404 / 409 |
+| 后台 UI(无头 Chrome 真点) | **全通过** | 登录→列表→打开→改→保存→发布弹层,4 张截图 |
+
+**过程中抓到并修掉的真 bug:**
+
+1. **front matter 后的空行**:语料里 111 篇有空行、19 篇没有;`getPost` 把前导空行剥掉后
+ 信息丢了,`savePost` 又无条件补一个 → 那 19 篇一保存就被平白多插一个空行。
+ 修法:`readIndex` 记 `bodyLead`,回写时按原文件风格还原。
+2. **slug 撞名静默改错文件**(详见第 6 节):定位键从 slug 换成目录名。
+3. CRLF/LF、块标量 chomping(`|-` / `|` / `|+`)、`getPost` 漏 `filePath`/`eol`、
+ 重复 slug 返回 500 —— 都是早期测试抓到的。
+
+---
+
+## 5. 上线步骤
+
+### ① 服务器侧(23.254.236.47)
+
+```bash
+# 1. 博客仓库 checkout 到 /srv/blog(main 分支,git 工作区)
+# (原 write-server 用的那份仓库可以直接沿用,确认 remote 是 CNB + GitHub 两段)
+
+# 2. 放 compose 与令牌
+cd /srv/blog
+mkdir -p /srv/editor-trash
+cat > .env <<'EOF'
+EDITOR_TOKEN=
+EOF
+docker compose -f docker-compose.editor.yml up -d --build
+curl -s http://127.0.0.1:8017/health # 应返回 {"ok":true,...}
+```
+
+### ② nginx(post.usj.cc 的 server 块里加一段)
+
+```nginx
+ # —— 文章编辑后端:只给 Cloudflare Worker 反代用 ——
+ # 令牌本身就是鉴权(X-Editor-Token),所以这里不再叠 auth_basic。
+ location /editor-api/ {
+ proxy_pass http://127.0.0.1:8017/; # 末尾的 / 会剥掉 /editor-api 前缀
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ client_max_body_size 25m; # 图片直传
+ proxy_read_timeout 120s; # git push 可能慢
+ }
+```
+
+> 可选加固:`location` 里再叠 `allow ; deny all;`,
+> 让这条路径只有 CF 能碰到。令牌泄露才是真风险,这层属于纵深防御。
+
+### ③ Cloudflare 侧
+
+```bash
+cd blog-admin
+npx wrangler secret put EDITOR_TOKEN # 粘贴与服务器 .env 相同的值
+npx wrangler deploy
+```
+`EDITOR_API_BASE` 已写在 `wrangler.toml` 的 `[vars]`(`https://post.usj.cc/editor-api`)。
+
+### ④ 验证
+
+打开 `https://api.200181.xyz/admin` → 「内容管理 / 文章编辑」→ 随便开一篇 → 改一个字 →
+保存 → 「发布 / 同步」里确认文件出现在待发布列表。
+
+### ⑤ 下线 write-server(**确认新编辑器好用之后再做**)
+
+1. nginx 里摘掉 write-server 的 `location /`(先只留 `location /editor-api/`)
+2. `docker stop write-server && docker update --restart=no write-server`
+3. 关掉 `8016` 的公网映射(compose 里删 ports 或改绑 127.0.0.1)
+4. 观察几天没问题,再删镜像与数据卷(**删前先备份**)
+
+---
+
+## 6. 遗留问题:slug 撞名(需要你决定)
+
+仓库里有 **5 组**文章共用同一个 slug,Hugo 的 permalink 是 `/:slug`,所以每组里
+**有一篇在线上是被另一篇覆盖掉的(打不开)**:
+
+| slug | 两篇 |
+|---|---|
+| `20210901` | Twitter主题加入加载耗时… / 无悔 |
+| `20211122` | 情侣恋爱倒计时小工具… / 这组照片的主题,咱就叫它光吧 |
+| `20211128` | 大学生体测… / 可惜不能一直做小孩子… |
+| `20211223` | 更换掉jsdelivr… / 放假之前最后一次的照片合集… |
+| `20240602` | parsec远程软件报6023错误 / idea关闭ai自动补全 |
+
+编辑器已经把这件事**标出来了**(列表里黄色「URL 冲突」标签;拿撞名 slug 去查会返回 409
+并列出候选篇目,不会猜)。但**改哪一篇的 slug、还是让后写的那篇换个 slug**,需要你定。
+
+> 顺带:改 slug = 改网址,旧链接会 404。如果在意 SEO,得配套做重定向。