1298 lines
58 KiB
JavaScript
1298 lines
58 KiB
JavaScript
/* ==========================================================================
|
||||
|
|
评论后台 —— artalk-cf 管理面板
|
|||
|
|
纯原生 JS,零依赖。对接本服务已有的 Artalk API v2。
|
|||
|
|
========================================================================== */
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
// ============================================================ 基础工具
|
|||
|
|
|
|||
|
|
const API = location.origin + '/api/v2';
|
|||
|
|
const RSS_API = 'https://api.200181.xyz';
|
|||
|
|
const LS_T = 'atk.token';
|
|||
|
|
const LS_U = 'atk.user';
|
|||
|
|
const LS_RSS = 'atk.rss.token'; // 订阅口令
|
|||
|
|
|
|||
|
|
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) =>
|
|||
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,
|
|||
|
|
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(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TABS = [
|
|||
|
|
{ id: 'dash', label: '仪表盘', group: '', icon: 'dash' },
|
|||
|
|
{ id: 'comments', label: '评论管理', group: '内容管理', icon: 'comments' },
|
|||
|
|
{ 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: 'images', label: '图片', group: '订阅中心', icon: 'images' },
|
|||
|
|
{ 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"/>'),
|
|||
|
|
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"/>'),
|
|||
|
|
images: I('<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="M4 17.5l5-4.5 4 3.5 3-2.5 4 3.5"/>'),
|
|||
|
|
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"/>'),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ============================================================ HTTP
|
|||
|
|
|
|||
|
|
let TOKEN = localStorage.getItem(LS_T) || '';
|
|||
|
|
|
|||
|
|
async function api(path, opt) {
|
|||
|
|
opt = opt || {};
|
|||
|
|
const headers = {};
|
|||
|
|
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
|
|||
|
|
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
|
|||
|
|
|
|||
|
|
let res;
|
|||
|
|
try {
|
|||
|
|
res = await fetch(API + path, {
|
|||
|
|
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) {
|
|||
|
|
// 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 订阅后端请求:URL 带 ?token=<订阅口令>
|
|||
|
|
let RSS_TOKEN = localStorage.getItem(LS_RSS) || '';
|
|||
|
|
|
|||
|
|
async function rssApi(path, opt) {
|
|||
|
|
opt = opt || {};
|
|||
|
|
const u = new URL(RSS_API + path);
|
|||
|
|
if (RSS_TOKEN) u.searchParams.set('token', RSS_TOKEN);
|
|||
|
|
|
|||
|
|
const headers = {};
|
|||
|
|
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
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) {
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================ 骨架
|
|||
|
|
|
|||
|
|
function renderTabs() {
|
|||
|
|
// 分组渲染: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>';
|
|||
|
|
}
|
|||
|
|
$('#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 === '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 === 'images') await viewImages();
|
|||
|
|
} 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; }
|
|||
|
|
};
|
|||
|
|
})();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================ 视图:设置
|
|||
|
|
|
|||
|
|
async function viewSettings() {
|
|||
|
|
const [sites, conf] = await Promise.all([api('/sites'), api('/conf')]);
|
|||
|
|
state.sites = sites || [];
|
|||
|
|
const fe = conf.frontend_conf || {};
|
|||
|
|
|
|||
|
|
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 feRows = [
|
|||
|
|
['评论分页', (fe.pagination && fe.pagination.pageSize) || 20, '条/页'],
|
|||
|
|
['初始加载', fe.countOfCommentsOnPageInit || 20, '条'],
|
|||
|
|
['验证码', fe.captchaMode || 'off', ''],
|
|||
|
|
['投票', fe.vote ? '开启' : '关闭', ''],
|
|||
|
|
['图片上传', fe.imgUpload ? '可用' : '不可用', ''],
|
|||
|
|
['语言', fe.locale || 'zh-CN', ''],
|
|||
|
|
['表情包', fe.emoticons || '—', ''],
|
|||
|
|
['头像源', (fe.gravatar && fe.gravatar.mirror) || '', ''],
|
|||
|
|
].map((r) =>
|
|||
|
|
'<dt>' + esc(r[0]) + '</dt><dd>' + esc(String(r[1])) + (r[2] ? ' <span style="color:var(--muted)">' + esc(r[2]) + '</span>' : '') + '</dd>'
|
|||
|
|
).join('');
|
|||
|
|
|
|||
|
|
$('#view').innerHTML =
|
|||
|
|
head('设置', 'SETTINGS', '站点与前台配置') +
|
|||
|
|
'<div class="grid2">' +
|
|||
|
|
'<section class="card"><header class="card-head"><h3>站点</h3></header><div class="card-body"><div class="mini">' + siteRows + '</div></div></section>' +
|
|||
|
|
'<section class="card"><header class="card-head"><h3>前台配置</h3></header><div class="card-body"><dl class="kv">' + feRows + '</dl></div></section>' +
|
|||
|
|
'</div>' +
|
|||
|
|
'<section class="card" style="margin-top:14px"><header class="card-head"><h3>服务端配置</h3></header>' +
|
|||
|
|
'<div class="card-body" id="yamlBox"><div class="loading">加载中…</div></div></section>';
|
|||
|
|
|
|||
|
|
api('/settings').then((d) => {
|
|||
|
|
const box = $('#yamlBox');
|
|||
|
|
if (!box) return;
|
|||
|
|
box.innerHTML =
|
|||
|
|
'<textarea id="yamlTxt" class="code" spellcheck="false" style="width:100%;min-height:360px;resize:vertical">' + esc(d.yaml || '') + '</textarea>' +
|
|||
|
|
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px">' +
|
|||
|
|
'<button class="btn primary" id="yamlSave"><span>保存配置</span></button>' +
|
|||
|
|
'<button class="btn" id="yamlReset"><span>放弃修改</span></button>' +
|
|||
|
|
'<span id="yamlMsg" style="font-size:12px;color:var(--muted)"></span>' +
|
|||
|
|
'</div>' +
|
|||
|
|
'<p style="margin:12px 0 0;font-size:12px;color:var(--muted)">' +
|
|||
|
|
'保存在 D1 settings 表,改完即刻生效。服务端识别的键:' +
|
|||
|
|
'<code>frontend.*</code>(前台配置,含 <code>emoticons</code> 表情包地址)、' +
|
|||
|
|
'<code>captcha</code> 验证码、<code>moderator.pending_default</code> 待审、' +
|
|||
|
|
'<code>ip_region</code> 归属地、<code>site_default</code> 默认站点名;其余键仅存档。</p>';
|
|||
|
|
|
|||
|
|
let original = d.yaml || '';
|
|||
|
|
|
|||
|
|
// IP 归属地快捷开关:读写 yaml 里的 ip_region.enabled(与 sidebar 设置页同一份数据)
|
|||
|
|
var ipOn = /ip_region:[\s\S]*?enabled:\s*true/.test(original);
|
|||
|
|
var ipRow = document.createElement('div');
|
|||
|
|
ipRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' +
|
|||
|
|
'background:var(--surface);border:1px solid var(--line);border-radius:6px';
|
|||
|
|
ipRow.innerHTML =
|
|||
|
|
'<input type="checkbox" id="ipRegionToggle"' + (ipOn ? ' checked' : '') + ' style="width:auto">' +
|
|||
|
|
'<label for="ipRegionToggle" style="font-size:13px;cursor:pointer;color:var(--text)">显示评论者 IP 归属地(如 CN / US)</label>' +
|
|||
|
|
'<span style="font-size:11px;color:var(--muted)">(同 sidebar 设置页的 ip_region.enabled)</span>';
|
|||
|
|
var txt = $('#yamlTxt');
|
|||
|
|
txt.parentNode.insertBefore(ipRow, txt);
|
|||
|
|
ipRow.querySelector('#ipRegionToggle').addEventListener('change', function () {
|
|||
|
|
var on = this.checked;
|
|||
|
|
var t = $('#yamlTxt');
|
|||
|
|
var v = t.value;
|
|||
|
|
if (/ip_region:[\s\S]*?enabled:\s*(true|false)/.test(v)) {
|
|||
|
|
v = v.replace(/(ip_region:[\s\S]*?enabled:\s*)(true|false)/, '$1' + on);
|
|||
|
|
} else {
|
|||
|
|
v += (v.endsWith('\n') ? '' : '\n') + 'ip_region://n enabled: ' + on + '\n';
|
|||
|
|
}
|
|||
|
|
t.value = v;
|
|||
|
|
toast('已切换为 ' + (on ? '显示' : '隐藏') + ',保存后生效');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 人机验证快捷开关(自研「一键验证」):读写 yaml 里的 human_check.enabled,
|
|||
|
|
// 保存时由服务端同步到 KV(评论提交时读 KV,不占 D1 额度)
|
|||
|
|
var hcOn = /human_check:[\s\S]*?enabled://s*true/.test(original);
|
|||
|
|
var hcRow = document.createElement('div');
|
|||
|
|
hcRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding:10px 12px;' +
|
|||
|
|
'background:var(--surface);border:1px solid var(--line);border-radius:6px';
|
|||
|
|
hcRow.innerHTML =
|
|||
|
|
'<input type="checkbox" id="humanCheckToggle"' + (hcOn ? ' checked' : '') + ' style="width:auto">' +
|
|||
|
|
'<label for="humanCheckToggle" style="font-size:13px;cursor:pointer;color:var(--text)">' +
|
|||
|
|
'人机验证(一键验证:读者无感,可疑才出验证码)</label>' +
|
|||
|
|
'<span style="font-size:11px;color:var(--muted)">(human_check.enabled)</span>';
|
|||
|
|
ipRow.parentNode.insertBefore(hcRow, ipRow.nextSibling);
|
|||
|
|
hcRow.querySelector('#humanCheckToggle').addEventListener('change', function () {
|
|||
|
|
var on = this.checked;
|
|||
|
|
var t = $('#yamlTxt');
|
|||
|
|
var v = t.value;
|
|||
|
|
if (/human_check:[\s\S]*?enabled://s*(true|false)/.test(v)) {
|
|||
|
|
v = v.replace(/(human_check:[\s\S]*?enabled://s*)(true|false)/, '$1' + on);
|
|||
|
|
} else {
|
|||
|
|
v += (v.endsWith('\n') ? '' : '\n') + 'human_check://n enabled: ' + on + '\n';
|
|||
|
|
}
|
|||
|
|
t.value = v;
|
|||
|
|
toast('人机验证已' + (on ? '开启' : '关闭') + ',保存后生效');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const $r = () => $('#yamlReset');
|
|||
|
|
if ($r()) $r().onclick = () => { $('#yamlTxt').value = original; toast('已还原'); };
|
|||
|
|
|
|||
|
|
const $s = () => $('#yamlSave');
|
|||
|
|
if ($s()) $s().onclick = async () => {
|
|||
|
|
const b = $s(); b.dataset.busy = '1';
|
|||
|
|
const msg = $('#yamlMsg'); if (msg) msg.textContent = '保存中…';
|
|||
|
|
try {
|
|||
|
|
const r = await api('/settings', { method: 'PUT', body: { yaml: $('#yamlTxt').value } });
|
|||
|
|
original = $('#yamlTxt').value;
|
|||
|
|
if (msg) msg.textContent = '';
|
|||
|
|
toast('✓ 已保存' + (r && r.msg ? '' : ''));
|
|||
|
|
viewSettings(); // 重新拉取,让上方"前台配置"卡片同步刷新
|
|||
|
|
} catch (e) {
|
|||
|
|
if (msg) msg.textContent = '';
|
|||
|
|
toast('✕ ' + e.message, true);
|
|||
|
|
delete b.dataset.busy;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}).catch(() => { const b = $('#yamlBox'); if (b) b.innerHTML = '<div class="empty">读取失败</div>'; });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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; }
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================ 视图:图片
|
|||
|
|
|
|||
|
|
async function viewImages() {
|
|||
|
|
if (state.tab !== 'images') return;
|
|||
|
|
$('#view').innerHTML =
|
|||
|
|
head('图片管理', 'IMAGES', '随机图 · ' + RSS_API, '<button class="btn tiny" data-act="i-reload">刷新</button>');
|
|||
|
|
|
|||
|
|
let folders;
|
|||
|
|
try {
|
|||
|
|
const d = await rssApi('/api/random-image?list=1');
|
|||
|
|
folders = d.folders || [];
|
|||
|
|
} catch (e) {
|
|||
|
|
$('#view').innerHTML += '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rows = folders.map((f) =>
|
|||
|
|
'<div class="mini-i"><span class="b"><span class="nm">' + esc(f) + '</span></span>' +
|
|||
|
|
'<span class="c"><a class="btn tiny" href="' + RSS_API + '/api/random-image?folder=' + encodeURIComponent(f) + '&meta=1" target="_blank" rel="noopener">查看</a></span></div>'
|
|||
|
|
).join('') || '<div class="empty">暂无图片文件夹</div>';
|
|||
|
|
|
|||
|
|
$('#view').innerHTML +=
|
|||
|
|
'<section class="card"><header class="card-head"><h3>图片文件夹(' + folders.length + ')</h3>' +
|
|||
|
|
'<button class="btn tiny" data-act="i-add">添加图片</button></header>' +
|
|||
|
|
'<div class="card-body"><div class="mini">' + rows + '</div></div></section>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addImage() {
|
|||
|
|
modal('添加图片',
|
|||
|
|
'<label class="field"><span class="field-k">文件夹</span><input id="iFolder" value="jiege"></label>' +
|
|||
|
|
'<label class="field"><span class="field-k">图片 URL(多个换行分隔)</span>' +
|
|||
|
|
'<textarea id="iUrls" style="min-height:80px" placeholder="https://..."></textarea></label>' +
|
|||
|
|
'<button class="btn primary block" id="iSave"><span>添加</span></button>');
|
|||
|
|
$('#iSave').onclick = async () => {
|
|||
|
|
const b = $('#iSave'); b.dataset.busy = '1';
|
|||
|
|
try {
|
|||
|
|
const urls = $('#iUrls').value.split('\n').map((s) => s.trim()).filter(Boolean);
|
|||
|
|
const folder = $('#iFolder').value.trim() || 'jiege';
|
|||
|
|
await rssApi('/api/random-image?folder=' + encodeURIComponent(folder), { method: 'POST', body: { urls } });
|
|||
|
|
closeModal(); toast('✓ 已添加'); viewImages();
|
|||
|
|
} catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; }
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ============================================================ 侧边栏抽屉 / 主题
|
|||
|
|
|
|||
|
|
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 === 'tab') { if (state.tab !== el.dataset.id) go(el.dataset.id); return; }
|
|||
|
|
if (act === 'logout') {
|
|||
|
|
confirmBox('退出登录', '确定要退出当前会话吗?', () => logout());
|
|||
|
|
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 === '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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 图片
|
|||
|
|
if (act === 'i-reload') { el.dataset.busy = '1'; try { await viewImages(); } finally { delete el.dataset.busy; } return; }
|
|||
|
|
if (act === 'i-add') { addImage(); return; }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ============================================================ 启动
|
|||
|
|
|
|||
|
|
$('#loginForm').addEventListener('submit', async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const btn = $('#lgBtn');
|
|||
|
|
const err = $('#lgErr');
|
|||
|
|
err.hidden = true;
|
|||
|
|
btn.dataset.busy = '1';
|
|||
|
|
try {
|
|||
|
|
await doLogin($('#lgUser').value.trim(), $('#lgPass').value);
|
|||
|
|
state.me = state.me || {};
|
|||
|
|
if (state.me.is_admin === false) throw new Error('该账号不是管理员');
|
|||
|
|
|
|||
|
|
// 订阅口令:填了就验证并存下,没填就清空(订阅模块会提示未配置)
|
|||
|
|
const rssPwd = $('#lgRss') ? $('#lgRss').value.trim() : '';
|
|||
|
|
if (rssPwd) {
|
|||
|
|
const ok = await rssApi('/api/auth?token=' + encodeURIComponent(rssPwd));
|
|||
|
|
if (!ok || ok.ok !== true) throw new Error('订阅口令错误');
|
|||
|
|
RSS_TOKEN = rssPwd;
|
|||
|
|
localStorage.setItem(LS_RSS, rssPwd);
|
|||
|
|
} else {
|
|||
|
|
RSS_TOKEN = localStorage.getItem(LS_RSS) || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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');
|
|||
|
|
|
|||
|
|
(async function boot() {
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
})();
|