feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)

后端(新增 editor-api/,零 npm 依赖,只用 node 内置模块):
- 只做文章相关:列表/读取/新建/保存/删除/图片上传/git 状态·发布·同步
- front matter 往返保真:未改动的块按字节照抄,CRLF/块标量/引号写法都不动
- 列表用目录名当 id,slug 撞名不再静默改错文件(返回 409 列候选)
- 鉴权只有一条路:X-Editor-Token(只存在 Worker 侧,浏览器拿不到)
- 端口只绑 127.0.0.1,由宿主机 nginx 反代出去

部署(新增 deploy/editor-api/bootstrap.sh):
- 一条命令在新机器上完成 克隆仓库→写 .env→起容器→健康检查
- 状态全在两个目录(/srv/blog 仓库工作区 + /srv/editor-api 配置),
  迁移 = 复制目录或在新机重跑本脚本,容器本身无状态

Cloudflare Worker 侧(blog-admin):
- src/routes/editor.ts:鉴权 + 反代,浏览器只跟 Worker 说话
- 管理面板新增「文章编辑」:两栏布局 + 快捷插入面板(13 项短代码,
  与 write-server 的 ShortcutPanel 一致)+ 底部 草稿/保存/发布
- 系统设置改为 schema 驱动的表单,且**以运行时实际生效的配置为准**
  (frontend_conf/captcha/moderator/ip_region/site_default + KV human_check),
  修复「表单显示一套、评论系统跑另一套」的脱节问题

验证:tsc 0 错;front matter 往返 130/130;保存往返 130/130;
API e2e 44/44;无头 Chrome UI e2e 14/14
This commit is contained in:
zqlit committed 2026-10-04 21:16:06 +08:00
1 parent 48a2fd697f
commit c217d20c30
24 files changed
+3597 -221

No files matched your search

+123
View File
@@ -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 };
}