/* ========================================================================== 评论后台 —— 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(/]*>/gi, '[图]') .replace(//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) => ''; const ICONS = { dash: I(''), comments: I(''), pages: I(''), users: I(''), feeds: I(''), links: I(''), images: I(''), settings: I(''), status: I(''), moon: I(''), out: I(''), }; // ============================================================ 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, '

' + esc(text) + '

' + '
' + '' + '' + '
'); $('#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 += ''; html += ''; } else if (lastGroup !== null) { html += ''; } lastGroup = t.group; } const badge = (t.id === 'comments' && state.pending > 0) ? '' + state.pending + '' : ''; html += ''; } $('#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 ? '' + esc(t.group) + '/' : '') + '' + esc(t.label) + ''; } 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 = '
加载中…
'; 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 = '
出错了:' + esc(e.message) + '
'; } } // ============================================================ 视图:仪表盘 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) => '
' + c.k + '
' + '
' + nfmt(c.v) + '
' + esc(c.x) + '
' ).join(''); const latestRows = (latest.data || []).map((c) => '
' + '' + esc((c.date || '').slice(5, 16)) + '' + '' + esc(c.nick) + ' ' + '' + esc(truncate(String(c.content || '').replace(/<[^>]+>/g, ''), 44)) + '' + '#' + c.id + '' + '
' ).join('') || '
暂无评论
'; const hotRows = (hot.data || []).map((p) => '
' + '' + esc(truncate(p.title || p.key, 26)) + ' ' + '' + esc(p.key) + '' + '' + nfmt(p.comment_count) + '' + '
' ).join('') || '
暂无数据
'; if (state.tab !== 'dash') return; $('#view').innerHTML = head('仪表盘', 'DASHBOARD', '站点 ' + sn + ' 的评论概览') + '
' + cards + '
' + '
' + '

最新评论

' + latestRows + '
' + '

评论最多的文章

' + hotRows + '
' + '
'; } function head(zh, en, sub, actions) { void en; // 页头不再显示英文代号 return '

' + esc(zh) + '

' + (sub ? '' + esc(sub) + '' : '') + (actions ? '
' + actions + '
' : '') + '
'; } // ============================================================ 视图:评论 async function viewComments() { const c = state.c; if (state.tab !== 'comments') return; $('#view').innerHTML = head('评论管理', 'COMMENTS', '', '') + '
' + '
' + '' + '' + '
' + '
加载中…
' + '
'; 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 = '
加载中…
'; // 待审视图走扁平模式:待审的可能是任意层级的回复,按“顶层分页”会整条漏掉 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 = '
' + (c.q ? '没有匹配 “' + esc(c.q) + '” 的评论' : '暂无评论') + '
'; $('#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 = '
' + roots.map((r) => node(r, kids, 0)).join('') + '
'; const pages = Math.max(1, Math.ceil(c.denom / c.limit)); const cur = Math.floor(c.offset / c.limit) + 1; $('#cpager').innerHTML = '
' + '' + '第 ' + cur + ' / ' + pages + ' 页 · 共 ' + nfmt(c.total) + ' 条评论' + (useFlat ? '(待审,按条)' : ' / ' + nfmt(c.denom) + ' 个主题') + '' + '' + '
'; } 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('置顶'); if (c.is_pending) tags.push('待审'); if (c.is_collapsed) tags.push('折叠'); if (c.badge_name) tags.push('' + esc(c.badge_name) + ''); if (c.is_verified) tags.push('博主'); 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 '
' + '
' + '' + '
' + '
' + '' + esc(c.nick) + '' + tags.join('') + '#' + c.id + ' · ' + esc(c.date) + '' + '
' + '
' + (plainify(c.content) || '(空内容)') + '
' + '
' + '' + esc(truncate(c.page_key, 42)) + '' + (ch.length ? ' · 回复 ' + ch.length + '' : '') + (c.ua ? ' · ' + esc(truncate(c.ua.replace(/^Mozilla\/5\.0\s*/, ''), 26)) + '' : '') + '' + (ch.length ? '' : '') + (c.is_pending ? '' : '') + '' + '' + '' + '' + '
' + (ch.length && opened ? '
' + ch.map((x) => node(x, kids, depth + 1)).join('') + '
' : '') + '
' + '
' + '
'; } // ============================================================ 视图:页面 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 ? '
' + '' + '' + rows.map((r) => '' + '' + '' + '' + '' + '').join('') + '
页面标题访问路径票数最近活动
' + (esc(r.title) || '(无标题)') + '' + esc(r.key) + '' + nfmt(r.vote_up) + ' / ' + nfmt(r.vote_down) + '' + esc(r.date) + '
' + pager('p', p, '页') : '
暂无页面
'); } 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 '
' + '' + '第 ' + cur + ' / ' + pages + ' 页 · 共 ' + nfmt(st.total) + ' ' + unit + '' + '' + '
'; } // ============================================================ 视图:用户 async function viewUsers() { const u = state.u; if (state.tab !== 'users') return; $('#view').innerHTML = head('用户', 'USERS', '', '') + '
' + '
加载中…
'; 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 = '
加载中…
'; 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 ? '
' + '' + '' + rows.map((r) => '' + '' + '' + '' + '' + '' + '' + '').join('') + '
昵称邮箱评论徽章近期 IP
' + esc(r.name) + '' + (r.is_admin ? ' 管理员' : '') + '' + esc(r.email) + '' + nfmt(r.comment_count) + '' + (r.badge_name ? '' + esc(r.badge_name) + '' : '—') + '' + esc(r.last_ip || '—') + '
' + pager('u', u, '个用户') : '
没有匹配的用户
'; 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, '' + '' + '' + '
' + '' + '' + '
' + '' + '' + '' + ''); $('#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) => '
' + '' + esc(s.name) + ' ' + '' + esc((s.urls || []).join(', ')) + '' + '' + '
').join('') || '
暂无站点
'; 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) => '
' + esc(r[0]) + '
' + esc(String(r[1])) + (r[2] ? ' ' + esc(r[2]) + '' : '') + '
' ).join(''); $('#view').innerHTML = head('设置', 'SETTINGS', '站点与前台配置') + '
' + '

