Files
blog/blog-admin/public/admin/admin.js
T
zqlit a40a92f526 feat: SSL 证书管家 —— 后台面板 + 专属角色 + 国内机放行
集成在 api.200181.xyz(同一个 Worker),国内机 writeapi.usj.cc 同步可用。

新增第三档后台角色 'ssl':
- 只拿证书管家钥匙,看不到评论/文章/用户等模块
- 判定正着枚举放行(canManageSSL = admin|ssl),不用排除法,
  免得以后新增角色静默获得私钥权限
- 鉴权只认 Bearer 会话,绝不走 isAdminRequest —— 后者有 Artalk
  老客户端的 query 兜底,一旦进来的就是 TLS 私钥

数据(复用 RSS_KV,前缀 certkeeper:):
- 凭据一条一键,避开 KV 读-改-写无事务导致的并发丢数据
- 私钥/AK-SK 一律 AES-GCM 密文(TOKEN_SECRET 经 PBKDF2 派生)
- 列表接口只回显前 4 后 4 位,明文不进内存
- 配置读失败抛错而非返回空,避免一次保存覆盖线上配置

只读监控:
- probeTls 走 cloudflare:sockets 拿证书正文,实现到期分级
  与「库里记录 vs 线上实测」对比(match/mismatch/live-only/unreachable)
- 到期提醒邮件(HTML 已转义)

国内机 editor-api:
- identify 识别 ssl 角色;业务分支前白名单,ssl 只能碰 /health、
  /admin/session、/admin/logout 与 /api/v2/ssl*
- /api/v2/ssl* 反代放行 admin + ssl(RSS 仍是 admin-only)
- posts.mjs 越权兜底方向修正:owns() 从「非 editor 即放行」改为
  「只有 admin 不受限」,漏进来的 ssl 被当受限编辑而非管理员全放行
- createPost 显式拒绝非写作角色

验证:typecheck ✓ / selftest:ssl 117 项 ✓ / 浏览器 37 项 ✓
2026-10-06 14:19:01 +08:00

