Files
blog/blog-admin/public/admin/admin.js
T
zqlit a74b3c7127
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

1298 lines
58 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';
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) =>
({ '&': '&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,
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));
}
})();
})();