站点

' + siteRows + '
' + '

前台配置

' + feRows + '
' + '
' + '

服务端配置

' + '
加载中…
'; api('/settings').then((d) => { const box = $('#yamlBox'); if (!box) return; box.innerHTML = '' + '
' + '' + '' + '' + '
' + '

' + '保存在 D1 settings 表,改完即刻生效。服务端识别的键:' + 'frontend.*(前台配置,含 emoticons 表情包地址)、' + 'captcha 验证码、moderator.pending_default 待审、' + 'ip_region 归属地、site_default 默认站点名;其余键仅存档。

'; 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 = '' + '' + '(同 sidebar 设置页的 ip_region.enabled)'; 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 = '' + '' + '(human_check.enabled)'; 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 = '
读取失败
'; }); } function editSite(id) { const s = state.sites.filter((x) => x.id === id)[0]; if (!s) return; modal('编辑站点 #' + id, '' + '' + '

改站点名会让前台 site 与数据对不上,除非你同步改博客配置。

' + ''); $('#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) => '
' + (x ? 'OK' : 'FAIL') + '
'; if (state.tab !== 'status') return; $('#view').innerHTML = head('系统状态', 'STATUS', '运行时自检') + '
' + '

服务端自检

' + '
D1 数据库
' + okDd(h.db === 'up') + '
评论总数
' + nfmt(h.comments) + '
' + '
查询延迟
' + esc(h.latency_ms) + ' ms
' + '
API 版本
' + esc(h.version) + '
' + '
' + '

运行时

' + '
应用
' + esc(v.app || 'artalk') + '
' + '
版本
' + esc(v.version || '—') + '
' + '
提交
' + esc(v.commit_hash || '—') + '
' + '
端点
' + esc(location.origin) + '
' + '
登录身份
' + esc((state.me && (state.me.name || state.me.email)) || '—') + '
' + '
' + '
' + '

注意事项

