Files
blog/blog-admin/public/admin/admin.js
T
zqlit bcfa229105 邮件模板微信化 + 评论老友自动填充 + CF 后台国内线路一键跳
- 邮件模板重做成微信聊天式:灰底、自己评论绿色气泡靠右、对方白色气泡靠左、
  40px 圆角方头像(weavatar,邮箱 md5 不外泄)、时间居中、#07C160 主按钮;
  布局全嵌套表格(Outlook 稳),表情内联 max-height:28px(修掉「表情太大」)、
  配图限宽 100%;管理员主题从「新的评论待审」改成「您有一个新的评论」
- 老友自动填充:新增 /comments/lookup(只回昵称+网址,绝不含邮箱;IP 限速
  30 次/10 分钟)+ 主题 comment-autofill 模块(填昵称弹提示、填邮箱自动带出
  上次的昵称/网址,事件委托不怕 Artalk 重建编辑器)
- CF 后台新增「国内线路」一键跳:/editor/handoff 用共享令牌对时间戳 HMAC
  签名 → editor-api /admin/handoff 验签发会话 Cookie(2 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
2026-10-05 07:36:26 +08:00

2111 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
评论后台 —— artalk-cf 管理面板
纯原生 JS,零依赖。对接本服务已有的 Artalk API v2。
========================================================================== */
(function () {
'use strict';
// ============================================================ 基础工具
const API = location.origin + '/api/v2';
// 订阅服务跑在 Cloudflare Worker 上。后台若部署在国内机,同源请求会由 nginx
// 反代过去;部署在 Worker 上时 location.origin 就是 api.200181.xyz,行为一致。
const RSS_API = location.origin;
const LS_T = 'atk.token';
const LS_U = 'atk.user';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
/** HTML 转义 —— 所有来自评论/用户的数据都必须过这一层 */
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (m) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
/** 把用户写的 HTML 评论压成安全纯文本,表情图用占位符代替 */
function plainify(html) {
const t = String(html == null ? '' : html)
.replace(/<img[^>]*>/gi, '[图]')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li)>/gi, '\n')
.replace(/<[^>]+>/g, '');
return esc(t).replace(/\n{3,}/g, '\n\n').trim();
}
const nfmt = (n) => Number(n || 0).toLocaleString('zh-CN');
function qs(obj) {
const u = new URLSearchParams();
for (const k of Object.keys(obj)) {
const v = obj[k];
if (v !== '' && v != null) u.set(k, String(v));
}
return u.toString();
}
const truncate = (s, n) => (String(s || '').length > n ? String(s).slice(0, n) + '…' : String(s || ''));
// ============================================================ 状态
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 }
};
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' },
];
// 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"/>'),
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;
$('#who').textContent = (state.me && (state.me.name || state.me.email)) || 'admin';
$('#footR').textContent = 'API v2 · 站点:' + (state.site || '—');
renderTabs();
go('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 };
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>';
$('#tabs').innerHTML =
t('posts', '文章编辑') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">订阅中心</div>' +
t('feeds', '订阅源') + t('links', '友链') +
'<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 为空的(仪表盘)单独在顶部;组间加分隔线
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)
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();
} 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" 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>' + (r.is_admin ? ' <span class="tag acc">管理员</span>' : '') + '</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;
}
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>' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:18px">' +
'<input type="checkbox" id="euAdm"' + (u.is_admin ? ' checked' : '') + ' style="width:auto">' +
'<span class="px" style="color:var(--muted)">管理员权限</span></label>' +
'<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(),
is_admin: $('#euAdm').checked,
};
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 danger" data-act="l-ap-reject" 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);
const field = (k, label, cls, ph) =>
'<label class="field ' + (cls || '') + '"><span class="field-k">' + esc(label) + '</span>' +
'<input data-fm="' + k + '" autocomplete="off" spellcheck="false" placeholder="' + esc(ph || '') + '" value="' + esc(orig[k] == null ? '' : orig[k]) + '"></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') +
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)">待发布文件</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">发布 = 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);
}
// ============================================================ 事件委托
document.addEventListener('click', async (e) => {
const el = e.target.closest('[data-act]');
if (!el) return;
const act = el.dataset.act;
const id = el.dataset.id ? parseInt(el.dataset.id, 10) : 0;
// --- 通用
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-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-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;
}
});
// ============================================================ 启动
$('#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 (state.me.is_admin === false) throw new Error('该账号不是管理员');
await bootstrap();
} catch (ex) {
err.textContent = '✕ ' + ex.message;
err.hidden = false;
TOKEN = '';
localStorage.removeItem(LS_T);
} finally {
delete btn.dataset.busy;
}
});
async function bootstrap() {
// 站点列表 -> 默认站点
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 };
$('#gate').hidden = true;
$('#app').hidden = false;
$('#who').textContent = state.me.name || 'admin';
$('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare';
go('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;
if (state.me.is_admin === false) throw Object.assign(new Error('该账号不是管理员'), { status: 403 });
localStorage.setItem(LS_U, JSON.stringify(state.me));
await bootstrap();
} catch (e) {
logout(e.status ? '会话已失效,请重新登录' : ('连接失败:' + e.message));
}
})();
})();