2724 lines
124 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
评论后台 —— artalk-cf 管理面板
纯原生 JS,零依赖。对接本服务已有的 Artalk API v2。
========================================================================== */
(function () {
'use strict';
// ============================================================ 基础工具
const API = location.origin + '/api/v2';
// 订阅服务跑在 Cloudflare Worker 上。后台若部署在国内机,同源请求会由 nginx
// 反代过去;部署在 Worker 上时 location.origin 就是 api.200181.xyz,行为一致。
const RSS_API = location.origin;
const LS_T = 'atk.token';
const LS_U = 'atk.user';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
/** HTML 转义 —— 所有来自评论/用户的数据都必须过这一层 */
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (m) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
/** 把用户写的 HTML 评论压成安全纯文本,表情图用占位符代替 */
function plainify(html) {
const t = String(html == null ? '' : html)
.replace(/<img[^>]*>/gi, '[图]')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li)>/gi, '\n')
.replace(/<[^>]+>/g, '');
return esc(t).replace(/\n{3,}/g, '\n\n').trim();
}
const nfmt = (n) => Number(n || 0).toLocaleString('zh-CN');
function qs(obj) {
const u = new URLSearchParams();
for (const k of Object.keys(obj)) {
const v = obj[k];
if (v !== '' && v != null) u.set(k, String(v));
}
return u.toString();
}
const truncate = (s, n) => (String(s || '').length > n ? String(s).slice(0, n) + '…' : String(s || ''));
// ============================================================ 角色
//
// 后台三档角色(服务端 users.role,权威实现在 blog-admin/src/lib/role.ts):
// admin 管理员 —— 全部模块
// editor 编辑 —— 只有「文章编辑」,而且只能写 / 发布自己的文章
// ssl SSL 管理员 —— 只有「证书管家」,碰不到评论 / 文章 / 用户
// 服务端已经归一化过一次(is_admin 优先),这里再兜一次,
// 兼容缓存里的老 state.me(localStorage 可能还留着改造前的结构)。
function roleOf(u) {
if (!u) return 'user';
if (u.is_admin) return 'admin';
if (u.role === 'editor' || u.role === 'ssl') return u.role;
return u.role === 'admin' ? 'admin' : 'user';
}
/** 当前登录者是不是编辑(决定导航能画几项、作者能不能改) */
function isEditor() { return roleOf(state.me) === 'editor'; }
/** 当前登录者是不是 SSL 管理员(只该看到「证书管家」一项) */
function isSslOnly() { return roleOf(state.me) === 'ssl'; }
/** 能进写作后台的角色 */
function canEnterBackend() { const r = roleOf(state.me); return r === 'admin' || r === 'editor'; }
/** 能进证书管家的角色 —— ★ 正着枚举,别写成「非编辑即放行」 */
function canUseCert() { const r = roleOf(state.me); return r === 'admin' || r === 'ssl'; }
/** 用户列表里的角色标签 */
function roleTag(u) {
const r = roleOf(u);
if (r === 'admin') return ' <span class="tag acc">管理员</span>';
if (r === 'editor') return ' <span class="tag">编辑</span>';
if (r === 'ssl') return ' <span class="tag ssl">SSL</span>';
return '';
}
// ============================================================ 状态
const state = {
tab: 'dash',
sites: [],
site: '',
me: null,
// 国内机模式:后台静态页部署在国内机(writeapi.usj.cc),编辑器接口同机直连,
// 登录态是本机签发的 Cookie 会话,整条链路不过 Cloudflare。
direct: false,
pending: 0,
c: { q: '', type: 'all', offset: 0, limit: 20, total: 0, denom: 1, rows: [], busy: false },
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 }
// 证书管家(SSL):概述 / 表单草稿 / 正在编辑的凭据名
ssl: { ov: null, cfg: null, busy: false, editing: '', accessItems: null, probe: {} },
};
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: 'settings', label: '系统设置', group: '系统', icon: 'settings' },
{ id: 'status', label: '运行状态', group: '系统', icon: 'status' },
{ id: 'ssl', label: '证书管家', group: '系统', icon: 'lock' },
];
// 16px 线性图标(stroke 继承 currentColor)
const I = (p) =>
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + '</svg>';
const ICONS = {
dash: I('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h5v-6h4v6h5V9.5"/>'),
comments: I('<path d="M21 11.5a8.4 8.4 0 0 1-8.5 8.4c-1.2 0-2.4-.2-3.4-.7L3 21l1.8-4.6A8.4 8.4 0 1 1 21 11.5z"/>'),
posts: I('<path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M8.5 13h7M8.5 16.5h4.5"/>'),
pages: I('<path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/>'),
users: I('<circle cx="9" cy="8" r="3.5"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M17.5 14.5a5.5 5.5 0 0 1 3 5"/>'),
feeds: I('<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5.2" cy="18.8" r="1.5"/>'),
links: I('<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 1 0-6.4-6.4l-1.6 1.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 1 0 6.4 6.4l1.6-1.6"/>'),
settings: I('<circle cx="12" cy="12" r="3.2"/><path d="M12 2.8v2.4M12 18.8v2.4M4.9 4.9l1.7 1.7M17.4 17.4l1.7 1.7M2.8 12h2.4M18.8 12h2.4M4.9 19.1l1.7-1.7M17.4 6.6l1.7-1.7"/>'),
status: I('<path d="M3 12h4l3-8 4 16 3-8h4"/>'),
lock: I('<rect x="4.5" y="10.5" width="15" height="10" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/><path d="M12 14.5v2.5"/>'),
moon: I('<path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/>'),
out: I('<path d="M9 4H5.5A1.5 1.5 0 0 0 4 5.5v13A1.5 1.5 0 0 0 5.5 20H9"/><path d="M15 16l4-4-4-4M19 12H9"/>'),
eye: I('<path d="M2.5 12S6 5.8 12 5.8 21.5 12 21.5 12 18 18.2 12 18.2 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="2.8"/>'),
edit: I('<path d="M4 20h4L19.5 8.5a2.12 2.12 0 0 0-3-3L5 17v3z"/><path d="M13.5 6.5l3 3"/>'),
trash: I('<path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 13h9l1-13"/><path d="M10 11v5M14 11v5"/>'),
};
// ============================================================ HTTP
let TOKEN = localStorage.getItem(LS_T) || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* 带重试的请求。
* GET 是幂等的,网络抖一下(浏览器报 Failed to fetch / ERR_CONNECTION_*)
* 不该让用户看到「打开失败」——换个连接重试一两次基本就好了。
* 非 GET 一律不重试:写操作重放可能造成重复写入。
*/
async function api(path, opt) {
opt = opt || {};
const method = (opt.method || 'GET').toUpperCase();
const tries = opt.retries != null ? opt.retries : (method === 'GET' ? 3 : 1);
let res = null;
let lastErr = null;
for (let i = 0; i < tries; i++) {
if (i) await sleep(i === 1 ? 350 : 900);
const headers = {};
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
try {
res = await fetch(API + path, {
method,
headers,
body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined,
signal: opt.signal,
});
break;
} catch (e) {
res = null;
lastErr = e;
// 主动取消(AbortError)不是网络问题,别重试
if (e && e.name === 'AbortError') break;
}
}
if (!res) {
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
const why = offline ? '本机网络已断开' : (lastErr && lastErr.message ? lastErr.message : '请求被中断');
throw new Error('网络不可达(' + why + ',已重试 ' + tries + ' 次)—— 检查网络后重试');
}
const txt = await res.text();
let data = null;
try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = { msg: txt }; }
if (!res.ok) {
// 401/403 一律视为会话失效,回登录门
if (res.status === 401 || res.status === 403) {
const e = new Error((data && data.msg) || '未授权');
e.status = res.status;
throw e;
}
throw new Error((data && (data.msg || data.error)) || 'HTTP ' + res.status);
}
return data;
}
// 订阅后端请求。凭据不再单独让用户输 —— 见下方注释。
async function rssApi(path, opt) {
opt = opt || {};
const u = new URL(RSS_API + path);
const headers = {};
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
// Cloudflare 版:带后台会话,订阅接口的 requireAuth 直接认(不再要「订阅口令」)
// 国内机版:TOKEN 为空,请求由本机 editor-api 反代时注入共享令牌
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
let res;
try {
res = await fetch(u.toString(), {
method: opt.method || 'GET',
headers,
body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined,
signal: opt.signal,
});
} catch (e) {
throw new Error('订阅后端网络不可达');
}
const txt = await res.text();
let data = null;
try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = { msg: txt }; }
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
const e = new Error((data && (data.error || data.msg)) || '订阅模块未授权');
e.status = res.status;
throw e;
}
throw new Error((data && (data.error || data.msg)) || 'HTTP ' + res.status);
}
return data;
}
// ============================================================ 提示 / 模态
let toastTimer = null;
function toast(msg, bad) {
const el = $('#toast');
el.textContent = msg;
el.className = 'toast' + (bad ? ' bad' : '');
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, 3200);
}
function modal(title, html) {
$('#modalTitle').textContent = title;
$('#modalBody').innerHTML = html;
$('#modal').hidden = false;
}
function closeModal() { $('#modal').hidden = true; }
function confirmBox(title, text, onYes) {
modal(title,
'<p style="margin-top:0">' + esc(text) + '</p>' +
'<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px">' +
'<button class="btn" data-act="modal-close"><span>取消</span></button>' +
'<button class="btn danger" id="cfmYes"><span>确定</span></button>' +
'</div>');
$('#cfmYes').onclick = async () => {
const b = $('#cfmYes'); b.dataset.busy = '1';
try { await onYes(); closeModal(); }
catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
// ============================================================ 登录 / 登出
function showGate() {
$('#app').hidden = true;
$('#gate').hidden = false;
const foot = $('#lgFoot');
if (foot) {
foot.textContent = state.direct
? '编辑器走国内机 · 不经过 Cloudflare'
: '优世界 · 评论与订阅统一管理';
}
const pass = $('#lgPass');
if (pass) pass.value = '';
setTimeout(() => { const i = $('#lgUser'); if (i) i.focus(); }, 60);
}
function showApp() {
$('#gate').hidden = true;
$('#app').hidden = false;
// 侧栏显示身份:编辑 / SSL 管理员标一下,免得 TA 以为自己权限跟管理员一样
const tag = isEditor()
? ' <span class="tag" style="font-size:10px">编辑</span>'
: isSslOnly()
? ' <span class="tag ssl" style="font-size:10px">SSL</span>'
: '';
$('#who').innerHTML = esc((state.me && (state.me.name || state.me.email)) || 'admin') + tag;
$('#footR').textContent = state.direct ? '编辑器走国内机 · 不经过 Cloudflare' : 'API v2 · 站点:' + (state.site || '—');
renderTabs();
// 各自落到自己唯一有权限的那一页:仪表盘/评论这些它们没有权限,
// 默认页只会是一片 403
go(isEditor() ? 'posts' : isSslOnly() ? 'ssl' : 'dash');
}
function logout(msg) {
// 国内机模式登录态在 Cookie 里,得让后端删掉会话,否则刷新又进去了
if (state.direct) {
fetch(API + '/admin/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
}
TOKEN = '';
state.me = null;
localStorage.removeItem(LS_T);
localStorage.removeItem(LS_U);
showGate();
if (msg) {
const err = $('#lgErr');
err.textContent = msg;
err.hidden = false;
}
}
async function doLogin(email, password) {
const d = await api('/user/access_token', { method: 'POST', body: { email, password } });
if (!d || !d.token) throw new Error('服务端未返回凭据');
TOKEN = d.token;
state.me = d.user || null;
localStorage.setItem(LS_T, TOKEN);
try { localStorage.setItem(LS_U, JSON.stringify(state.me)); } catch (e) {}
return state.me;
}
/**
* 国内机模式的登录:账号密码发给同机的 editor-api,换一个 HttpOnly Cookie。
* 全程不出境,也不碰 Cloudflare 的 D1 用户表。
*/
async function directLogin(user, password) {
let res;
try {
res = await fetch(API + '/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ user, password }),
});
} catch (e) {
throw new Error('连不上本机编辑器服务(' + (e.message || '网络错误') + ')');
}
const d = await res.json().catch(() => null);
if (!res.ok || !d || !d.ok) {
throw new Error((d && (d.error || d.msg)) || ('登录失败(HTTP ' + res.status + ')'));
}
state.me = d.user || { name: user, is_admin: true, role: 'admin' };
return state.me;
}
// ============================================================ 骨架
function renderTabs() {
// 国内机模式:文章编辑走本机直连(不出境);订阅源/友链由本机后端反代到
// Cloudflare 的订阅服务(带了共享令牌,前端不用口令)。评论/设置这些还
// 必须走 Cloudflare 会话(D1 数据在国内访问那一跳很慢),单独留个入口。
if (state.direct) {
const t = (id, label) =>
'<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' +
(ICONS[id] || '') + '<span>' + label + '</span></button>';
// 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy),
// 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画。
// ★ SSL 管理员在国内机侧除了证书管家什么都不该看到;证书接口由本机
// editor-api 反代到 Cloudflare(见 server.mjs 的 SSL_PATH_PREFIXES),
// 所以这一项在国内线路下同样可用。
if (isSslOnly()) {
$('#tabs').innerHTML = t('ssl', '证书管家');
return;
}
$('#tabs').innerHTML =
t('posts', '文章编辑') +
(isEditor()
? ''
: '<div class="nav-sep"></div>' +
'<div class="nav-group">订阅中心</div>' +
t('feeds', '订阅源') + t('links', '友链') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">证书</div>' +
t('ssl', '证书管家') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">需要 Cloudflare</div>' +
'<button class="tab" data-act="relogin">' + I('<path d="M12 3v9"/><path d="M7 7.5a7 7 0 1 0 10 0"/>') + '<span>登录完整后台</span></button>');
return;
}
// 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线
// ★ 权限裁剪:服务端本来就 403(见 routes/admin.ts 的 requireAdmin、
// routes/ssl.ts 的 auth),画出来只会让人点进一片报错。
// editor → 只留「文章编辑」
// ssl → 只留「证书管家」
// ★ 用 isEditor()/isSslOnly() 各自**正着**判断,别写成一条 filter 链 ——
// 以后新增角色时,漏写一个分支的后果是「默认看到全部模块」。
let tabs;
if (isEditor()) tabs = TABS.filter((t) => t.id === 'posts');
else if (isSslOnly()) tabs = TABS.filter((t) => t.id === 'ssl');
else tabs = TABS;
let html = '';
let lastGroup = null;
for (const t of tabs) {
if (t.group !== lastGroup) {
if (t.group) {
if (lastGroup !== null && lastGroup !== '') html += '<div class="nav-sep"></div>';
html += '<div class="nav-group">' + esc(t.group) + '</div>';
} else if (lastGroup !== null) {
html += '<div class="nav-sep"></div>';
}
lastGroup = t.group;
}
const badge = (t.id === 'comments' && state.pending > 0)
? '<span class="n">' + state.pending + '</span>' : '';
html += '<button class="tab' + (state.tab === t.id ? ' on' : '') + '" data-act="tab" data-id="' + t.id + '">'
+ (ICONS[t.icon] || '') + '<span>' + t.label + '</span>' + badge + '</button>';
}
// 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台
// (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie)
// 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份)
// ★ SSL 管理员不给这个入口:国内机后台目前只认 admin/editor 两种角色
// (见 editor-api 的 handoff 校验),跳过去会签不出会话。
if (!isSslOnly()) {
html += '<div class="nav-sep"></div>' +
'<div class="nav-group">线路</div>' +
'<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') +
'<span>国内线路(直连)</span></button>';
}
$('#tabs').innerHTML = html;
}
/** 更新顶栏面包屑:group / label */
function renderCrumb() {
const t = TABS.find((x) => x.id === state.tab);
if (!t) return;
const el = $('#crumb');
if (el) el.innerHTML = (t.group ? '<span class="cg">' + esc(t.group) + '</span><span class="sp">/</span>' : '')
+ '<span class="pg">' + esc(t.label) + '</span>';
}
function setBusy(sel, on) {
$$(sel).forEach((b) => { if (on) b.dataset.busy = '1'; else delete b.dataset.busy; });
}
// ============================================================ 路由
async function go(tab) {
state.tab = tab;
renderTabs();
renderCrumb();
closeSidebar(); // 移动端点完就收起抽屉
const v = $('#view');
v.innerHTML = '<div class="loading">加载中…</div>';
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 === 'ssl') await viewSSL();
} catch (e) {
if (e.status === 401 || e.status === 403) { logout('会话已失效,请重新登录'); return; }
if (state.tab !== tab) return; // 已切走,别用旧错误覆盖新视图
v.innerHTML = '<div class="empty">出错了:' + esc(e.message) + '</div>';
}
}
// ============================================================ 视图:仪表盘
async function viewDash() {
const sn = state.site;
const [total, pages, users, pend, latest, hot] = await Promise.all([
api('/stats/site_comment?' + qs({ site_name: sn })),
api('/pages?' + qs({ site_name: sn, limit: 1 })),
api('/users/all?' + qs({ limit: 1 })),
api('/comments?' + qs({ scope: 'site', site_name: sn, type: 'pending', limit: 1 })),
api('/stats/latest_comments?' + qs({ limit: 8 })),
api('/stats/comment_most_pages?' + qs({ limit: 6 })),
]);
state.pending = pend.count || 0;
renderTabs();
const cards = [
{ k: '评论总数', v: total.data || 0, x: '站点「' + sn + '」' },
{ k: '涉及页面', v: pages.count || 0, x: '有评论记录的文章' },
{ k: '评论用户', v: users.count || 0, x: '去重后的人数' },
{ k: '待审核', v: state.pending, x: state.pending ? '需要处理' : '全部已处理' },
].map((c) =>
'<div class="stat"><div class="k">' + c.k + '</div>' +
'<div class="v">' + nfmt(c.v) + '</div><div class="x">' + esc(c.x) + '</div></div>'
).join('');
const latestRows = (latest.data || []).map((c) =>
'<div class="mini-i">' +
'<span class="n">' + esc((c.date || '').slice(5, 16)) + '</span>' +
'<span class="b"><span class="nm">' + esc(c.nick) + '</span> ' +
'<span class="tx">' + esc(truncate(String(c.content || '').replace(/<[^>]+>/g, ''), 44)) + '</span></span>' +
'<span class="c">#' + c.id + '</span>' +
'</div>'
).join('') || '<div class="empty">暂无评论</div>';
const hotRows = (hot.data || []).map((p) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(truncate(p.title || p.key, 26)) + '</span> ' +
'<span class="tx">' + esc(p.key) + '</span></span>' +
'<span class="c">' + nfmt(p.comment_count) + '</span>' +
'</div>'
).join('') || '<div class="empty">暂无数据</div>';
if (state.tab !== 'dash') return;
$('#view').innerHTML =
head('仪表盘', 'DASHBOARD', '站点 ' + sn + ' 的评论概览') +
'<div class="stats">' + cards + '</div>' +
'<div class="grid2">' +
'<section class="card"><header class="card-head"><h3>最新评论</h3></header><div class="card-body"><div class="mini">' +
latestRows + '</div></div></section>' +
'<section class="card"><header class="card-head"><h3>评论最多的文章</h3></header><div class="card-body"><div class="mini">' +
hotRows + '</div></div></section>' +
'</div>';
}
function head(zh, en, sub, actions) {
void en; // 页头不再显示英文代号
return '<div class="page-head"><h2>' + esc(zh) + '</h2>' +
(sub ? '<span class="sub">' + esc(sub) + '</span>' : '') +
(actions ? '<div class="right">' + actions + '</div>' : '') +
'</div>';
}
// ============================================================ 视图:评论
async function viewComments() {
const c = state.c;
if (state.tab !== 'comments') return;
$('#view').innerHTML =
head('评论管理', 'COMMENTS', '', '<button class="btn tiny" data-act="c-reload">刷新</button>') +
'<div class="ctool">' +
'<div class="grow"><input id="cq" type="search" placeholder="搜索昵称 / 邮箱 / 内容…" value="' + esc(c.q) + '"></div>' +
'<select id="ctype" style="width:132px">' +
['all|全部评论', 'pending|仅待审核'].map((o) => {
const [v, l] = o.split('|');
return '<option value="' + v + '"' + (c.type === v ? ' selected' : '') + '>' + l + '</option>';
}).join('') +
'</select>' +
'<select id="climit" style="width:112px">' +
[20, 50, 100].map((n) => '<option value="' + n + '"' + (c.limit === n ? ' selected' : '') + '>' + n + ' 条/页</option>').join('') +
'</select>' +
'</div>' +
'<div id="clist"><div class="loading">加载中…</div></div>' +
'<div id="cpager"></div>';
const q = $('#cq');
let tmr = null;
q.oninput = () => {
clearTimeout(tmr);
tmr = setTimeout(() => { c.q = q.value.trim(); c.offset = 0; loadComments(); }, 420);
};
$('#ctype').onchange = (e) => { c.type = e.target.value; c.offset = 0; loadComments(); };
$('#climit').onchange = (e) => { c.limit = parseInt(e.target.value, 10); c.offset = 0; loadComments(); };
await loadComments();
}
async function loadComments() {
const c = state.c;
const box = $('#clist');
if (!box) return;
box.innerHTML = '<div class="loading">加载中…</div>';
// 待审视图走扁平模式:待审的可能是任意层级的回复,按“顶层分页”会整条漏掉
const useFlat = c.type === 'pending';
const d = await api('/comments?' + qs({
scope: 'site',
site_name: state.site,
type: useFlat ? 'pending' : '',
search: c.q,
limit: c.limit,
offset: c.offset,
flat_mode: useFlat ? 'true' : '',
}));
if (state.tab !== 'comments' || !document.body.contains(box)) return; // 已切走,丢弃
// visible=false 是服务端为补全树而带出的“范围外父评论”,管理列表不需要
c.rows = (d.comments || []).filter((x) => x.visible !== false);
c.total = d.count || 0;
// 树形视图按“顶层数”分页;扁平视图按总条数分页
c.denom = useFlat ? c.total : (d.roots_count != null ? d.roots_count : c.total);
if (useFlat) state.pending = c.total;
renderTabs();
if (!c.rows.length) {
box.innerHTML = '<div class="empty">' + (c.q ? '没有匹配 “' + esc(c.q) + '” 的评论' : '暂无评论') + '</div>';
$('#cpager').innerHTML = '';
return;
}
// 按父子关系组织
const kids = new Map();
const roots = [];
const ids = new Set(c.rows.map((x) => x.id));
for (const r of c.rows) {
if (r.rid && ids.has(r.rid)) {
if (!kids.has(r.rid)) kids.set(r.rid, []);
kids.get(r.rid).push(r);
} else {
roots.push(r);
}
}
box.innerHTML = '<div class="clist">' + roots.map((r) => node(r, kids, 0)).join('') + '</div>';
const pages = Math.max(1, Math.ceil(c.denom / c.limit));
const cur = Math.floor(c.offset / c.limit) + 1;
$('#cpager').innerHTML =
'<div class="pager">' +
'<button class="btn tiny" data-act="c-prev"' + (c.offset <= 0 ? ' disabled' : '') + '>‹ 上一页</button>' +
'<span class="info">第 <b>' + cur + '</b> / ' + pages + ' 页 · 共 <b>' + nfmt(c.total) + '</b> 条评论' +
(useFlat ? '(待审,按条)' : ' / ' + nfmt(c.denom) + ' 个主题') + '</span>' +
'<button class="btn tiny" data-act="c-next"' + (cur >= pages ? ' disabled' : '') + '>下一页 ›</button>' +
'</div>';
}
function node(c, kids, depth) {
const cls = ['citem'];
if (c.is_pending) cls.push('pend');
if (c.is_pinned) cls.push('pin');
if (c.is_collapsed) cls.push('hid');
if (depth > 0) cls.push('rep');
const tags = [];
if (c.is_pinned) tags.push('<span class="tag pink">置顶</span>');
if (c.is_pending) tags.push('<span class="tag warn">待审</span>');
if (c.is_collapsed) tags.push('<span class="tag line">折叠</span>');
if (c.badge_name) tags.push('<span class="tag acc">' + esc(c.badge_name) + '</span>');
if (c.is_verified) tags.push('<span class="tag blue">博主</span>');
const ch = kids.get(c.id) || [];
const opened = state.openReps.has(c.id);
// 头像:服务端给的 email_encrypted(md5),走前台同一套 weavatar 镜像
const avatar = 'https://weavatar.com/avatar/' + esc(c.email_encrypted || '') + '?d=mp&s=80';
return '<article class="' + cls.join(' ') + '" data-cid="' + c.id + '">' +
'<div class="crow">' +
'<img class="cavatar" src="' + avatar + '" loading="lazy" alt="" onerror="this.style.visibility=\'hidden\'">' +
'<div class="cmain">' +
'<div class="chead">' +
'<span class="nick">' + esc(c.nick) + '</span>' +
tags.join('') +
'<span class="t">#' + c.id + ' · ' + esc(c.date) + '</span>' +
'</div>' +
'<div class="cbody">' + (plainify(c.content) || '<span class="ph">(空内容)</span>') + '</div>' +
'<div class="cfoot">' +
'<a href="' + esc(c.page_url || '#') + '" target="_blank" rel="noopener" title="' + esc(c.page_url || '') + '">' +
esc(truncate(c.page_key, 42)) + '</a>' +
(ch.length ? ' · <span>回复 ' + ch.length + '</span>' : '') +
(c.ua ? ' · <span title="' + esc(c.ua) + '">' + esc(truncate(c.ua.replace(/^Mozilla\/5\.0\s*/, ''), 26)) + '</span>' : '') +
'<span class="acts">' +
(ch.length ? '<button class="btn tiny" data-act="c-toggle" data-id="' + c.id + '">' + (opened ? '收起' : '展开') + '</button>' : '') +
(c.is_pending ? '<button class="btn tiny" data-act="c-approve" data-id="' + c.id + '">通过</button>' : '') +
'<button class="btn tiny" data-act="c-pin" data-id="' + c.id + '" data-v="' + (c.is_pinned ? '0' : '1') + '">' + (c.is_pinned ? '取消置顶' : '置顶') + '</button>' +
'<button class="btn tiny" data-act="c-fold" data-id="' + c.id + '" data-v="' + (c.is_collapsed ? '0' : '1') + '">' + (c.is_collapsed ? '展开' : '折叠') + '</button>' +
'<button class="btn tiny danger" data-act="c-del" data-id="' + c.id + '">删除</button>' +
'</span>' +
'</div>' +
(ch.length && opened ? '<div class="creplies">' + ch.map((x) => node(x, kids, depth + 1)).join('') + '</div>' : '') +
'</div>' +
'</div>' +
'</article>';
}
// ============================================================ 视图:页面
async function viewPages() {
const p = state.p;
const d = await api('/pages?' + qs({ site_name: state.site, limit: p.limit, offset: p.offset }));
p.total = d.count || 0;
const rows = d.pages || [];
if (state.tab !== 'pages') return;
$('#view').innerHTML =
head('页面', 'PAGES', '共 ' + nfmt(p.total) + ' 个有评论记录的页面') +
(rows.length
? '<div class="tblwrap"><table><thead><tr>' +
'<th>页面标题</th><th>访问路径</th><th class="num">票数</th><th>最近活动</th>' +
'</tr></thead><tbody>' +
rows.map((r) =>
'<tr>' +
'<td><span class="ell">' + (esc(r.title) || '<span style="color:var(--muted)">(无标题)</span>') + '</span></td>' +
'<td><a class="lk" href="' + esc(r.url) + '" target="_blank" rel="noopener">' + esc(r.key) + '</a></td>' +
'<td class="num">' + nfmt(r.vote_up) + ' / ' + nfmt(r.vote_down) + '</td>' +
'<td class="lk">' + esc(r.date) + '</td>' +
'</tr>').join('') +
'</tbody></table></div>' +
pager('p', p, '页')
: '<div class="empty">暂无页面</div>');
}
function pager(prefix, st, unit) {
const pages = Math.max(1, Math.ceil(st.total / st.limit));
const cur = Math.floor(st.offset / st.limit) + 1;
return '<div class="pager">' +
'<button class="btn tiny" data-act="' + prefix + '-prev"' + (cur <= 1 ? ' disabled' : '') + '>‹ 上一页</button>' +
'<span class="info">第 <b>' + cur + '</b> / ' + pages + ' 页 · 共 <b>' + nfmt(st.total) + '</b> ' + unit + '</span>' +
'<button class="btn tiny" data-act="' + prefix + '-next"' + (cur >= pages ? ' disabled' : '') + '>下一页 ›</button>' +
'</div>';
}
// ============================================================ 视图:用户
async function viewUsers() {
const u = state.u;
if (state.tab !== 'users') return;
$('#view').innerHTML =
head('用户', 'USERS', '',
'<button class="btn tiny primary" data-act="u-new">+ 新建用户</button>' +
'<button class="btn tiny" data-act="u-reload">刷新</button>') +
'<div class="ctool"><div class="grow"><input id="uq" type="search" placeholder="按昵称 / 邮箱筛选…" value="' + esc(u.q) + '"></div></div>' +
'<div id="ulist"><div class="loading">加载中…</div></div>';
const q = $('#uq');
let tmr = null;
q.oninput = () => {
clearTimeout(tmr);
tmr = setTimeout(() => { u.q = q.value.trim(); u.offset = 0; loadUsers(); }, 420);
};
await loadUsers();
}
async function loadUsers() {
const u = state.u;
const box = $('#ulist');
if (!box) return;
box.innerHTML = '<div class="loading">加载中…</div>';
const d = await api('/users/all?' + qs({
limit: u.limit, offset: u.offset,
name: u.q.indexOf('@') < 0 ? u.q : '',
email: u.q.indexOf('@') >= 0 ? u.q : '',
}));
u.total = d.count || 0;
if (state.tab !== 'users' || !document.body.contains(box)) return; // 已切走,丢弃
const rows = d.users || [];
u.rows = rows; // 供编辑弹窗取行数据(API 单页上限 200,不能靠再查一次兜住 492 个用户)
const html = rows.length
? '<div class="tblwrap"><table><thead><tr>' +
'<th>昵称</th><th>邮箱</th><th class="num">评论</th><th>徽章</th><th>近期 IP</th><th></th>' +
'</tr></thead><tbody>' +
rows.map((r) =>
'<tr>' +
'<td><b>' + esc(r.name) + '</b>' + roleTag(r) + '</td>' +
'<td class="lk">' + esc(r.email) + '</td>' +
'<td class="num">' + nfmt(r.comment_count) + '</td>' +
'<td>' + (r.badge_name
? '<span class="tag" style="background:' + esc(r.badge_color || '#0f8') + '22;color:' + esc(r.badge_color || '#0f8') + '">' + esc(r.badge_name) + '</span>'
: '<span style="color:var(--muted)">—</span>') + '</td>' +
'<td class="lk">' + esc(r.last_ip || '—') + '</td>' +
'<td><button class="btn tiny" data-act="u-edit" data-id="' + r.id + '">编辑</button></td>' +
'</tr>').join('') +
'</tbody></table></div>' + pager('u', u, '个用户')
: '<div class="empty">没有匹配的用户</div>';
box.innerHTML = html;
}
/**
* 角色下拉 —— 一个控件同时表达四档。
* 拆成「管理员 checkbox + 编辑 checkbox + SSL checkbox」会有八种组合,
* 其中七种是废的。
*/
function roleSelectHtml(u, id) {
const cur = roleOf(u);
const self = state.me && state.me.id != null && String(u.id) === String(state.me.id);
const opt = (v, label) =>
'<option value="' + v + '"' + (cur === v ? ' selected' : '') + '>' + esc(label) + '</option>';
return '<label class="field"><span class="field-k">角色</span>' +
'<select id="' + id + '"' + (self ? ' disabled' : '') + '>' +
opt('user', '普通用户(只能评论,进不了后台)') +
opt('editor', '编辑(只能写 / 发布自己的文章)') +
opt('ssl', 'SSL 管理员(只能配证书,碰不到评论和文章)') +
opt('admin', '管理员(全部权限)') +
'</select>' +
(self
? '<span class="px" style="color:var(--muted);font-size:12px">不能修改自己的角色 —— 免得把自己锁在后台外面</span>'
: '') +
'</label>';
}
/** 新建用户:体量上跟「编辑用户」不是一回事,单独一个弹窗 */
function newUser() {
modal('新建用户',
'<label class="field"><span class="field-k">昵称(会显示在文章署名上)</span><input id="nuName" autocomplete="off" placeholder="如:小美"></label>' +
'<label class="field"><span class="field-k">登录账号(邮箱或用户名都行)</span><input id="nuMail" autocomplete="off" placeholder="登录时填这个"></label>' +
'<label class="field"><span class="field-k">密码</span><input id="nuPw" type="password" autocomplete="new-password" placeholder="给 TA 一个初始密码"></label>' +
roleSelectHtml({ role: 'editor' }, 'nuRole') +
'<label class="field"><span class="field-k">头衔徽章(可选)</span><input id="nuTitle" autocomplete="off" placeholder="如:专栏作者"></label>' +
'<div class="alert" style="margin-bottom:0">编辑角色登录后只会看到「文章编辑」,列表里只有自己写的文章,' +
'发布时也只提交自己名下那几篇。</div>' +
'<button class="btn primary block" id="nuSave" style="margin-top:16px"><span>创建</span></button>');
$('#nuSave').onclick = async () => {
const body = {
name: $('#nuName').value.trim(),
email: $('#nuMail').value.trim(),
password: $('#nuPw').value,
role: $('#nuRole').value,
title_name: $('#nuTitle').value.trim(),
};
if (!body.name || !body.email) { toast('✕ 昵称和登录账号都要填', true); return; }
if (!body.password) { toast('✕ 要设一个初始密码,否则 TA 登不进来', true); return; }
const b = $('#nuSave'); b.dataset.busy = '1';
try {
await api('/users', { method: 'POST', body: body });
closeModal(); toast('✓ 已创建');
await loadUsers();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
function editUser(id) {
(function () {
const u = (state.u.rows || []).filter((x) => x.id === id)[0];
if (!u) { toast('用户不存在,请刷新列表', true); return; }
modal('编辑用户 #' + id,
'<label class="field"><span class="field-k">昵称</span><input id="euName" value="' + esc(u.name) + '"></label>' +
'<label class="field"><span class="field-k">邮箱</span><input id="euMail" value="' + esc(u.email) + '"></label>' +
'<label class="field"><span class="field-k">主页链接</span><input id="euLink" value="' + esc(u.link || '') + '"></label>' +
'<div style="display:flex;gap:14px">' +
'<label class="field" style="flex:1"><span class="field-k">徽章文字</span><input id="euBn" value="' + esc(u.badge_name || '') + '" placeholder="如:博主"></label>' +
'<label class="field" style="width:130px"><span class="field-k">徽章颜色</span><input id="euBc" value="' + esc(u.badge_color || '') + '" placeholder="#0f8"></label>' +
'</div>' +
'<label class="field"><span class="field-k">头衔徽章(金色,显示在系统徽章后、友链/友圈/官职前)</span><input id="euTitle" value="' + esc(u.title_name || '') + '" placeholder="如:荣誉站长 / 合作伙伴"></label>' +
'<label class="field"><span class="field-k">重设密码(留空不变)</span><input id="euPw" type="password" placeholder="••••••••"></label>' +
roleSelectHtml(u, 'euRole') +
'<button class="btn primary block" id="euSave"><span>保存</span></button>');
$('#euSave').onclick = async () => {
const body = {
name: $('#euName').value.trim(),
email: $('#euMail').value.trim(),
link: $('#euLink').value.trim(),
badge_name: $('#euBn').value.trim(),
badge_color: $('#euBc').value.trim(),
title_name: $('#euTitle').value.trim(),
role: $('#euRole').value,
};
const pw = $('#euPw').value;
if (pw) body.password = pw;
const b = $('#euSave'); b.dataset.busy = '1';
try {
await api('/users/' + id, { method: 'PUT', body: body });
closeModal(); toast('✓ 已保存');
loadUsers();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
})();
}
// ============================================================ 视图:设置
//
// 表单化:schema 由后端 /settings/form 下发(唯一事实源在 src/lib/settings-form.ts),
// 前端只负责画表单和收值,完全不拼 YAML。schema 之外的键放在 extra 里原样传回,
// 保证「表单化」永远不会静默丢配置。需要手改 YAML 时展开底部「高级」区。
async function viewSettings() {
const [sites, form] = await Promise.all([api('/sites'), api('/settings/form')]);
state.sites = sites || [];
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) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(s.name) + '</span> ' +
'<span class="tx">' + esc((s.urls || []).join(', ')) + '</span></span>' +
'<span class="c"><button class="btn tiny" data-act="s-edit" data-id="' + s.id + '">编辑</button></span>' +
'</div>').join('') || '<div class="empty">暂无站点</div>';
const groups = state.sf.schema.map((g) => {
const fields = g.fields.map(sfField).join('');
return '<section class="card" style="margin-top:14px">' +
'<header class="card-head"><h3>' + esc(g.title) + '</h3>' +
(g.desc ? '<span class="po-hint">' + esc(g.desc) + '</span>' : '') + '</header>' +
'<div class="card-body"><div class="sf-grid">' + fields + '</div></div></section>';
}).join('');
$('#view').innerHTML =
head('设置', 'SETTINGS', '站点与前台配置 · 改完点右上角保存',
'<button class="btn primary tiny" data-act="sf-save">保存配置</button>') +
'<section class="card"><header class="card-head"><h3>站点</h3></header>' +
'<div class="card-body"><div class="mini">' + siteRows + '</div></div></section>' +
groups +
'<details class="sf-adv" id="sfAdv" style="margin-top:14px">' +
'<summary>高级:直接编辑原始 YAML(动了这个就以它为准,表单不再参与保存)</summary>' +
'<div id="sfAdvBox" style="margin-top:10px"><div class="loading">展开时加载…</div></div>' +
'</details>';
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 = '<textarea id="sfYaml" class="code" spellcheck="false" style="min-height:320px"></textarea>' +
'<p style="margin:10px 0 0;font-size:12px;color:var(--muted)">这是服务端实际保存的完整配置。' +
'保存后仍会按同一套规则回填(frontend.* / captcha / human_check / moderator / ip_region / site_default)。</p>';
$('#sfYaml').value = d.yaml || '';
$('#sfYaml').addEventListener('input', () => { state.sf.advTouched = true; });
box.dataset.loaded = '1';
} catch (e) {
box.innerHTML = '<div class="empty">读取失败:' + esc(e.message) + '</div>';
}
});
}
/** 单个表单控件(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 '<div class="sf-item"><label class="po-ck sf-ck">' +
'<input type="checkbox" data-sf="' + esc(f.path) + '" id="' + id + '" style="width:auto"' + (v === true ? ' checked' : '') + '>' +
'<span>' + esc(f.label) + (f.hint ? ' <em class="sf-em">' + esc(f.hint) + '</em>' : '') + '</span></label></div>';
}
let ctl;
if (t === 'select') {
ctl = '<select data-sf="' + esc(f.path) + '" id="' + id + '">' +
(f.options || []).map((o) =>
'<option value="' + esc(o.v) + '"' + (String(v == null ? '' : v) === o.v ? ' selected' : '') + '>' + esc(o.t) + '</option>',
).join('') + '</select>';
} else if (t === 'number') {
ctl = '<input data-sf="' + esc(f.path) + '" id="' + id + '" type="number" step="1" value="' + esc(v == null ? '' : v) + '">';
} else {
ctl = '<input data-sf="' + esc(f.path) + '" id="' + id + '" value="' + esc(v == null ? '' : v) + '" autocomplete="off" spellcheck="false">';
}
return '<div class="sf-item"><label class="field" style="margin-bottom:0">' +
'<span class="field-k">' + esc(f.label) + '</span>' + ctl +
(f.hint ? '<span class="sf-hint">' + esc(f.hint) + '</span>' : '') +
'</label></div>';
}
/** 表单 → 扁平的 { 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;
modal('编辑站点 #' + id,
'<label class="field"><span class="field-k">站点名</span><input id="esName" value="' + esc(s.name) + '"></label>' +
'<label class="field"><span class="field-k">站点地址(多个用换行分隔)</span>' +
'<textarea id="esUrls" style="min-height:80px">' + esc((s.urls || []).join('\n')) + '</textarea></label>' +
'<p style="font-size:12px;color:var(--muted);margin-top:0">改站点名会让前台 <code>site</code> 与数据对不上,除非你同步改博客配置。</p>' +
'<button class="btn primary block" id="esSave"><span>保存</span></button>');
$('#esSave').onclick = async () => {
const b = $('#esSave'); b.dataset.busy = '1';
try {
await api('/sites/' + id, {
method: 'PUT',
body: { name: $('#esName').value.trim(), urls: $('#esUrls').value.split('\n').map((x) => x.trim()).filter(Boolean).join(',') },
});
closeModal(); toast('✓ 已保存');
go('settings');
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
// ============================================================ 视图:状态
async function viewStatus() {
const h = await api('/healthz');
let v = {};
try { v = await api('/version'); } catch (e) {}
const okDd = (x) => '<dd class="' + (x ? 'ok' : 'bad') + '">' + (x ? 'OK' : 'FAIL') + '</dd>';
if (state.tab !== 'status') return;
$('#view').innerHTML =
head('系统状态', 'STATUS', '运行时自检') +
'<div class="grid2">' +
'<section class="card"><header class="card-head"><h3>服务端自检</h3></header><div class="card-body"><dl class="kv">' +
'<dt>D1 数据库</dt>' + okDd(h.db === 'up') +
'<dt>评论总数</dt><dd>' + nfmt(h.comments) + '</dd>' +
'<dt>查询延迟</dt><dd>' + esc(h.latency_ms) + ' ms</dd>' +
'<dt>API 版本</dt><dd>' + esc(h.version) + '</dd>' +
'</dl></div></section>' +
'<section class="card"><header class="card-head"><h3>运行时</h3></header><div class="card-body"><dl class="kv">' +
'<dt>应用</dt><dd>' + esc(v.app || 'artalk') + '</dd>' +
'<dt>版本</dt><dd>' + esc(v.version || '—') + '</dd>' +
'<dt>提交</dt><dd>' + esc(v.commit_hash || '—') + '</dd>' +
'<dt>端点</dt><dd>' + esc(location.origin) + '</dd>' +
'<dt>登录身份</dt><dd>' + esc((state.me && (state.me.name || state.me.email)) || '—') + '</dd>' +
'</dl></div></section>' +
'</div>' +
'<section class="card" style="margin-top:14px"><header class="card-head"><h3>注意事项</h3></header><div class="card-body">' +
'<dl class="kv">' +
'<dt>邮件通知</dt><dd class="' + (h.mail === 'on' ? 'ok' : 'bad') + '">' +
(h.mail === 'on' ? '已启用(Resend)' : '未配置 —— 需写入 RESEND_API_KEY') + '</dd>' +
'<dt>验证码登录</dt><dd class="bad">不可用 —— Workers 无 TCP,发不了 SMTP</dd>' +
'<dt>官方后台</dt><dd>可用 —— <a href="./../sidebar/" target="_blank" rel="noopener">' + esc(location.origin) + '/sidebar/</a></dd>' +
'<dt>慢查询</dt><dd>跨境访问有 RTT 开销,评论列表为动态接口无法走 CDN 缓存</dd>' +
'</dl></div></section>';
}
// ============================================================ 视图:订阅源
// 数据来自 rss-robot-cf(api.200181.xyz)
async function viewFeeds() {
if (state.tab !== 'feeds') return;
$('#view').innerHTML =
head('订阅源管理', 'FEEDS', 'RSS 订阅源 · ' + RSS_API, '<button class="btn tiny" data-act="f-reload">刷新</button>');
let data;
try {
data = await rssApi('/api/feeds');
} catch (e) {
if (e.status === 401 || e.status === 403) {
$('#view').innerHTML = head('订阅源管理', 'FEEDS', '未授权') +
'<div class="empty">登录状态已失效 —— 请退出后重新登录后台。</div>';
return;
}
$('#view').innerHTML += '<div class="empty">加载失败:' + esc(e.message) + '</div>';
return;
}
const feeds = data.feeds || [];
state.feedsRows = feeds; // 编辑弹窗取数用
const rows = feeds.map((f) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(f.feedTitle || f.url) + '</span> ' +
'<span class="tx">' + esc(f.url) + '</span></span>' +
'<span class="c">' +
'<button class="btn tiny" data-act="f-edit" data-url="' + esc(f.url) + '">编辑</button>' +
'<button class="btn tiny" data-act="f-del" data-url="' + esc(f.url) + '">删除</button>' +
'</span>' +
'</div>').join('') || '<div class="empty">暂无订阅源</div>';
$('#view').innerHTML +=
'<section class="card"><header class="card-head"><h3>订阅源(' + feeds.length + ')</h3>' +
'<button class="btn tiny" data-act="f-add">添加</button></header>' +
'<div class="card-body"><div class="mini">' + rows + '</div></div></section>';
}
function addFeed() {
modal('添加订阅源',
'<label class="field"><span class="field-k">订阅地址</span><input id="fUrl" placeholder="https://example.com/feed"></label>' +
'<label class="field"><span class="field-k">名称(可选)</span><input id="fTitle" placeholder="博客名称"></label>' +
'<button class="btn primary block" id="fSave"><span>添加</span></button>');
$('#fSave').onclick = async () => {
const b = $('#fSave'); b.dataset.busy = '1';
try {
await rssApi('/api/feeds', { method: 'POST', body: { url: $('#fUrl').value.trim(), feedTitle: $('#fTitle').value.trim() } });
closeModal(); toast('✓ 已添加'); viewFeeds();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
function editFeed(url) {
const f = (state.feedsRows || []).filter((x) => x.url === url)[0];
if (!f) { toast('订阅源不存在,请刷新', true); return; }
modal('编辑订阅源',
'<label class="field"><span class="field-k">订阅地址</span><input id="feUrl" value="' + esc(f.url) + '"></label>' +
'<label class="field"><span class="field-k">名称</span><input id="feTitle" value="' + esc(f.feedTitle || '') + '"></label>' +
'<p style="margin:-6px 0 14px;font-size:11px;color:var(--muted)">改地址不会丢抓取记录 —— 服务端按原地址定位后更新。</p>' +
'<button class="btn primary block" id="feSave"><span>保存</span></button>');
$('#feSave').onclick = async () => {
const b = $('#feSave'); b.dataset.busy = '1';
try {
await rssApi('/api/feeds', {
method: 'POST',
body: { url: $('#feUrl').value.trim(), feedTitle: $('#feTitle').value.trim(), oldUrl: f.url },
});
closeModal(); toast('✓ 已保存'); viewFeeds();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
// ============================================================ 视图:友链
async function viewLinks() {
if (state.tab !== 'links') return;
$('#view').innerHTML =
head('友链管理', 'LINKS', '友链 · ' + RSS_API, '<button class="btn tiny" data-act="l-reload">刷新</button>');
let data;
let apps = [];
try {
data = await rssApi('/api/links?all=1');
try {
const ad = await rssApi('/api/link-apply/list');
apps = (ad.apps || []).filter((a) => a.status === 'pending');
} catch (e) { /* 申请列表加载失败不阻塞友链展示 */ }
} catch (e) {
if (e.status === 401 || e.status === 403) {
$('#view').innerHTML = head('友链管理', 'LINKS', '未授权') +
'<div class="empty">登录状态已失效 —— 请退出后重新登录后台。</div>';
return;
}
$('#view').innerHTML += '<div class="empty">加载失败:' + esc(e.message) + '</div>';
return;
}
state.linkApps = apps;
// 待审申请卡片
let appHtml = '';
if (apps.length) {
const appRows = apps.map((a) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(a.name) + '</span> ' +
'<span class="tx">' + esc(a.url) + (a.feed ? ' · RSS' : '') + (a.email ? ' · ' + esc(a.email) : '') + '</span>' +
(a.description ? '<span class="tx">' + esc(a.description) + '</span>' : '') +
'</span>' +
'<span class="c">' +
'<button class="btn tiny" data-act="l-ap-view" data-id="' + esc(a.id) + '">查看</button>' +
'<button class="btn tiny" data-act="l-ap-approve" data-id="' + esc(a.id) + '">通过</button>' +
'<button class="btn tiny" data-act="l-ap-reject" data-id="' + esc(a.id) + '">拒绝</button>' +
'<button class="btn tiny danger" data-act="l-ap-del" data-id="' + esc(a.id) + '">删除</button>' +
'</span>' +
'</div>').join('');
appHtml =
'<section class="card"><header class="card-head"><h3>待审申请(' + apps.length + ')</h3>' +
'<span class="tag warn">新</span></header>' +
'<div class="card-body"><div class="mini">' + appRows + '</div></div></section>';
}
const links = data.links || [];
state.linksRows = links;
const rows = links.map((l) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(l.name || l.url) + (l.hidden ? ' <span class="tag pink">隐藏</span>' : '') + '</span> ' +
'<span class="tx">' + esc(l.url) + (l.rss ? ' · RSS' : '') + '</span></span>' +
'<span class="c">' +
'<button class="btn tiny" data-act="l-edit" data-url="' + esc(l.url) + '">编辑</button>' +
'<button class="btn tiny" data-act="l-del" data-url="' + esc(l.url) + '">删除</button>' +
'</span>' +
'</div>').join('') || '<div class="empty">暂无友链</div>';
$('#view').innerHTML += appHtml +
'<section class="card"><header class="card-head"><h3>友链(' + links.length + ')</h3>' +
'<button class="btn tiny" data-act="l-add">添加</button></header>' +
'<div class="card-body"><div class="mini">' + rows + '</div></div></section>';
}
function viewLinkApply(id) {
const a = (state.linkApps || []).find((x) => x.id === id);
if (!a) return toast('✕ 申请不存在', true);
modal('友链申请 · ' + a.name,
'<div class="kv" style="font-size:13px;line-height:2">' +
'<div><b>名称:</b>' + esc(a.name) + '</div>' +
'<div><b>链接:</b><a href="' + esc(a.url) + '" target="_blank" rel="noopener">' + esc(a.url) + '</a></div>' +
'<div><b>订阅:</b>' + esc(a.feed || '—') + '</div>' +
'<div><b>邮箱:</b>' + esc(a.email) + '</div>' +
(a.description ? '<div><b>描述:</b>' + esc(a.description) + '</div>' : '') +
'<div><b>申请时间:</b>' + esc(a.appliedAt) + '</div>' +
'</div>' +
'<label class="field"><span class="field-k">反馈意见(拒绝时建议填写,将邮件告知申请人)</span>' +
'<textarea id="laFeedback" rows="2"></textarea></label>' +
'<div style="display:flex;gap:8px;margin-top:10px">' +
'<button class="btn primary" style="flex:1" id="laApprove">通过并加为友链</button>' +
'<button class="btn danger" style="flex:1" id="laReject">拒绝</button>' +
'</div>');
$('#laApprove').onclick = async () => {
try {
await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'approve', feedback: $('#laFeedback').value.trim() } });
closeModal(); toast('✓ 已通过并邮件通知申请人'); viewLinks();
} catch (e) { toast('✕ ' + e.message, true); }
};
$('#laReject').onclick = async () => {
try {
await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'reject', feedback: $('#laFeedback').value.trim() } });
closeModal(); toast('✓ 已拒绝并邮件告知申请人'); viewLinks();
} catch (e) { toast('✕ ' + e.message, true); }
};
}
function addLink() {
modal('添加友链',
'<label class="field"><span class="field-k">名称</span><input id="lName" placeholder="站点名称"></label>' +
'<label class="field"><span class="field-k">地址</span><input id="lUrl" placeholder="https://example.com"></label>' +
'<label class="field"><span class="field-k">描述(可选)</span><input id="lDesc" placeholder="一句话描述"></label>' +
'<button class="btn primary block" id="lSave"><span>添加</span></button>');
$('#lSave').onclick = async () => {
const b = $('#lSave'); b.dataset.busy = '1';
try {
await rssApi('/api/links', { method: 'POST', body: { name: $('#lName').value.trim(), url: $('#lUrl').value.trim(), description: $('#lDesc').value.trim() } });
closeModal(); toast('✓ 已添加'); viewLinks();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
function editLink(url) {
const l = (state.linksRows || []).filter((x) => x.url === url)[0];
if (!l) { toast('友链不存在,请刷新', true); return; }
modal('编辑友链',
'<label class="field"><span class="field-k">名称</span><input id="leName" value="' + esc(l.name || '') + '"></label>' +
'<label class="field"><span class="field-k">地址</span><input id="leUrl" value="' + esc(l.url) + '"></label>' +
'<label class="field"><span class="field-k">描述</span><input id="leDesc" value="' + esc(l.description || '') + '"></label>' +
'<label class="field"><span class="field-k">RSS 地址(可选)</span><input id="leRss" value="' + esc(l.rss || '') + '"></label>' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:18px">' +
'<input type="checkbox" id="leHidden"' + (l.hidden ? ' checked' : '') + ' style="width:auto">' +
'<span class="px" style="color:var(--muted)">隐藏(不在前台展示,保留数据)</span></label>' +
'<button class="btn primary block" id="leSave"><span>保存</span></button>');
$('#leSave').onclick = async () => {
const b = $('#leSave'); b.dataset.busy = '1';
try {
await rssApi('/api/links', {
method: 'POST',
body: {
name: $('#leName').value.trim(),
url: $('#leUrl').value.trim(),
description: $('#leDesc').value.trim(),
rss: $('#leRss').value.trim(),
hidden: $('#leHidden').checked,
oldUrl: l.url,
},
});
closeModal(); toast('✓ 已保存'); viewLinks();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
// ============================================================ 视图:文章编辑
//
// 全线走 Worker 的 /editor/* 反代:浏览器只跟 Worker 说话,编辑器令牌在 Worker 侧
// 注入,所以前端不需要任何额外凭据(复用后台已有的登录会话即可)。
//
// ★ 定位键用 id(content 下的目录名,全仓唯一),**不是 slug** ——
// 仓库里有 5 组 slug 撞名的文章,按 slug 打开会静默地打开/保存到另一篇文件上。
//
// ★ 交互形态对齐 write-server(用户用惯的那套):
// · 列表 = 卡片行:标题可点 + 草稿/已发布徽章,hover 出 预览/编辑/删除
// · 新建 = 直接进编辑器(不再弹窗),输入标题时自动生成时间戳 slug
// · 编辑器 = 紧凑表单(标题+slug 一行、日期+作者+布局+分类一行、标签一行)
// + 正文 + 右上角 保存 / 发布
/** 界面上可编辑的 front matter 字段(其余键一律只读展示、原样保留) */
const PO_FM_FIELDS = [
{ k: 'title', label: '标题' },
{ k: 'slug', label: 'slug(决定网址 /<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']);
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 {
d = await api('/editor/posts?' + qs({ q: po.q, page: po.page, perPage: po.perPage }));
} catch (e) {
if (state.tab !== 'posts') return;
$('#view').innerHTML = head('文章编辑', '', '') +
'<div class="empty"><b>加载不了文章列表</b>' + esc(e.message) + '</div>';
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 ? '<span class="tag warn">草稿</span>' : '<span class="tag ok">已发布</span>',
r.slugConflict
? '<span class="tag dang" title="slug 与另一篇文章重复:Hugo 用 /:slug,撞名时线上会有一篇被覆盖">URL 冲突</span>'
: '',
r.parseable === false
? '<span class="tag dang" title="front matter 有解析不了的内容:打开后只能改正文,字段保持原样">FM 异常</span>'
: '',
].filter(Boolean).join(' ');
const meta = [poFmtDate(r.date), (r.categories || []).join('、')].filter(Boolean).join(' · ');
return '<div class="po-row">' +
'<div class="po-main">' +
'<div class="po-line">' +
'<span class="po-t" data-act="po-open" data-id="' + esc(r.id) + '" title="' + esc(r.id) + '">' + esc(r.title) + '</span>' +
badges +
'</div>' +
'<div class="po-sub">' + esc(meta) + '</div>' +
'</div>' +
'<div class="po-acts">' +
(r.url ? '<a class="po-ic" href="' + esc(r.url) + '" target="_blank" rel="noopener" title="预览线上页面">' + ICONS.eye + '</a>' : '') +
'<button class="po-ic" type="button" data-act="po-open" data-id="' + esc(r.id) + '" title="编辑">' + ICONS.edit + '</button>' +
'<button class="po-ic danger" type="button" data-act="po-del" data-id="' + esc(r.id) + '" data-title="' + esc(r.title) + '" title="删除">' + ICONS.trash + '</button>' +
'</div>' +
'</div>';
}).join('');
$('#view').innerHTML =
head('文章编辑', '', '共 ' + nfmt(po.total) + ' 篇 · 直接改仓库 content/posts 下的 Markdown',
'<button class="btn tiny" data-act="po-git">发布 / 同步</button>' +
'<button class="btn primary tiny" data-act="po-new">+ 新建</button>') +
'<div class="ctool"><div class="grow">' +
'<input id="poq" type="search" placeholder="搜索标题、slug、分类、标签、作者…" value="' + esc(po.q) + '">' +
'</div></div>' +
(po.rows.length
? '<div class="po-list">' + rows + '</div>' + pagerPo()
: '<div class="empty"><b>' + (po.q ? '没有匹配的文章' : '还没有文章') + '</b>' +
(po.q ? '换个关键词试试' : '点右上角「+ 新建」写第一篇') + '</div>');
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 '<div class="pager">' +
'<button class="btn tiny" data-act="po-prev"' + (po.page <= 1 ? ' disabled' : '') + '>‹ 上一页</button>' +
'<span class="info">第 <b>' + po.page + '</b> / ' + po.totalPages + ' 页 · 共 <b>' + nfmt(po.total) + '</b> 篇</span>' +
'<button class="btn tiny" data-act="po-next"' + (po.page >= po.totalPages ? ' disabled' : '') + '>下一页 ›</button>' +
'</div>';
}
async function openPost(id) {
$('#view').innerHTML = '<div class="loading">加载中…</div>';
let post;
try {
post = await api('/editor/posts/' + encodeURIComponent(id));
} catch (e) {
if (state.tab !== 'posts') return;
$('#view').innerHTML = head('打开失败', '', esc(id)) +
'<div class="empty"><b>' + esc(e.message) + '</b>' +
'<p class="po-note" style="margin-top:10px">文章还在服务器上没动过,直接重试即可;连续失败多半是本机到 Cloudflare 这一跳不稳。</p>' +
'</div>' +
'<div style="margin-top:14px">' +
'<button class="btn primary" data-act="po-open" data-id="' + esc(id) + '">重试</button> ' +
'<button class="btn" data-act="po-back">返回列表</button>' +
'</div>';
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);
// 编辑角色:作者字段只读。后端本来就不采纳它传的 author/author_id
// (见 editor-api/src/posts.mjs),这里显示自己的名字只是为了别让人白填。
const roAuthor = isEditor();
const field = (k, label, cls, ph, readonly) => {
let v = orig[k] == null ? '' : orig[k];
if (readonly && !v) v = (state.me && state.me.name) || '';
return '<label class="field ' + (cls || '') + '"><span class="field-k">' + esc(label) + '</span>' +
'<input data-fm="' + k + '" autocomplete="off" spellcheck="false" placeholder="' + esc(ph || '') + '"' +
(readonly ? ' readonly title="编辑角色只能署自己的名字"' : '') +
' value="' + esc(v) + '"></label>';
};
const metaForm =
'<div class="po-frow">' +
field('title', '标题', 'po-f-title', '文章标题') +
field('slug', 'Slug(自动生成)', 'po-f-slug', 'YYYYMMDDHHMMSS') +
'</div>' +
'<div class="po-frow4">' +
field('date', '日期', 'po-f-date') +
field('author', '作者', 'po-f-author', '', roAuthor) +
field('layout', '布局', 'po-f-layout') +
'<label class="field po-f-cats"><span class="field-k">分类(逗号分隔)</span>' +
'<input id="poCats" autocomplete="off" value="' + esc(arrText(orig.categories)) + '"></label>' +
'</div>' +
'<label class="field"><span class="field-k">标签(逗号分隔)</span>' +
'<input id="poTags" autocomplete="off" value="' + esc(arrText(orig.tags)) + '"></label>' +
field('description', '摘要(可选)') +
'<div class="po-folder" id="poFolder"></div>';
const extraKeys = Object.keys(orig).filter((k) => !PO_KNOWN.has(k));
const extra = extraKeys.length
? '<div class="po-ro"><span class="po-ro-k">其他字段(只读保留,不会被动过)</span>' +
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 '<div class="po-ro-i"><code>' + esc(k) + '</code><span class="t">' + esc(t) + '</span>' +
'<span class="v">' + esc(truncate(s.replace(/\s+/g, ' '), 70)) + '</span></div>';
}).join('') + '</div>'
: '';
const warn = [
p.slugConflict
? '<div class="alert">这篇文章的 slug「' + esc(p.slug) + '」与另一篇重复。Hugo 用 /:slug 生成网址,' +
'撞名时线上只会剩一篇 —— 改 slug 前先确认另一篇要不要一起处理。</div>'
: '',
fmBroken
? '<div class="alert">front matter 没法完整解析(' + esc(p.frontMatterError || '没有 front matter') + ')。' +
'为了不写坏这篇,字段区已停用,保存时只会动正文。</div>'
: '',
].filter(Boolean).join('');
$('#view').innerHTML =
head(ed.isNew ? '新建文章' : (p.title || ed.id), '', ed.isNew ? '写完点「保存」才真正落盘' : ('正文 ' + String(p.content || '').length + ' 字 · ' + esc(p.id)),
'<button class="btn tiny" data-act="po-back">← 列表</button>' +
(ed.isNew ? '' :
'<button class="btn tiny" data-act="po-reload-post">还原改动</button>' +
'<button class="btn tiny danger" data-act="po-del">删除</button>')) +
(!ed.isNew && p.url ? '<div class="po-url">线上地址 <a class="lk" href="' + esc(p.url) + '" target="_blank" rel="noopener">' + esc(p.url) + '</a>' +
'<span class="po-file" title="实际写入的文件">' + esc(p.filePath) + '</span></div>' : '') +
warn +
'<div class="po-grid">' +
'<div class="po-maincol">' +
'<section class="card"><header class="card-head"><h3>文章信息</h3></header><div class="card-body">' +
metaForm + extra +
'</div></section>' +
'<section class="card" style="margin-top:14px">' +
'<header class="card-head"><h3>正文 · MARKDOWN</h3>' +
'<button class="btn tiny" data-act="po-img" type="button">插入图片</button>' +
'<input type="file" id="poImgFile" accept="image/*" multiple style="display:none">' +
'<span class="po-hint">可直接 Ctrl+V 粘贴截图,自动存到文章目录</span></header>' +
'<div class="card-body">' +
'<textarea id="poBody" class="po-body" spellcheck="false" placeholder="开始撰写…"></textarea>' +
'<div class="po-foot">' +
'<label class="po-ck"><input type="checkbox" id="poDraft" style="width:auto"' + (orig.draft === true ? ' checked' : '') + '><span>草稿(不发布到线上)</span></label>' +
'<button class="btn tiny" data-act="po-save" type="button">保存</button>' +
'<button class="btn primary tiny" data-act="po-publish" type="button">发布</button>' +
'<span id="poStat">尚未修改</span>' +
'<span class="po-kbd">Ctrl / ⌘ + S 保存</span></div>' +
'</div></section>' +
'</div>' +
'<aside class="po-side">' + shortcutPanelHtml() + '</aside>' +
'</div>';
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 '<h3 class="po-side-t">快捷插入</h3>' +
'<div class="po-sc">' +
Object.keys(PO_SHORTCUTS).map((label) =>
'<button type="button" class="po-sc-b" data-sc="' + esc(label) + '"><span class="g">' + esc(PO_SHORTCUT_GLYPH[label] || '·') + '</span>' + esc(label) + '</button>'
).join('') +
'</div>' +
'<div class="po-note"><h4>说明</h4><ul>' +
'<li>选中文字后点按钮可包裹</li>' +
'<li>未选中则插入模板 + 占位文字</li>' +
'<li>视频需替换 src 中的路径</li>' +
'<li>B站/YouTube 替换 BV号/ID</li>' +
'</ul></div>';
}
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;
}
// 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 = '![](' + d.fileName + ')';
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();
}
/** 发布 / 同步:把改动提交并推到主仓(会触发线上构建) */
async function openGitModal() {
modal('发布与同步', '<div class="loading">读取仓库状态…</div>');
let st;
try {
st = await api('/editor/git/status');
} catch (e) {
$('#modalBody').innerHTML = '<div class="empty"><b>读不到仓库状态</b>' + esc(e.message) + '</div>';
return;
}
const files = (st.files || []).map((f) => '<div class="po-ro-i"><code>' + esc(f.slice(0, 2).trim() || '·') + '</code>' +
'<span class="v">' + esc(f.slice(2).trim().replace(/^"|"$/g, '')) + '</span></div>').join('') ||
'<div class="po-ro-i"><span class="v" style="color:var(--muted)">没有改动</span></div>';
$('#modalBody').innerHTML =
'<dl class="kv">' +
'<dt>分支</dt><dd>' + esc(st.branch) + '</dd>' +
'<dt>改动</dt><dd>' + (st.dirty ? '<span style="color:var(--pend-fg)">' + nfmt(st.changed) + ' 个文件待发布</span>' : '工作区干净') + '</dd>' +
'<dt>最近提交</dt><dd class="lk" style="font-size:12px">' + esc(st.lastCommit || '—') + '</dd>' +
'</dl>' +
'<div style="margin:14px 0 6px;font-size:12px;color:var(--muted)">待发布文件' +
(st.scoped ? '(只列你自己名下的文章)' : '') + '</div>' +
'<div class="po-ro" style="max-height:200px;overflow:auto">' + files + '</div>' +
'<label class="field" style="margin-top:16px"><span class="field-k">提交说明(留空自动生成)</span>' +
'<input id="poMsg" autocomplete="off" placeholder="例如:修几处错别字"></label>' +
'<div class="alert" style="margin-bottom:0">' +
(st.scoped
? '发布 = 只提交**你自己名下的文章目录**(别人还没发布的改动不会被顺走),再 commit + push 到主仓触发线上构建。'
: '发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。') +
'</div>' +
'<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px">' +
'<button class="btn" id="poSync"><span>先同步远程</span></button>' +
'<button class="btn primary" id="poPub"><span>发布到线上</span></button>' +
'</div>';
$('#poSync').onclick = async () => {
const b = $('#poSync');
b.dataset.busy = '1';
try {
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 = '尚未修改';
}
// ============================================================ 侧边栏抽屉 / 主题
function closeSidebar() {
const sb = $('#sidebar'), sc = $('#scrim');
if (sb) sb.classList.remove('open');
if (sc) sc.hidden = true;
}
function toggleSidebar() {
const sb = $('#sidebar'), sc = $('#scrim');
if (!sb) return;
const open = sb.classList.toggle('open');
if (sc) sc.hidden = !open;
}
function applyTheme(t) {
if (t === 'dark') document.documentElement.dataset.theme = 'dark';
else delete document.documentElement.dataset.theme;
const b = $('#themeBtn');
if (b) b.innerHTML = ICONS.moon + '<span>' + (t === 'dark' ? '浅色模式' : '深色模式') + '</span>';
}
function toggleTheme() {
const cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
const next = cur === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('atk-theme', next); } catch (e) {}
applyTheme(next);
}
// ============================================================ 视图:证书管家
//
// 数据全部来自 /api/v2/ssl/*(Worker 侧,鉴权放行 admin + ssl)。
// 国内机模式下同源请求会被本机 editor-api 反代到 Cloudflare
// (见 editor-api/server.mjs 的 SSL_PATH_PREFIXES),前端代码完全一致。
//
// ★ 前端**不做任何权限判断**:能不能用由服务端的 auth() 决定,
// 这里只负责「服务端 403 了就别把界面留在半死状态」。
const SSL_ACCESS_TYPES = [
{ v: 'tencentcloud', label: '腾讯云 CAM(DNSPod DNS-01)', fields: [['secretId', 'SecretId'], ['secretKey', 'SecretKey']] },
{ v: 'cloudflare', label: 'Cloudflare API Token(DNS + 部署)', fields: [['apiToken', 'API Token']] },
{ v: 'dogecloud', label: '多吉云 AK/SK(CDN 证书部署)', fields: [['accessKey', 'AccessKey'], ['secretKey', 'SecretKey']] },
{ v: '1panel', label: '1Panel 面板(API Key)', fields: [['serverUrl', '面板地址'], ['apiKey', 'API Key']] },
{ v: 'acme-eab', label: 'ACME EAB(LiteSSL / ZeroSSL)', fields: [['directoryUrl', 'Directory URL'], ['eabKid', 'EAB Kid'], ['eabHmacKey', 'EAB HmacKey']] },
];
const SSL_DEPLOYS = [
{ v: 'dogecloud', label: '多吉云 CDN' },
{ v: '1panel', label: '1Panel' },
{ v: 'tencentcloud-eo', label: '腾讯云 EdgeOne' },
];
function sslApi(path, opt) {
return api('/ssl' + path, opt);
}
async function viewSSL() {
const v = $('#view');
v.innerHTML = '<div class="loading">正在读证书状态…</div>';
const ov = await sslApi('/overview');
if (state.tab !== 'ssl') return;
state.ssl.ov = ov;
state.ssl.accessItems = null; // 每次进这一页都重新拉,免得拿着过期列表
v.innerHTML = head('证书管家', 'CERT KEEPER',
'域名 ' + ov.domains.length + ' 个 · 提醒阈值 ' + ov.notify.daysBefore + ' 天',
'<button class="btn tiny" data-act="ssl-check"><span>立即检查</span></button>' +
'<button class="btn tiny" data-act="ssl-notify"><span>发提醒邮件</span></button>' +
'<button class="btn tiny" data-act="ssl-add-domain"><span>+ 域名</span></button>' +
(ov.mailEnabled ? '' : '<span class="tag warn">邮件未配置</span>')) +
sslWarnBanner(ov) +
'<div id="ssl-list">' + sslDomainTable(ov) + '</div>' +
'<div class="grid2" style="margin-top:14px">' +
'<section class="card"><header class="card-head"><h3>凭据(密钥只显示尾 4 位)</h3>' +
'<button class="btn tiny" style="margin-left:auto" data-act="ssl-access-new"><span>+ 凭据</span></button>' +
'</header><div class="card-body" id="ssl-access"><div class="loading">加载中…</div></div></section>' +
'<section class="card"><header class="card-head"><h3>最近执行记录</h3>' +
'<button class="btn tiny" style="margin-left:auto" data-act="ssl-log-clear"><span>清空</span></button>' +
'</header><div class="card-body" id="ssl-log"><div class="loading">加载中…</div></div></section>' +
'</div>' +
'<section class="card" style="margin-top:14px"><header class="card-head"><h3>通知设置</h3></header>' +
'<div class="card-body"><div id="ssl-notify-cfg">' + sslNotifyForm(ov) + '</div></div></section>';
await Promise.all([loadSslAccess(), loadSslLog()]);
}
/** 顶部横幅:把最需要行动的信息顶到最上面 */
function sslWarnBanner(ov) {
const expiring = ov.domains.filter((d) => d.level === 'danger' || d.level === 'warn');
const noCert = ov.domains.filter((d) => d.hasCert === false && !d.disabled);
if (!expiring.length && !noCert.length) return '';
const parts = [];
if (expiring.length) {
parts.push('<b>' + expiring.length + '</b> 个域名即将或已经过期:' +
expiring.map((d) => esc(d.name) + '(' + (d.daysLeft == null ? '无记录' : d.daysLeft + ' 天') + ')').join('、'));
}
if (noCert.length) {
parts.push('<b>' + noCert.length + '</b> 个域名还没有证书记录(只影响监控,不影响线上):' +
noCert.map((d) => esc(d.name)).join('、'));
}
return '<div class="alert" style="background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
parts.join('<br>') + '</div>';
}
function sslLevelTag(d) {
if (d.disabled) return '<span class="tag line">已停用</span>';
if (!d.hasCert) return '<span class="tag warn">无记录</span>';
if (d.level === 'danger') return '<span class="tag dang">' + (d.daysLeft < 0 ? '已过期' : '紧急') + '</span>';
if (d.level === 'warn') return '<span class="tag warn">临期</span>';
return '<span class="tag ok">正常</span>';
}
function sslDomainTable(ov) {
if (!ov.domains.length) {
return '<div class="empty"><b>还没有配置任何域名</b>' +
'点右上角「+ 域名」加第一个。域名不会立刻签发证书,只是纳入监控与后续自动化。</div>';
}
const rows = ov.domains.map((d) =>
'<tr>' +
'<td><b>' + esc(d.name) + '</b>' + sslLevelTag(d) +
'<div class="lk">' + esc(d.san.join(' · ')) + '</div></td>' +
'<td class="num">' + (d.daysLeft == null ? '—' : (d.daysLeft < 0 ? '<span style="color:var(--danger)">' + d.daysLeft + '</span>' : d.daysLeft)) + '</td>' +
'<td>' + esc(d.issuer || '—') + '<div class="lk">' + esc(d.expireText || '—') + '</div></td>' +
'<td>' + esc(d.dns || '—') + '</td>' +
'<td>' + (d.deploy.length ? d.deploy.map((t) => '<span class="tag line">' + esc(t) + '</span>').join(' ') : '<span style="color:var(--muted)">—</span>') + '</td>' +
'<td style="white-space:nowrap">' +
'<button class="btn tiny" data-act="ssl-probe" data-host="' + esc(d.san.find((s) => !s.startsWith('*.')) || d.name) + '">实测</button> ' +
'<button class="btn tiny" data-act="ssl-import" data-domain="' + esc(d.name) + '">登记</button> ' +
'<button class="btn tiny" data-act="ssl-edit-domain" data-domain="' + esc(d.name) + '">编辑</button>' +
'</td>' +
'</tr>'
).join('');
return '<div class="tblwrap"><table><thead><tr>' +
'<th>域名 / SAN</th><th class="num">剩余天</th><th>签发方 / 到期</th><th>DNS 凭据</th><th>部署目标</th><th></th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>' +
(ov.orphans.length
? '<div class="alert" style="margin-top:12px">库里有 ' + ov.orphans.length +
' 条孤儿证书记录(域名已从配置里删掉):' + esc(ov.orphans.join('、')) +
'。不影响使用,要清理点上面的「登记」旁的删除,或忽略即可。</div>'
: '');
}
async function loadSslAccess() {
const box = $('#ssl-access');
if (!box) return;
const d = await sslApi('/access');
if (state.tab !== 'ssl' || !document.body.contains(box)) return;
const items = d.items || [];
state.ssl.accessItems = items; // 弹窗要用(凭据下拉 + 脱敏回显)
if (!items.length) {
box.innerHTML = '<div class="empty" style="padding:24px 12px"><b>还没有凭据</b>' +
'DNS-01 需要一把能改 DNS 的钥匙(腾讯云 CAM / Cloudflare Token),部署需要多吉云和 1Panel 的。' +
'密钥用 TOKEN_SECRET 派生密钥 AES-GCM 加密后落 KV,列表只回显尾 4 位。</div>';
return;
}
box.innerHTML = '<div class="mini">' + items.map((a) =>
'<div class="mini-i">' +
'<span class="b"><span class="nm">' + esc(a.name) + '</span>' +
' <span class="tag line">' + esc(a.type || '未知') + '</span>' +
(a.unreadable ? ' <span class="tag dang">读不出</span>' : '') +
'<div class="lk" style="font-size:11px;color:var(--muted);margin-top:2px">' +
esc(Object.entries(a.fields).map(([k, v]) => k + '=' + v).join(' ') || (a.note || '(未填密钥)')) +
'</div></span>' +
'<button class="btn tiny" data-act="ssl-access-edit" data-name="' + esc(a.name) + '">编辑</button> ' +
'<button class="btn tiny" data-act="ssl-access-del" data-name="' + esc(a.name) + '">删除</button>' +
'</div>').join('') + '</div>';
}
/** 打开弹窗前确保凭据列表已加载(正常路径下 viewSSL 已经拉过,这里只是兜底) */
async function ensureAccessLoaded() {
if (state.ssl.accessItems) return;
const d = await sslApi('/access');
state.ssl.accessItems = d.items || [];
}
async function loadSslLog() {
const box = $('#ssl-log');
if (!box) return;
const d = await sslApi('/log?limit=40');
if (state.tab !== 'ssl' || !document.body.contains(box)) return;
const items = d.items || [];
box.innerHTML = items.length
? '<div class="mini">' + items.map((l) => {
const color = l.level === 'error' ? 'var(--danger)' : l.level === 'warn' ? 'var(--warning)' : 'var(--muted)';
return '<div class="mini-i"><span class="n" style="width:76px">' +
esc(new Date(l.at + 8 * 3600e3).toISOString().slice(5, 16).replace('T', ' ')) + '</span>' +
'<span class="b"><span class="tx" style="color:' + color + '">[' + esc(l.action) + ']</span> ' +
esc(l.message) + '</span></div>';
}).join('') + '</div>'
: '<div class="empty" style="padding:24px 12px">还没有执行记录</div>';
}
function sslNotifyForm(ov) {
return '<div style="display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap">' +
'<label class="field" style="flex:1;min-width:260px;margin:0"><span class="field-k">提醒收件人(逗号或换行分隔)</span>' +
'<input id="sslNotifyEmails" value="' + esc(ov.notify.emails.join(', ')) + '" placeholder="you@example.com"></label>' +
'<label class="field" style="width:150px;margin:0"><span class="field-k">提前天数</span>' +
'<input id="sslNotifyDays" type="number" min="1" max="365" value="' + ov.notify.daysBefore + '"></label>' +
'<button class="btn primary" data-act="ssl-notify-save"><span>保存</span></button>' +
'</div>';
}
/** 域名的编辑弹窗。新建时 opts.name 为空 */
function sslDomainModal(name) {
const ov = state.ssl.ov || { domains: [], notify: { emails: [], daysBefore: 30 } };
const cur = (ov.domains || []).find((d) => d.name === name) || {
name: '', san: [], dns: '', deploy: [], dogecloud_domains: [], one_panel_sites: [], disabled: false,
};
const isNew = !name;
const accessNames = (state.ssl.accessItems || []).map((a) => a.name);
if (!accessNames.length) {
modal('先建凭据', '<p style="margin-top:0">配域名之前得先有 DNS 凭据 —— DNS-01 验证要往你的域名下写 TXT 记录。' +
'先去「凭据」那一栏加一把腾讯云 CAM 或 Cloudflare Token。</p>' +
'<div style="text-align:right"><button class="btn" data-act="modal-close"><span>知道了</span></button></div>');
return;
}
const accessOpts = accessNames.map((n) =>
'<option value="' + esc(n) + '"' + (cur.dns === n ? ' selected' : '') + '>' + esc(n) + '</option>').join('');
modal(isNew ? '新增域名' : '编辑「' + name + '」',
'<label class="field"><span class="field-k">主域名</span>' +
'<input id="sdName" value="' + esc(cur.name) + '" placeholder="usj.cc"' + (isNew ? '' : ' readonly') + '></label>' +
'<label class="field"><span class="field-k">SAN(证书覆盖的域名,逗号分隔;泛域名写 *.usj.cc)</span>' +
'<input id="sdSan" value="' + esc((cur.san || []).join(', ')) + '" placeholder="usj.cc, *.usj.cc"></label>' +
'<label class="field"><span class="field-k">DNS 凭据(负责写 _acme-challenge 记录)</span>' +
'<select id="sdDns">' + accessOpts + '</select></label>' +
'<label class="field"><span class="field-k">部署目标</span>' +
SSL_DEPLOYS.map((t) =>
'<label style="display:inline-flex;align-items:center;gap:5px;margin-right:14px;font-size:12px">' +
'<input type="checkbox" id="sdDeploy_' + t.v + '"' + ((cur.deploy || []).includes(t.v) ? ' checked' : '') +
' style="width:auto">' + esc(t.label) + '</label>').join('') +
'</label>' +
'<label class="field"><span class="field-k">多吉云 CDN 要绑的域名(逗号分隔,留空 = 不部署到多吉云)</span>' +
'<input id="sdDoge" value="' + esc((cur.dogecloud_domains || []).join(', ')) + '" placeholder="usj.cc"></label>' +
'<label class="field"><span class="field-k">1Panel 站点名(逗号分隔,留空 = 不部署到 1Panel)</span>' +
'<input id="sdPanel" value="' + esc((cur.one_panel_sites || []).join(', ')) + '" placeholder="artalk.usj.cc, openlist.usj.cc"></label>' +
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin-bottom:16px">' +
'<input type="checkbox" id="sdOff"' + (cur.disabled ? ' checked' : '') + ' style="width:auto">' +
'停用(保留配置,不参与检查与续期)</label>' +
'<div style="display:flex;gap:10px;justify-content:flex-end">' +
(isNew ? '' : '<button class="btn danger" data-act="ssl-del-domain" data-domain="' + esc(name) + '"><span>删除</span></button>') +
'<button class="btn" data-act="modal-close"><span>取消</span></button>' +
'<button class="btn primary" id="sdSave"><span>保存</span></button>' +
'</div>');
$('#sdSave').onclick = async () => {
const domains = JSON.parse(JSON.stringify((state.ssl.ov && state.ssl.ov.domains) || []));
const clean = (s) => String(s || '').split(/[,,\n]/).map((x) => x.trim()).filter(Boolean);
const next = {
name: $('#sdName').value.trim().toLowerCase(),
san: clean($('#sdSan').value).map((s) => s.toLowerCase()),
dns: $('#sdDns').value,
deploy: SSL_DEPLOYS.filter((t) => $('#sdDeploy_' + t.v).checked).map((t) => t.v),
dogecloud_domains: clean($('#sdDoge').value),
one_panel_sites: clean($('#sdPanel').value),
...($('#sdOff').checked ? { disabled: true } : {}),
};
if (!next.name) { toast('✕ 主域名要填', true); return; }
if (!next.san.length) next.san = [next.name];
const i = domains.findIndex((d) => d.name === name || d.name === next.name);
if (i >= 0) domains[i] = next; else domains.push(next);
const b = $('#sdSave'); b.dataset.busy = '1';
try {
await sslApi('/config', {
method: 'POST',
body: { version: 1, notify: state.ssl.ov.notify, domains },
});
closeModal(); toast('✓ 已保存');
await viewSSL();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
/** 凭据的新建 / 编辑弹窗 */
function sslAccessModal(name) {
const cur = (state.ssl.accessItems || []).find((a) => a.name === name);
const isNew = !name;
const type = cur ? cur.type : 'tencentcloud';
const def = SSL_ACCESS_TYPES.find((t) => t.v === type) || SSL_ACCESS_TYPES[0];
const typeOpts = SSL_ACCESS_TYPES.map((t) =>
'<option value="' + t.v + '"' + (type === t.v ? ' selected' : '') + '>' + esc(t.label) + '</option>').join('');
const fieldHtml = (fields) => fields.map(([k, label]) => {
const shown = cur && cur.fields && cur.fields[k] ? cur.fields[k] : '';
return '<label class="field"><span class="field-k">' + esc(label) + '</span>' +
'<input data-field="' + esc(k) + '" value="' + esc(shown) + '" autocomplete="off" spellcheck="false"' +
(cur ? ' placeholder="留空则不改(现在:' + esc(shown || '空') + ')"' : '') + '></label>';
}).join('');
modal(isNew ? '新增凭据' : '编辑凭据「' + name + '」',
'<label class="field"><span class="field-k">凭据名(域名配置里引用的就是这个名字)</span>' +
'<input id="saName" value="' + esc(name || '') + '"' + (isNew ? '' : ' readonly') + ' placeholder="tencent-usj"></label>' +
'<label class="field"><span class="field-k">类型</span>' +
'<select id="saType">' + typeOpts + '</select></label>' +
'<div id="saFields">' + fieldHtml(def.fields) + '</div>' +
'<label class="field"><span class="field-k">备注(可选)</span>' +
'<input id="saNote" value="' + esc(cur ? cur.note : '') + '" placeholder="如:小赵腾讯云 / 团团腾讯云"></label>' +
'<div class="alert" style="background:var(--surface);border-color:var(--line);color:var(--muted)">' +
'密钥用 TOKEN_SECRET 派生的密钥 AES-GCM 加密后才写进 KV,列表接口只回显尾 4 位。' +
'编辑时留空的字段保持不变 —— 不用为了改个备注重新粘一遍密钥。</div>' +
'<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">' +
'<button class="btn" data-act="modal-close"><span>取消</span></button>' +
'<button class="btn primary" id="saSave"><span>保存</span></button>' +
'</div>');
const rerenderFields = () => {
const t = SSL_ACCESS_TYPES.find((x) => x.v === $('#saType').value) || SSL_ACCESS_TYPES[0];
const keep = {};
$$('#saFields [data-field]').forEach((i) => { keep[i.dataset.field] = i.value; });
$('#saFields').innerHTML = t.fields.map(([k, label]) =>
'<label class="field"><span class="field-k">' + esc(label) + '</span>' +
'<input data-field="' + esc(k) + '" value="' + esc(keep[k] || (cur && cur.fields && cur.fields[k]) || '') +
'" autocomplete="off" spellcheck="false"></label>').join('');
};
$('#saType').onchange = rerenderFields;
$('#saSave').onclick = async () => {
const fields = {};
$$('#saFields [data-field]').forEach((i) => {
const v = i.value.trim();
// 回显的是脱敏值,没动过就丢掉(服务端也认这个规则,双保险)
if (v && !v.includes('****')) fields[i.dataset.field] = v;
});
const body = {
name: $('#saName').value.trim(),
type: $('#saType').value,
note: $('#saNote').value.trim(),
fields,
};
if (!body.name) { toast('✕ 凭据名要填', true); return; }
const b = $('#saSave'); b.dataset.busy = '1';
try {
await sslApi('/access', { method: 'POST', body });
closeModal(); toast('✓ 已保存');
await viewSSL();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
/** 登记证书(手工粘贴 PEM) */
function sslImportModal(domain) {
modal('登记证书' + (domain ? ':' + domain : ''),
'<label class="field"><span class="field-k">域名</span>' +
'<input id="siDomain" value="' + esc(domain || '') + '" placeholder="usj.cc"></label>' +
'<label class="field"><span class="field-k">证书链 PEM(fullchain)</span>' +
'<textarea id="siCert" style="min-height:110px;font-family:ui-monospace,Consolas,monospace;font-size:11px" ' +
'placeholder="-----BEGIN CERTIFICATE-----&#10;..."></textarea></label>' +
'<label class="field"><span class="field-k">私钥 PEM(可留空,留空则只做监控不参与部署)</span>' +
'<textarea id="siKey" style="min-height:90px;font-family:ui-monospace,Consolas,monospace;font-size:11px" ' +
'placeholder="-----BEGIN PRIVATE KEY-----&#10;..."></textarea></label>' +
'<label class="field"><span class="field-k">签发方(可选,如 LiteSSL / Let\'s Encrypt)</span>' +
'<input id="siIssuer" placeholder="LiteSSL"></label>' +
'<button class="btn primary block" id="siSave"><span>保存(加密存 KV)</span></button>');
$('#siSave').onclick = async () => {
const b = $('#siSave'); b.dataset.busy = '1';
try {
await sslApi('/cert/import', {
method: 'POST',
body: {
domain: $('#siDomain').value.trim(),
cert: $('#siCert').value,
key: $('#siKey').value,
issuer: $('#siIssuer').value.trim(),
},
});
closeModal(); toast('✓ 已登记');
await viewSSL();
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
};
}
/** 实测结果弹窗 —— 结构化展示,别再让用户去 openssl */
function sslProbeModal(host, r) {
const dl = (ms) => ms == null ? '—' : new Date(ms + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' ');
if (!r.ok) {
modal('实测 ' + host,
'<div class="alert">握手失败:' + esc(r.error || '未知原因') + '</div>' +
'<p style="color:var(--muted);font-size:12px;margin-bottom:16px">如果这里是超时,也可能是对方 443 没开或者被墙 —— 不一定是证书问题。' +
'Worker 从 Cloudflare 的边缘节点出去,和你在本地看到的未必一样。</p>' +
'<div style="text-align:right"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
return;
}
modal('实测 ' + host,
'<dl class="kv">' +
'<dt>剩余天数</dt><dd class="' + (r.daysLeft != null && r.daysLeft > 30 ? 'ok' : 'bad') + '">' +
(r.daysLeft == null ? '—' : r.daysLeft + ' 天') + '</dd>' +
'<dt>到期时间</dt><dd>' + esc(r.notAfterText || '—') + '</dd>' +
'<dt>签发方</dt><dd>' + esc(r.issuer || '—') + '</dd>' +
'<dt>主体</dt><dd>' + esc(r.subject || '—') + '</dd>' +
'<dt>覆盖域名</dt><dd>' + esc((r.altNames || []).join(' · ') || '—') + '</dd>' +
'</dl>' +
(r.error ? '<div class="alert" style="margin-top:14px;background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
esc(r.error) + '</div>' : '') +
'<div style="text-align:right;margin-top:16px"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
}
// ============================================================ 事件委托
document.addEventListener('click', async (e) => {
const el = e.target.closest('[data-act]');
if (!el) return;
const act = el.dataset.act;
// ★ 不能无脑 parseInt:友链申请的 id 是字符串(la + base36,如 lamus5uptzcdam),
// parseInt 会得到 NaN,序列化成 JSON 变成 null —— 后端只看到空 id,一律 400
// 「id and action required」,表现就是「查看/通过/拒绝点了没反应」。
// 规矩:纯数字才转数字,其余原样保留。
const rawId = el.dataset.id || '';
const id = /^\d+$/.test(rawId) ? parseInt(rawId, 10) : rawId;
// --- 通用
if (act === 'modal-close') { closeModal(); return; }
if (act === 'menu') { toggleSidebar(); return; }
if (act === 'theme') { toggleTheme(); return; }
if (act === 'peek-pass') {
// 登录页的「显示密码」:手机上输长密码很容易打错,看得见才好排查
const input = $('#lgPass');
const on = input.type === 'password';
input.type = on ? 'text' : 'password';
el.setAttribute('aria-pressed', String(on));
el.setAttribute('aria-label', on ? '隐藏密码' : '显示密码');
el.style.color = on ? 'var(--accent)' : '';
input.focus();
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 === 'relogin') {
// 从国内机模式切回完整后台:评论/订阅仍在 Cloudflare,得走那边的会话
state.direct = false;
$('#app').hidden = true;
showGate();
setTimeout(() => toast('完整后台需经 Cloudflare 登录,国内访问这一跳可能较慢'), 200);
return;
}
if (act === 'cn-line') {
// 一键跳国内机后台:先向 Worker 要一张「免登录票」(Worker 用共享令牌
// 签名,国内机验签后直接发会话 Cookie),失败就退回普通地址手动登录。
toast('正在打开国内线路…');
let url = 'https://writeapi.usj.cc/admin/';
try {
const d = await api('/editor/handoff');
if (d && d.url) url = d.url;
} catch (e) { /* 拿不到票就直开,让用户手动登录 */ }
window.open(url, '_blank', 'noopener');
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; }
if (act === 'c-next') { state.c.offset += state.c.limit; await loadComments(); return; }
if (act === 'c-toggle') {
if (state.openReps.has(id)) state.openReps.delete(id); else state.openReps.add(id);
await loadComments();
return;
}
if (act === 'c-approve' || act === 'c-pin' || act === 'c-fold') {
const body = {};
if (act === 'c-approve') body.is_pending = false;
if (act === 'c-pin') body.is_pinned = el.dataset.v === '1';
if (act === 'c-fold') body.is_collapsed = el.dataset.v === '1';
el.dataset.busy = '1';
try {
await api('/comments/' + id, { method: 'PUT', body: body });
toast('✓ 已更新 #' + id);
await loadComments();
} catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; }
return;
}
if (act === 'c-del') {
confirmBox('删除评论 #' + id, '删除后前台不再显示(软删除,数据仍在库里)。确定继续?', async () => {
await api('/comments/' + id, { method: 'DELETE' });
toast('✓ 已删除 #' + id);
await loadComments();
});
return;
}
// --- 页面
if (act === 'p-prev') { state.p.offset = Math.max(0, state.p.offset - state.p.limit); go('pages'); return; }
if (act === 'p-next') { state.p.offset += state.p.limit; go('pages'); return; }
// --- 用户
if (act === 'u-new') { newUser(); return; }
if (act === 'u-reload') { el.dataset.busy = '1'; try { await loadUsers(); } finally { delete el.dataset.busy; } return; }
if (act === 'u-prev') { state.u.offset = Math.max(0, state.u.offset - state.u.limit); await loadUsers(); return; }
if (act === 'u-next') { state.u.offset += state.u.limit; await loadUsers(); return; }
if (act === 'u-edit') { editUser(id); return; }
// --- 站点
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; }
if (act === 'f-edit') { editFeed(el.dataset.url); return; }
if (act === 'f-del') {
const u = el.dataset.url;
confirmBox('删除订阅源', '确定删除「' + (u || '') + '」吗?', async () => {
await rssApi('/api/feeds?url=' + encodeURIComponent(u), { method: 'DELETE' });
toast('✓ 已删除');
await viewFeeds();
});
return;
}
// --- 友链申请审核
if (act === 'l-ap-view' || act === 'l-ap-reject') { viewLinkApply(id); return; }
if (act === 'l-ap-approve') {
const a = (state.linkApps || []).find((x) => x.id === id);
confirmBox('通过申请', '确定通过「' + (a ? a.name : '') + '」的友链申请吗?通过后自动加进友链并邮件通知对方。', async () => {
await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'approve' } });
toast('✓ 已通过'); await viewLinks();
});
return;
}
if (act === 'l-ap-del') {
const a = (state.linkApps || []).find((x) => x.id === id);
confirmBox('删除申请记录', '确定删除「' + (a ? a.name : '') + '」这条申请吗?只清掉申请记录,已有的友链不受影响。', async () => {
await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'delete' } });
toast('✓ 已删除申请记录'); await viewLinks();
});
return;
}
// --- 友链
if (act === 'l-reload') { el.dataset.busy = '1'; try { await viewLinks(); } finally { delete el.dataset.busy; } return; }
if (act === 'l-add') { addLink(); return; }
if (act === 'l-edit') { editLink(el.dataset.url); return; }
if (act === 'l-del') {
const u = el.dataset.url;
confirmBox('删除友链', '确定删除「' + (u || '') + '」吗?', async () => {
await rssApi('/api/links?url=' + encodeURIComponent(u), { method: 'DELETE' });
toast('✓ 已删除');
await viewLinks();
});
return;
}
// --- 证书管家
if (act === 'ssl-check') {
el.dataset.busy = '1';
try {
const r = await sslApi('/check', { method: 'POST' });
const bad = (r.items || []).filter((i) => i.verdict !== 'match');
toast(bad.length ? '⚠ ' + bad.length + ' 个域名需关注(见执行记录)' : '✓ 全部一致', !!bad.length);
await viewSSL();
} catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; }
return;
}
if (act === 'ssl-notify') {
el.dataset.busy = '1';
try {
const r = await sslApi('/notify', { method: 'POST' });
toast('✓ 已发给 ' + r.sent + '/' + r.total + ' 个收件人');
await loadSslLog();
} catch (ex) { toast('✕ ' + ex.message, true); }
delete el.dataset.busy;
return;
}
if (act === 'ssl-notify-save') {
const emails = $('#sslNotifyEmails').value.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
const days = Number($('#sslNotifyDays').value);
el.dataset.busy = '1';
try {
await sslApi('/config', {
method: 'POST',
body: { version: 1, notify: { emails, daysBefore: days }, domains: (state.ssl.ov.domains || []) },
});
toast('✓ 已保存'); await viewSSL();
} catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; }
return;
}
if (act === 'ssl-add-domain') { await ensureAccessLoaded(); sslDomainModal(''); return; }
if (act === 'ssl-edit-domain') { await ensureAccessLoaded(); sslDomainModal(el.dataset.domain); return; }
if (act === 'ssl-del-domain') {
const dn = el.dataset.domain;
confirmBox('删除域名「' + dn + '」',
'只从配置里移除,不会去删线上证书,也不会动 DNS。库里的证书记录会变成「孤儿」(不影响使用)。确定?',
async () => {
const domains = (state.ssl.ov.domains || []).filter((d) => d.name !== dn);
await sslApi('/config', { method: 'POST', body: { version: 1, notify: state.ssl.ov.notify, domains } });
toast('✓ 已删除'); await viewSSL();
});
return;
}
if (act === 'ssl-access-new') { sslAccessModal(''); return; }
if (act === 'ssl-access-edit') { await ensureAccessLoaded(); sslAccessModal(el.dataset.name); return; }
if (act === 'ssl-access-del') {
const an = el.dataset.name;
confirmBox('删除凭据「' + an + '」', '被域名配置引用的凭据删不掉(服务端会拦),删了就再也取不回密钥。确定?',
async () => {
await sslApi('/access/delete', { method: 'POST', body: { name: an } });
toast('✓ 已删除'); await viewSSL();
});
return;
}
if (act === 'ssl-import') { sslImportModal(el.dataset.domain || ''); return; }
if (act === 'ssl-probe') {
const host = el.dataset.host;
if (!host) return;
el.dataset.busy = '1';
try {
const r = await sslApi('/probe?host=' + encodeURIComponent(host));
sslProbeModal(host, r);
} catch (ex) { toast('✕ ' + ex.message, true); }
delete el.dataset.busy;
return;
}
if (act === 'ssl-log-clear') {
confirmBox('清空日志', '只清 KV 里的执行记录,不影响证书和配置。确定?', async () => {
await sslApi('/log/clear', { method: 'POST' });
toast('✓ 已清空'); await loadSslLog();
});
return;
}
});
// ============================================================ 启动
$('#loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = $('#lgBtn');
const err = $('#lgErr');
err.hidden = true;
btn.dataset.busy = '1';
try {
// 国内机模式:换的是本机 Cookie 会话,登录成功直接进文章编辑
if (state.direct) {
await directLogin($('#lgUser').value.trim(), $('#lgPass').value);
enterDirect();
return;
}
await doLogin($('#lgUser').value.trim(), $('#lgPass').value);
state.me = state.me || {};
if (!canEnterBackend() && !canUseCert()) {
throw new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)');
}
await bootstrap();
} catch (ex) {
err.textContent = '✕ ' + ex.message;
err.hidden = false;
TOKEN = '';
localStorage.removeItem(LS_T);
} finally {
delete btn.dataset.busy;
}
});
async function bootstrap() {
// 编辑角色没有仪表盘/评论/用户那些模块的权限(服务端 requireAdmin 一律 403),
// 所以不能跟着管理员那套预取走 —— 否则第一句 /sites 就把登录打成「会话已失效」。
if (isEditor()) { showApp(); return; }
// 站点列表 -> 默认站点
const sites = await api('/sites');
state.sites = sites || [];
let def = state.sites[0] ? state.sites[0].name : '';
try {
const c = await api('/conf');
if (c && c.frontend_conf && c.frontend_conf.site_default) def = c.frontend_conf.site_default;
} catch (e) {}
if (!def && state.sites[0]) def = state.sites[0].name;
state.site = def;
// 待审数量(供导航徽章)
try {
const p = await api('/comments?' + qs({ scope: 'site', site_name: state.site, type: 'pending', limit: 1 }));
state.pending = p.count || 0;
} catch (e) {}
showApp();
}
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 = ''; }
});
/**
* 问一句「本机后台的登录态还在不在」。
* 只有 editor-api 会回 { mode:'direct' };Cloudflare 版后台没这条路由,
* 回的不是这个形状 —— 用它来区分两种部署,比猜域名可靠。
*/
async function probeSession() {
try {
const r = await fetch(API + '/admin/session', { credentials: 'same-origin' });
const d = await r.json().catch(() => null);
if (!d || d.mode !== 'direct') return null;
return d;
} catch (e) { return null; }
}
/** 国内机模式:本机会话已就绪,直接进自己有权限的那一页(不经过 Cloudflare) */
function enterDirect() {
state.direct = true;
if (!state.me) state.me = { name: 'admin', is_admin: true, role: 'admin' };
$('#gate').hidden = true;
$('#app').hidden = false;
$('#who').innerHTML = esc(state.me.name || 'admin') +
(isEditor()
? ' <span class="tag" style="font-size:10px">编辑</span>'
: isSslOnly() ? ' <span class="tag ssl" style="font-size:10px">SSL</span>' : '');
$('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare';
go(isSslOnly() ? 'ssl' : 'posts');
}
(async function boot() {
// ① 本机后台(国内机部署):先看会话,未登录就亮自己的登录页
const sess = await probeSession();
if (sess) {
state.direct = true;
if (sess.ok) {
state.me = sess.user || { name: 'admin', is_admin: true };
enterDirect();
} else {
showGate();
}
return;
}
// ② Cloudflare 版后台:沿用原来的令牌流程
if (!TOKEN) { showGate(); return; }
try {
// /user 未登录时返回 200 + {user:null},所以要显式判 is_login
const me = await api('/user');
if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 });
state.me = me.user;
// 三种角色各有各的去处:编辑去文章、SSL 去证书、其余(普通用户)一律拒绝
if (!canEnterBackend() && !canUseCert()) {
throw Object.assign(new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)'), { status: 403 });
}
localStorage.setItem(LS_U, JSON.stringify(state.me));
if (canEnterBackend()) await bootstrap();
else showApp(); // SSL 管理员:跳过站点/评论那套预取(它没有权限,预取必 403)
} catch (e) {
logout(e.status ? '会话已失效,请重新登录' : ('连接失败:' + e.message));
}
})();
})();