' + '
' + '
邮件通知
' + (h.mail === 'on' ? '已启用(Resend)' : '未配置 —— 需写入 RESEND_API_KEY') + '
' + '
验证码登录
不可用 —— Workers 无 TCP,发不了 SMTP
' + '
官方后台
可用 —— ' + esc(location.origin) + '/sidebar/
' + '
慢查询
跨境访问有 RTT 开销,评论列表为动态接口无法走 CDN 缓存
' + '
'; } // ============================================================ 视图:订阅源 // 数据来自 rss-robot-cf(api.200181.xyz) async function viewFeeds() { if (state.tab !== 'feeds') return; $('#view').innerHTML = head('订阅源管理', 'FEEDS', 'RSS 订阅源 · ' + RSS_API, ''); let data; try { data = await rssApi('/api/feeds'); } catch (e) { if (e.status === 401 || e.status === 403) { $('#view').innerHTML = head('订阅源管理', 'FEEDS', '需要订阅口令') + '
未配置订阅口令 —— 请退出后重新登录,并在登录页填写「订阅口令」。
'; return; } $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; return; } const feeds = data.feeds || []; state.feedsRows = feeds; // 编辑弹窗取数用 const rows = feeds.map((f) => '
' + '' + esc(f.feedTitle || f.url) + ' ' + '' + esc(f.url) + '' + '' + '' + '' + '' + '
').join('') || '
暂无订阅源
'; $('#view').innerHTML += '

订阅源(' + feeds.length + ')

' + '
' + '
' + rows + '
'; } function addFeed() { modal('添加订阅源', '' + '' + ''); $('#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('编辑订阅源', '' + '' + '

改地址不会丢抓取记录 —— 服务端按原地址定位后更新。

' + ''); $('#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, ''); 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', '需要订阅口令') + '
未配置订阅口令 —— 请退出后重新登录。
'; return; } $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; return; } state.linkApps = apps; // 待审申请卡片 let appHtml = ''; if (apps.length) { const appRows = apps.map((a) => '
' + '' + esc(a.name) + ' ' + '' + esc(a.url) + (a.feed ? ' · RSS' : '') + (a.email ? ' · ' + esc(a.email) : '') + '' + (a.description ? '' + esc(a.description) + '' : '') + '' + '' + '' + '' + '' + '' + '
').join(''); appHtml = '

待审申请(' + apps.length + ')

' + '新
' + '
' + appRows + '
'; } const links = data.links || []; state.linksRows = links; const rows = links.map((l) => '
' + '' + esc(l.name || l.url) + (l.hidden ? ' 隐藏' : '') + ' ' + '' + esc(l.url) + (l.rss ? ' · RSS' : '') + '' + '' + '' + '' + '' + '
').join('') || '
暂无友链
'; $('#view').innerHTML += appHtml + '

友链(' + links.length + ')

' + '
' + '
' + rows + '
'; } function viewLinkApply(id) { const a = (state.linkApps || []).find((x) => x.id === id); if (!a) return toast('✕ 申请不存在', true); modal('友链申请 · ' + a.name, '
' + '
名称:' + esc(a.name) + '
' + '' + '
订阅:' + esc(a.feed || '—') + '
' + '
邮箱:' + esc(a.email) + '
' + (a.description ? '
描述:' + esc(a.description) + '
' : '') + '
申请时间:' + esc(a.appliedAt) + '
' + '
' + '' + '
' + '' + '' + '
'); $('#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('添加友链', '' + '' + '' + ''); $('#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('编辑友链', '' + '' + '' + '' + '' + ''); $('#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, ''); let folders; try { const d = await rssApi('/api/random-image?list=1'); folders = d.folders || []; } catch (e) { $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; return; } const rows = folders.map((f) => '
' + esc(f) + '' + '查看
' ).join('') || '
暂无图片文件夹
'; $('#view').innerHTML += '

图片文件夹(' + folders.length + ')

' + '
' + '
' + rows + '
'; } function addImage() { modal('添加图片', '' + '' + ''); $('#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 + '' + (t === 'dark' ? '浅色模式' : '深色模式') + ''; } 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)); } })(); })();