后端(新增 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
205 lines
8.9 KiB
TypeScript
205 lines
8.9 KiB
TypeScript
/**
|
|
* 设置页的表单化: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<string, unknown>)[k];
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
|
|
const parts = path.split('.');
|
|
let cur: Record<string, unknown> = 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<string, unknown>;
|
|
}
|
|
cur[parts[parts.length - 1]] = value;
|
|
}
|
|
|
|
function deleteByPath(obj: Record<string, unknown>, 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<string, unknown>)[parts[i]];
|
|
}
|
|
if (cur !== null && typeof cur === 'object' && !Array.isArray(cur)) {
|
|
delete (cur as Record<string, unknown>)[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<string, unknown>): {
|
|
values: Record<string, unknown>;
|
|
extra: Record<string, unknown>;
|
|
} {
|
|
const extra: Record<string, unknown> = JSON.parse(JSON.stringify(parsed ?? {}));
|
|
const values: Record<string, unknown> = {};
|
|
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<string, unknown>,
|
|
extra: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const out: Record<string, unknown> = 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<string, string> {
|
|
const m = new Map<string, string>();
|
|
for (const g of SETTINGS_FORM) {
|
|
for (const f of g.fields) {
|
|
if (f.desc) m.set(f.path, f.desc);
|
|
}
|
|
}
|
|
return m;
|
|
}
|