Files
blog/blog-admin/public/admin/admin.js
T

2805 lines
129 KiB
JavaScript
Raw Normal View History

/* ==========================================================================
评论后台 —— 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-selfcheck"><span>环境自检</span></button>' +
'<button class="btn tiny" data-act="ssl-renew-check"><span>该续期了吗</span></button>' +
'<button class="btn tiny" data-act="ssl-issue-all"><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-issue" data-domain="' + esc(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 sslSelfCheckModal(r) {
const items = r.items || [];
const bad = items.filter((x) => !x.ok);
const kindLabel = { ca: 'CA', dns: 'DNS', deploy: '部署', target: '域名' };
const rows = items.map((x) =>
'<tr>' +
'<td>' + (x.ok ? '<span class="tag ok">通过</span>' : '<span class="tag dang">失败</span>') + '</td>' +
'<td><b>' + esc(x.name) + '</b><div class="lk">' + esc(kindLabel[x.kind] || x.kind) + '</div></td>' +
'<td>' + esc(x.detail) +
(x.hint ? '<div class="lk" style="color:var(--warning)">建议:' + esc(x.hint) + '</div>' : '') +
'</td>' +
'</tr>'
).join('');
modal('环境自检',
(bad.length
? '<div class="alert" style="background:var(--danger-soft);border-color:var(--danger-line);color:var(--danger)">' +
'<b>' + bad.length + '</b> / ' + items.length + ' 项有问题 —— 这些就是「续期那天才会炸」的地方,建议现在修掉。</div>'
: '<div class="alert" style="background:var(--ok-soft);border-color:var(--accent-line);color:var(--success)">' +
'全部 ' + items.length + ' 项通过 —— 凭据齐备,续期链路可用。</div>') +
'<div class="tblwrap" style="margin-top:12px"><table><thead><tr>' +
'<th style="width:64px"></th><th>项目</th><th>结果 / 建议</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>' +
'<p style="color:var(--muted);font-size:12px;margin-top:12px">自检<b>不会签发证书、不会写 DNS、不会改站点</b>,' +
'只做只读探测(拉 CA 目录 / 读 TXT / 列站点)。所以它可以随时安全地跑。</p>' +
'<div style="text-align:right;margin-top:16px"><button class="btn" data-act="modal-close"><span>关闭</span></button></div>');
}
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;
}
// 环境自检:不签发、不写 DNS,只验证凭据「连得上、认得对」
if (act === 'ssl-selfcheck') {
el.dataset.busy = '1';
el.innerHTML = '<span>自检中…</span>';
try {
const r = await sslApi('/selfcheck', { method: 'POST' });
sslSelfCheckModal(r);
} catch (ex) { toast('✕ ' + ex.message, true); }
el.innerHTML = '<span>环境自检</span>';
delete el.dataset.busy;
return;
}
// 续期检查(只读,秒回):看哪些域名到了该续的天数
if (act === 'ssl-renew-check') {
el.dataset.busy = '1';
try {
const r = await sslApi('/renew-check');
const need = (r.items || []).filter((i) => i.action !== 'ok');
if (!need.length) toast('✓ 全部域名都还不需要续期(阈值 ' + r.threshold + ' 天)');
else toast('⚠ ' + need.map((i) => i.domain + '(' + (i.daysLeft == null ? '无记录' : i.daysLeft + ' 天') + ')').join('、'), true);
state.ssl.renewCheck = r;
await viewSSL();
} catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; }
return;
}
// ★ 2026-10-06:签发/续期已**迁到国内机容器 cn-certkeeper**,Worker 侧只留只读。
// 这里不再调 /issue(后端也返回 501),改为给出可直接复制的操作指引。
// 为什么迁:免费版 CPU 硬顶 10ms(签发擦边)、CF 出口 IP 进不了 1Panel
// 白名单、Worker 拿不到对端证书正文。详见 函数版证书管家-方案.md 第九章。
if (act === 'ssl-issue' || act === 'ssl-issue-all') {
const domain = act === 'ssl-issue' ? el.dataset.domain : '';
const label = domain || '全部启用的域名';
const cmd = 'curl -s -X POST -H "X-Auth-Token: $AUTH_TOKEN" http://127.0.0.1:8019/renew';
modal('签发/续期已迁到国内机:' + label,
'<p style="margin-top:0">签发 + 部署整条链路现在跑在<b>国内机的 Docker 容器 cn-certkeeper</b>' +
'(只绑 <code>127.0.0.1</code>,不对外)。这个页面只做<b>只读监控与配置</b>。</p>' +
'<p>在内侧机上执行:</p>' +
'<pre style="white-space:pre-wrap;word-break:break-all;background:rgba(127,127,127,.12);padding:10px;border-radius:8px">' +
esc(cmd) + '</pre>' +
'<p style="margin-bottom:0">· 只问「该不该续」:<code>GET /renew-check</code><br>' +
'· 看凭据是否齐备:<code>GET /preflight</code><br>' +
'· 看每个域名的真实剩余天数:<code>GET /status</code></p>' +
'<div style="display:flex;justify-content:flex-end;margin-top:18px">' +
'<button class="btn" data-act="modal-close"><span>知道了</span></button></div>');
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));
}
})();
})();