/* ========================================================================== 评论后台 —— artalk-cf 管理面板 纯原生 JS,零依赖。对接本服务已有的 Artalk API v2。 ========================================================================== */ (function () { 'use strict'; // ============================================================ 基础工具 const API = location.origin + '/api/v2'; // 订阅服务跑在 Cloudflare Worker 上。后台若部署在国内机,同源请求会由 nginx // 反代过去;部署在 Worker 上时 location.origin 就是 api.200181.xyz,行为一致。 const RSS_API = location.origin; const LS_T = 'atk.token'; const LS_U = 'atk.user'; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); /** HTML 转义 —— 所有来自评论/用户的数据都必须过这一层 */ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 || '')); // ============================================================ 角色 // // 后台三档角色(服务端 users.role,权威实现在 blog-admin/src/lib/role.ts): // admin 管理员 —— 全部模块 // editor 编辑 —— 只有「文章编辑」,而且只能写 / 发布自己的文章 // ssl SSL 管理员 —— 只有「证书管家」,碰不到评论 / 文章 / 用户 // 服务端已经归一化过一次(is_admin 优先),这里再兜一次, // 兼容缓存里的老 state.me(localStorage 可能还留着改造前的结构)。 function roleOf(u) { if (!u) return 'user'; if (u.is_admin) return 'admin'; if (u.role === 'editor' || u.role === 'ssl') return u.role; return u.role === 'admin' ? 'admin' : 'user'; } /** 当前登录者是不是编辑(决定导航能画几项、作者能不能改) */ function isEditor() { return roleOf(state.me) === 'editor'; } /** 当前登录者是不是 SSL 管理员(只该看到「证书管家」一项) */ function isSslOnly() { return roleOf(state.me) === 'ssl'; } /** 能进写作后台的角色 */ function canEnterBackend() { const r = roleOf(state.me); return r === 'admin' || r === 'editor'; } /** 能进证书管家的角色 —— ★ 正着枚举,别写成「非编辑即放行」 */ function canUseCert() { const r = roleOf(state.me); return r === 'admin' || r === 'ssl'; } /** 用户列表里的角色标签 */ function roleTag(u) { const r = roleOf(u); if (r === 'admin') return ' 管理员'; if (r === 'editor') return ' 编辑'; if (r === 'ssl') return ' SSL'; return ''; } // ============================================================ 状态 const state = { tab: 'dash', sites: [], site: '', me: null, // 国内机模式:后台静态页部署在国内机(writeapi.usj.cc),编辑器接口同机直连, // 登录态是本机签发的 Cookie 会话,整条链路不过 Cloudflare。 direct: false, pending: 0, c: { q: '', type: 'all', offset: 0, limit: 20, total: 0, denom: 1, rows: [], busy: false }, p: { offset: 0, limit: 30, total: 0 }, u: { offset: 0, limit: 30, total: 0, q: '', rows: [] }, openReps: new Set(), // 文章编辑 po: { q: '', page: 1, perPage: 20, total: 0, totalPages: 1, rows: [], busy: false }, ed: null, // 打开某篇时:{ id, slug, post, fm, body, dirty, busy, git } // 证书管家(SSL):概述 / 表单草稿 / 正在编辑的凭据名 ssl: { ov: null, cfg: null, busy: false, editing: '', accessItems: null, probe: {} }, }; const TABS = [ { id: 'dash', label: '仪表盘', group: '', icon: 'dash' }, { id: 'comments', label: '评论管理', group: '内容管理', icon: 'comments' }, { id: 'posts', label: '文章编辑', group: '内容管理', icon: 'posts' }, { id: 'pages', label: '页面管理', group: '内容管理', icon: 'pages' }, { id: 'users', label: '用户管理', group: '内容管理', icon: 'users' }, { id: 'feeds', label: '订阅源', group: '订阅中心', icon: 'feeds' }, { id: 'links', label: '友链', group: '订阅中心', icon: 'links' }, { id: 'settings', label: '系统设置', group: '系统', icon: 'settings' }, { id: 'status', label: '运行状态', group: '系统', icon: 'status' }, { id: 'ssl', label: '证书管家', group: '系统', icon: 'lock' }, ]; // 16px 线性图标(stroke 继承 currentColor) const I = (p) => ''; const ICONS = { dash: I(''), comments: I(''), posts: I(''), pages: I(''), users: I(''), feeds: I(''), links: I(''), settings: I(''), status: I(''), lock: I(''), moon: I(''), out: I(''), eye: I(''), edit: I(''), trash: I(''), }; // ============================================================ HTTP let TOKEN = localStorage.getItem(LS_T) || ''; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * 带重试的请求。 * GET 是幂等的,网络抖一下(浏览器报 Failed to fetch / ERR_CONNECTION_*) * 不该让用户看到「打开失败」——换个连接重试一两次基本就好了。 * 非 GET 一律不重试:写操作重放可能造成重复写入。 */ async function api(path, opt) { opt = opt || {}; const method = (opt.method || 'GET').toUpperCase(); const tries = opt.retries != null ? opt.retries : (method === 'GET' ? 3 : 1); let res = null; let lastErr = null; for (let i = 0; i < tries; i++) { if (i) await sleep(i === 1 ? 350 : 900); const headers = {}; if (opt.body !== undefined) headers['Content-Type'] = 'application/json'; if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN; try { res = await fetch(API + path, { method, headers, body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined, signal: opt.signal, }); break; } catch (e) { res = null; lastErr = e; // 主动取消(AbortError)不是网络问题,别重试 if (e && e.name === 'AbortError') break; } } if (!res) { const offline = typeof navigator !== 'undefined' && navigator.onLine === false; const why = offline ? '本机网络已断开' : (lastErr && lastErr.message ? lastErr.message : '请求被中断'); throw new Error('网络不可达(' + why + ',已重试 ' + tries + ' 次)—— 检查网络后重试'); } const txt = await res.text(); let data = null; try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = { msg: txt }; } if (!res.ok) { // 401/403 一律视为会话失效,回登录门 if (res.status === 401 || res.status === 403) { const e = new Error((data && data.msg) || '未授权'); e.status = res.status; throw e; } throw new Error((data && (data.msg || data.error)) || 'HTTP ' + res.status); } return data; } // 订阅后端请求。凭据不再单独让用户输 —— 见下方注释。 async function rssApi(path, opt) { opt = opt || {}; const u = new URL(RSS_API + path); const headers = {}; if (opt.body !== undefined) headers['Content-Type'] = 'application/json'; // Cloudflare 版:带后台会话,订阅接口的 requireAuth 直接认(不再要「订阅口令」) // 国内机版:TOKEN 为空,请求由本机 editor-api 反代时注入共享令牌 if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN; let res; try { res = await fetch(u.toString(), { method: opt.method || 'GET', headers, body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined, signal: opt.signal, }); } catch (e) { throw new Error('订阅后端网络不可达'); } const txt = await res.text(); let data = null; try { data = txt ? JSON.parse(txt) : null; } catch (e) { data = { msg: txt }; } if (!res.ok) { if (res.status === 401 || res.status === 403) { const e = new Error((data && (data.error || data.msg)) || '订阅模块未授权'); e.status = res.status; throw e; } throw new Error((data && (data.error || data.msg)) || 'HTTP ' + res.status); } return data; } // ============================================================ 提示 / 模态 let toastTimer = null; function toast(msg, bad) { const el = $('#toast'); el.textContent = msg; el.className = 'toast' + (bad ? ' bad' : ''); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 3200); } function modal(title, html) { $('#modalTitle').textContent = title; $('#modalBody').innerHTML = html; $('#modal').hidden = false; } function closeModal() { $('#modal').hidden = true; } function confirmBox(title, text, onYes) { modal(title, '

' + 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; const foot = $('#lgFoot'); if (foot) { foot.textContent = state.direct ? '编辑器走国内机 · 不经过 Cloudflare' : '优世界 · 评论与订阅统一管理'; } const pass = $('#lgPass'); if (pass) pass.value = ''; setTimeout(() => { const i = $('#lgUser'); if (i) i.focus(); }, 60); } function showApp() { $('#gate').hidden = true; $('#app').hidden = false; // 侧栏显示身份:编辑 / SSL 管理员标一下,免得 TA 以为自己权限跟管理员一样 const tag = isEditor() ? ' 编辑' : isSslOnly() ? ' SSL' : ''; $('#who').innerHTML = esc((state.me && (state.me.name || state.me.email)) || 'admin') + tag; $('#footR').textContent = state.direct ? '编辑器走国内机 · 不经过 Cloudflare' : 'API v2 · 站点:' + (state.site || '—'); renderTabs(); // 各自落到自己唯一有权限的那一页:仪表盘/评论这些它们没有权限, // 默认页只会是一片 403 go(isEditor() ? 'posts' : isSslOnly() ? 'ssl' : 'dash'); } function logout(msg) { // 国内机模式登录态在 Cookie 里,得让后端删掉会话,否则刷新又进去了 if (state.direct) { fetch(API + '/admin/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {}); } TOKEN = ''; state.me = null; localStorage.removeItem(LS_T); localStorage.removeItem(LS_U); showGate(); if (msg) { const err = $('#lgErr'); err.textContent = msg; err.hidden = false; } } async function doLogin(email, password) { const d = await api('/user/access_token', { method: 'POST', body: { email, password } }); if (!d || !d.token) throw new Error('服务端未返回凭据'); TOKEN = d.token; state.me = d.user || null; localStorage.setItem(LS_T, TOKEN); try { localStorage.setItem(LS_U, JSON.stringify(state.me)); } catch (e) {} return state.me; } /** * 国内机模式的登录:账号密码发给同机的 editor-api,换一个 HttpOnly Cookie。 * 全程不出境,也不碰 Cloudflare 的 D1 用户表。 */ async function directLogin(user, password) { let res; try { res = await fetch(API + '/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ user, password }), }); } catch (e) { throw new Error('连不上本机编辑器服务(' + (e.message || '网络错误') + ')'); } const d = await res.json().catch(() => null); if (!res.ok || !d || !d.ok) { throw new Error((d && (d.error || d.msg)) || ('登录失败(HTTP ' + res.status + ')')); } state.me = d.user || { name: user, is_admin: true, role: 'admin' }; return state.me; } // ============================================================ 骨架 function renderTabs() { // 国内机模式:文章编辑走本机直连(不出境);订阅源/友链由本机后端反代到 // Cloudflare 的订阅服务(带了共享令牌,前端不用口令)。评论/设置这些还 // 必须走 Cloudflare 会话(D1 数据在国内访问那一跳很慢),单独留个入口。 if (state.direct) { const t = (id, label) => ''; // 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy), // 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画。 // ★ SSL 管理员在国内机侧除了证书管家什么都不该看到;证书接口由本机 // editor-api 反代到 Cloudflare(见 server.mjs 的 SSL_PATH_PREFIXES), // 所以这一项在国内线路下同样可用。 if (isSslOnly()) { $('#tabs').innerHTML = t('ssl', '证书管家'); return; } $('#tabs').innerHTML = t('posts', '文章编辑') + (isEditor() ? '' : '' + '' + t('feeds', '订阅源') + t('links', '友链') + '' + '' + t('ssl', '证书管家') + '' + '' + ''); return; } // 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线 // ★ 权限裁剪:服务端本来就 403(见 routes/admin.ts 的 requireAdmin、 // routes/ssl.ts 的 auth),画出来只会让人点进一片报错。 // editor → 只留「文章编辑」 // ssl → 只留「证书管家」 // ★ 用 isEditor()/isSslOnly() 各自**正着**判断,别写成一条 filter 链 —— // 以后新增角色时,漏写一个分支的后果是「默认看到全部模块」。 let tabs; if (isEditor()) tabs = TABS.filter((t) => t.id === 'posts'); else if (isSslOnly()) tabs = TABS.filter((t) => t.id === 'ssl'); else tabs = TABS; let html = ''; let lastGroup = null; for (const t of tabs) { if (t.group !== lastGroup) { if (t.group) { if (lastGroup !== null && lastGroup !== '') html += ''; html += ''; } else if (lastGroup !== null) { html += ''; } lastGroup = t.group; } const badge = (t.id === 'comments' && state.pending > 0) ? '' + state.pending + '' : ''; html += ''; } // 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台 // (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie) // 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份) // ★ SSL 管理员不给这个入口:国内机后台目前只认 admin/editor 两种角色 // (见 editor-api 的 handoff 校验),跳过去会签不出会话。 if (!isSslOnly()) { 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 === 'posts') await viewPosts(); else if (tab === 'pages') await viewPages(); else if (tab === 'users') await viewUsers(); else if (tab === 'settings') await viewSettings(); else if (tab === 'status') await viewStatus(); else if (tab === 'feeds') await viewFeeds(); else if (tab === 'links') await viewLinks(); else if (tab === 'ssl') await viewSSL(); } catch (e) { if (e.status === 401 || e.status === 403) { logout('会话已失效,请重新登录'); return; } if (state.tab !== tab) return; // 已切走,别用旧错误覆盖新视图 v.innerHTML = '
出错了:' + 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) + '' + roleTag(r) + '' + esc(r.email) + '' + nfmt(r.comment_count) + '' + (r.badge_name ? '' + esc(r.badge_name) + '' : '—') + '' + esc(r.last_ip || '—') + '
' + pager('u', u, '个用户') : '
没有匹配的用户
'; box.innerHTML = html; } /** * 角色下拉 —— 一个控件同时表达四档。 * 拆成「管理员 checkbox + 编辑 checkbox + SSL checkbox」会有八种组合, * 其中七种是废的。 */ function roleSelectHtml(u, id) { const cur = roleOf(u); const self = state.me && state.me.id != null && String(u.id) === String(state.me.id); const opt = (v, label) => ''; return ''; } /** 新建用户:体量上跟「编辑用户」不是一回事,单独一个弹窗 */ function newUser() { modal('新建用户', '' + '' + '' + roleSelectHtml({ role: 'editor' }, 'nuRole') + '' + '
编辑角色登录后只会看到「文章编辑」,列表里只有自己写的文章,' + '发布时也只提交自己名下那几篇。
' + ''); $('#nuSave').onclick = async () => { const body = { name: $('#nuName').value.trim(), email: $('#nuMail').value.trim(), password: $('#nuPw').value, role: $('#nuRole').value, title_name: $('#nuTitle').value.trim(), }; if (!body.name || !body.email) { toast('✕ 昵称和登录账号都要填', true); return; } if (!body.password) { toast('✕ 要设一个初始密码,否则 TA 登不进来', true); return; } const b = $('#nuSave'); b.dataset.busy = '1'; try { await api('/users', { method: 'POST', body: body }); closeModal(); toast('✓ 已创建'); await loadUsers(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; } function editUser(id) { (function () { const u = (state.u.rows || []).filter((x) => x.id === id)[0]; if (!u) { toast('用户不存在,请刷新列表', true); return; } modal('编辑用户 #' + id, '' + '' + '' + '
' + '' + '' + '
' + '' + '' + roleSelectHtml(u, 'euRole') + ''); $('#euSave').onclick = async () => { const body = { name: $('#euName').value.trim(), email: $('#euMail').value.trim(), link: $('#euLink').value.trim(), badge_name: $('#euBn').value.trim(), badge_color: $('#euBc').value.trim(), title_name: $('#euTitle').value.trim(), role: $('#euRole').value, }; const pw = $('#euPw').value; if (pw) body.password = pw; const b = $('#euSave'); b.dataset.busy = '1'; try { await api('/users/' + id, { method: 'PUT', body: body }); closeModal(); toast('✓ 已保存'); loadUsers(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; })(); } // ============================================================ 视图:设置 // // 表单化:schema 由后端 /settings/form 下发(唯一事实源在 src/lib/settings-form.ts), // 前端只负责画表单和收值,完全不拼 YAML。schema 之外的键放在 extra 里原样传回, // 保证「表单化」永远不会静默丢配置。需要手改 YAML 时展开底部「高级」区。 async function viewSettings() { const [sites, form] = await Promise.all([api('/sites'), api('/settings/form')]); state.sites = sites || []; state.sf = { schema: form.schema || [], values: form.values || {}, extra: form.extra || {}, custom: !!form.custom, advTouched: false, }; if (state.tab !== 'settings') return; const siteRows = state.sites.map((s) => '
' + '' + esc(s.name) + ' ' + '' + esc((s.urls || []).join(', ')) + '' + '' + '
').join('') || '
暂无站点
'; const groups = state.sf.schema.map((g) => { const fields = g.fields.map(sfField).join(''); return '
' + '

' + esc(g.title) + '

' + (g.desc ? '' + esc(g.desc) + '' : '') + '
' + '
' + fields + '
'; }).join(''); $('#view').innerHTML = head('设置', 'SETTINGS', '站点与前台配置 · 改完点右上角保存', '') + '

站点

' + '
' + siteRows + '
' + groups + '
' + '高级:直接编辑原始 YAML(动了这个就以它为准,表单不再参与保存)' + '
展开时加载…
' + '
'; const adv = $('#sfAdv'); adv.addEventListener('toggle', async () => { const box = $('#sfAdvBox'); if (!adv.open || !box || box.dataset.loaded) return; try { const d = await api('/settings'); box.innerHTML = '' + '

这是服务端实际保存的完整配置。' + '保存后仍会按同一套规则回填(frontend.* / captcha / human_check / moderator / ip_region / site_default)。

'; $('#sfYaml').value = d.yaml || ''; $('#sfYaml').addEventListener('input', () => { state.sf.advTouched = true; }); box.dataset.loaded = '1'; } catch (e) { box.innerHTML = '
读取失败:' + esc(e.message) + '
'; } }); } /** 单个表单控件(bool=开关行,select/number/text=标准输入) */ function sfField(f) { const t = f.type || 'text'; const v = state.sf.values[f.path]; const id = 'sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_'); if (t === 'bool') { return '
'; } let ctl; if (t === 'select') { ctl = ''; } else if (t === 'number') { ctl = ''; } else { ctl = ''; } return '
'; } /** 表单 → 扁平的 { path: value }(类型按 schema 归位) */ function sfCollect() { const out = {}; for (const g of state.sf.schema) { for (const f of g.fields) { const el = document.getElementById('sf-' + f.path.replace(/[^A-Za-z0-9]/g, '_')); if (!el) continue; if ((f.type || 'text') === 'bool') out[f.path] = el.checked; else if ((f.type || 'text') === 'number') out[f.path] = el.value.trim() === '' ? '' : Number(el.value); else out[f.path] = el.value; } } return out; } async function saveSettings() { const b = $('[data-act="sf-save"]'); if (b) b.dataset.busy = '1'; try { const adv = $('#sfYaml'); if (adv && state.sf.advTouched) { await api('/settings', { method: 'PUT', body: { yaml: adv.value } }); toast('✓ 已保存(按原始 YAML)'); } else { await api('/settings/form', { method: 'PUT', body: { values: sfCollect(), extra: state.sf.extra } }); toast('✓ 已保存'); } await viewSettings(); } catch (e) { toast('✕ ' + e.message, true); const b2 = $('[data-act="sf-save"]'); if (b2) delete b2.dataset.busy; } } function editSite(id) { const s = state.sites.filter((x) => x.id === id)[0]; if (!s) return; modal('编辑站点 #' + id, '' + '' + '

改站点名会让前台 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; } }; } // ============================================================ 视图:文章编辑 // // 全线走 Worker 的 /editor/* 反代:浏览器只跟 Worker 说话,编辑器令牌在 Worker 侧 // 注入,所以前端不需要任何额外凭据(复用后台已有的登录会话即可)。 // // ★ 定位键用 id(content 下的目录名,全仓唯一),**不是 slug** —— // 仓库里有 5 组 slug 撞名的文章,按 slug 打开会静默地打开/保存到另一篇文件上。 // // ★ 交互形态对齐 write-server(用户用惯的那套): // · 列表 = 卡片行:标题可点 + 草稿/已发布徽章,hover 出 预览/编辑/删除 // · 新建 = 直接进编辑器(不再弹窗),输入标题时自动生成时间戳 slug // · 编辑器 = 紧凑表单(标题+slug 一行、日期+作者+布局+分类一行、标签一行) // + 正文 + 右上角 保存 / 发布 /** 界面上可编辑的 front matter 字段(其余键一律只读展示、原样保留) */ const PO_FM_FIELDS = [ { k: 'title', label: '标题' }, { k: 'slug', label: 'slug(决定网址 /.html)' }, { k: 'date', label: '日期' }, { k: 'author', label: '作者' }, { k: 'layout', label: '布局' }, { k: 'description', label: '摘要' }, ]; const PO_KNOWN = new Set([...PO_FM_FIELDS.map((f) => f.k), 'categories', 'tags', 'draft']); const arrText = (v) => (Array.isArray(v) ? v.join(', ') : v == null ? '' : String(v)); const textArr = (s) => String(s || '').split(/[,,]/).map((x) => x.trim()).filter(Boolean); /** * 新文章的默认 slug:YYYYMMDDHHMMSS(本地时间 14 位)。 * 与 write-server 的 makeSlug(src/lib/bot/helpers.ts)一字不差 —— * 仓库里 2026-06-22 之后的文章全是这个风格,不要用标题当 slug。 */ function makeSlug(d = new Date()) { const p = (n) => String(n).padStart(2, '0'); return ( d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()) ); } /** 目录名里的标题段(与 write-server 的 computeDirPath 同一套规则) */ function titlePartOf(t) { return ( String(t || '') .replace(/[^\w一-鿿㐀-䶿]+/g, '-') .replace(/^-+|-+$/g, '') .toLowerCase() || 'post' ); } const poFmtDate = (d) => (d ? String(d).slice(0, 10).replace(/-/g, '/') : '—'); async function viewPosts() { if (state.tab !== 'posts') return; if (state.ed) return renderEditor(); await loadPosts(); } async function loadPosts() { const po = state.po; let d; try { d = await api('/editor/posts?' + qs({ q: po.q, page: po.page, perPage: po.perPage })); } catch (e) { if (state.tab !== 'posts') return; $('#view').innerHTML = head('文章编辑', '', '') + '
加载不了文章列表' + esc(e.message) + '
'; return; } po.rows = d.posts || []; po.total = d.total || 0; po.totalPages = d.totalPages || 1; po.page = d.page || 1; if (state.tab !== 'posts' || state.ed) return; renderPostList(); } function renderPostList() { const po = state.po; const rows = po.rows.map((r) => { const badges = [ r.draft ? '草稿' : '已发布', r.slugConflict ? 'URL 冲突' : '', r.parseable === false ? 'FM 异常' : '', ].filter(Boolean).join(' '); const meta = [poFmtDate(r.date), (r.categories || []).join('、')].filter(Boolean).join(' · '); return '
' + '
' + '
' + '' + esc(r.title) + '' + badges + '
' + '
' + esc(meta) + '
' + '
' + '
' + (r.url ? '' + ICONS.eye + '' : '') + '' + '' + '
' + '
'; }).join(''); $('#view').innerHTML = head('文章编辑', '', '共 ' + nfmt(po.total) + ' 篇 · 直接改仓库 content/posts 下的 Markdown', '' + '') + '
' + '' + '
' + (po.rows.length ? '
' + rows + '
' + pagerPo() : '
' + (po.q ? '没有匹配的文章' : '还没有文章') + '' + (po.q ? '换个关键词试试' : '点右上角「+ 新建」写第一篇') + '
'); const q = $('#poq'); let tmr = null; q.oninput = () => { clearTimeout(tmr); tmr = setTimeout(() => { po.q = q.value.trim(); po.page = 1; loadPosts(); }, 420); }; } function pagerPo() { const po = state.po; return '
' + '' + '第 ' + po.page + ' / ' + po.totalPages + ' 页 · 共 ' + nfmt(po.total) + ' 篇' + '' + '
'; } async function openPost(id) { $('#view').innerHTML = '
加载中…
'; let post; try { post = await api('/editor/posts/' + encodeURIComponent(id)); } catch (e) { if (state.tab !== 'posts') return; $('#view').innerHTML = head('打开失败', '', esc(id)) + '
' + esc(e.message) + '' + '

文章还在服务器上没动过,直接重试即可;连续失败多半是本机到 Cloudflare 这一跳不稳。

' + '
' + '
' + ' ' + '' + '
'; return; } state.ed = { id: post.id, post, dirty: false, uploading: 0, }; renderEditor(); } /** 新建:直接进编辑器(对齐 write-server 的「撰写文章」页),第一次保存时才真正创建 */ function newPost() { state.ed = { isNew: true, id: '', slugTouched: false, post: { id: '', title: '', slug: '', date: new Date().toISOString().slice(0, 10), author: '', layout: 'post', categories: [], tags: [], draft: true, url: '', filePath: '', eol: '\n', bodyLead: '', frontMatter: {}, frontMatterRaw: null, frontMatterError: null, content: '', }, dirty: false, uploading: 0, }; renderEditor(); } function renderEditor() { const ed = state.ed; if (!ed) { loadPosts(); return; } const p = ed.post; const orig = p.frontMatter || {}; const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError); // 编辑角色:作者字段只读。后端本来就不采纳它传的 author/author_id // (见 editor-api/src/posts.mjs),这里显示自己的名字只是为了别让人白填。 const roAuthor = isEditor(); const field = (k, label, cls, ph, readonly) => { let v = orig[k] == null ? '' : orig[k]; if (readonly && !v) v = (state.me && state.me.name) || ''; return ''; }; const metaForm = '
' + field('title', '标题', 'po-f-title', '文章标题') + field('slug', 'Slug(自动生成)', 'po-f-slug', 'YYYYMMDDHHMMSS') + '
' + '
' + field('date', '日期', 'po-f-date') + field('author', '作者', 'po-f-author', '', roAuthor) + field('layout', '布局', 'po-f-layout') + '' + '
' + '' + field('description', '摘要(可选)') + '
'; const extraKeys = Object.keys(orig).filter((k) => !PO_KNOWN.has(k)); const extra = extraKeys.length ? '
其他字段(只读保留,不会被动过)' + extraKeys.map((k) => { const v = orig[k]; const t = Array.isArray(v) ? '列表' : v === null ? '空' : typeof v; const s = Array.isArray(v) ? v.join(', ') : String(v == null ? '' : v); return '
' + esc(k) + '' + esc(t) + '' + '' + esc(truncate(s.replace(/\s+/g, ' '), 70)) + '
'; }).join('') + '
' : ''; const warn = [ p.slugConflict ? '
这篇文章的 slug「' + esc(p.slug) + '」与另一篇重复。Hugo 用 /:slug 生成网址,' + '撞名时线上只会剩一篇 —— 改 slug 前先确认另一篇要不要一起处理。
' : '', fmBroken ? '
front matter 没法完整解析(' + esc(p.frontMatterError || '没有 front matter') + ')。' + '为了不写坏这篇,字段区已停用,保存时只会动正文。
' : '', ].filter(Boolean).join(''); $('#view').innerHTML = head(ed.isNew ? '新建文章' : (p.title || ed.id), '', ed.isNew ? '写完点「保存」才真正落盘' : ('正文 ' + String(p.content || '').length + ' 字 · ' + esc(p.id)), '' + (ed.isNew ? '' : '' + '')) + (!ed.isNew && p.url ? '
线上地址 ' + esc(p.url) + '' + '' + esc(p.filePath) + '
' : '') + warn + '
' + '
' + '

文章信息

' + metaForm + extra + '
' + '
' + '

正文 · MARKDOWN

' + '' + '' + '可直接 Ctrl+V 粘贴截图,自动存到文章目录
' + '
' + '' + '
' + '' + '' + '' + '尚未修改' + 'Ctrl / ⌘ + S 保存
' + '
' + '
' + '' + '
'; const ta = $('#poBody'); ta.value = p.content || ''; if (fmBroken) { $$('#view [data-fm], #poCats, #poTags, #poDraft').forEach((el) => { el.disabled = true; }); } // 新建:标题一打字就自动刷新时间戳 slug(用户手动改过 slug 就不再动) const updateFolder = () => { const box = $('#poFolder'); if (!box) return; const g = (k) => { const el = $('#view [data-fm="' + k + '"]'); return el ? el.value.trim() : ''; }; const d = g('date') || new Date().toISOString().slice(0, 10); const slug = g('slug') || makeSlug(); box.textContent = '保存位置:content/posts/' + d.slice(0, 4) + '/' + d.slice(0, 10) + '-' + (titlePartOf(g('title')) || 'post') + '-' + slug + '/index.md'; }; const titleEl = $('#view [data-fm="title"]'); const slugEl = $('#view [data-fm="slug"]'); if (ed.isNew && titleEl && slugEl) { if (!slugEl.value) slugEl.value = makeSlug(); slugEl.addEventListener('input', () => { ed.slugTouched = true; updateFolder(); markDirty(); }); titleEl.addEventListener('input', () => { if (!ed.slugTouched) slugEl.value = makeSlug(); updateFolder(); }); updateFolder(); } ta.addEventListener('input', markDirty); ta.addEventListener('paste', onPasteImage, true); ['poCats', 'poTags', 'poDraft'].forEach((id) => { const el = $('#' + id); if (el) el.addEventListener('input', markDirty); }); $$('#view [data-fm]').forEach((el) => el.addEventListener('input', markDirty)); // 快捷插入面板(对齐 write-server 的 ShortcutPanel) $('.po-side').addEventListener('click', (e) => { const b = e.target.closest('[data-sc]'); if (!b) return; const fn = PO_SHORTCUTS[b.dataset.sc]; if (fn) { fn(); markDirty(); } }); // 插入图片按钮 → 文件选择器(粘贴截图之外的第二条路) const imgBtn = $('[data-act="po-img"]'); const imgFile = $('#poImgFile'); if (imgBtn && imgFile) { imgBtn.addEventListener('click', () => imgFile.click()); imgFile.addEventListener('change', async () => { for (const f of Array.from(imgFile.files || [])) await insertImage(f); imgFile.value = ''; }); } } /** 选中文字则包裹,没选中则插入「before + 占位 + after」并选中占位(行为照抄 write-server) */ function poWrap(before, after, placeholder) { const ta = $('#poBody'); if (!ta) return; const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; const e = ta.selectionEnd == null ? s : ta.selectionEnd; const sel = ta.value.slice(s, e) || placeholder; ta.value = ta.value.slice(0, s) + before + sel + after + ta.value.slice(e); requestAnimationFrame(() => { const pos = s + before.length + sel.length + after.length; ta.focus(); ta.setSelectionRange(s + before.length, pos); }); } /** 在光标处整块插入模板(短代码等多行结构) */ function poBlock(tpl) { const ta = $('#poBody'); if (!ta) return; const s = ta.selectionStart == null ? ta.value.length : ta.selectionStart; ta.value = ta.value.slice(0, s) + tpl + ta.value.slice(s); requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(s + tpl.length, s + tpl.length); }); } /** * 快捷插入面板:短代码模板与 write-server 的 ShortcutPanel.tsx 完全一致。 * 本博客的 Hugo 短代码在 themes/ying/layouts/shortcodes/(bilibili/grid/video 均实装)。 */ function shortcutPanelHtml() { return '

快捷插入

' + '
' + Object.keys(PO_SHORTCUTS).map((label) => '' ).join('') + '
' + '

说明

    ' + '
  • 选中文字后点按钮可包裹
  • ' + '
  • 未选中则插入模板 + 占位文字
  • ' + '
  • 视频需替换 src 中的路径
  • ' + '
  • B站/YouTube 替换 BV号/ID
  • ' + '
'; } const PO_SHORTCUT_GLYPH = { H2: 'H2', H3: 'H3', 粗体: 'B', 代码块: '{}', 行内代码: '`', 引用: '❝', 图片: '▣', 图片网格: '▦', 本地视频: '▶', B站视频: '▶', YouTube: '▶', 链接: '⧉', 分割线: '—', }; const PO_SHORTCUTS = { H2: () => poWrap('## ', '\n', '二级标题'), H3: () => poWrap('### ', '\n', '三级标题'), 粗体: () => poWrap('**', '**', '粗体文本'), 代码块: () => poWrap('```\n', '\n```\n', '代码'), 行内代码: () => poWrap('`', '`', 'code'), 引用: () => poWrap('> ', '\n', '引用内容'), 图片: () => poWrap('![', ']()', '描述'), 图片网格: () => poBlock('{{< grid cols="3" >}}\n![描述]()\n![描述]()\n![描述]()\n{{< /grid >}}\n'), 本地视频: () => poBlock('{{< video src="/video/example.mp4" caption="视频说明" >}}\n'), B站视频: () => poBlock('{{< bilibili BV1xx411c7mD >}}\n'), YouTube: () => poBlock('{{< youtube dQw4w9WgXcQ >}}\n'), 链接: () => poWrap('[', ']()', '链接文本'), 分割线: () => poBlock('\n---\n'), }; /** 表单 → 当前值(只在保存时调用) */ function readForm() { const fm = {}; $$('#view [data-fm]').forEach((el) => { fm[el.dataset.fm] = el.value; }); const d = $('#poDraft'); if (d) fm.draft = d.checked; const c = $('#poCats'); const t = $('#poTags'); if (c) fm.categories = textArr(c.value); if (t) fm.tags = textArr(t.value); return fm; } /** * 只回传**真正变了的**字段。 * 这一点很关键:后端是「没动 front matter 就原文照抄」,一旦我们无脑回传全部字段, * 那些值相等的字段也会让深比较失败,于是整块 front matter 被重新序列化 —— * 引号、块标量写法都会变,白产生一堆 diff。 */ function fmPatch() { const ed = state.ed; if (!ed) return {}; const orig = ed.post.frontMatter; if (!orig) return {}; // front matter 解析不了 → 一个字段都不碰 const now = readForm(); const out = {}; for (const f of PO_FM_FIELDS) { const k = f.k; const has = k in orig; const raw = (now[k] == null ? '' : String(now[k])).trim(); if (!has && raw === '') continue; // 原本没有、也没填 → 不新增 const before = orig[k] == null ? '' : String(orig[k]).trim(); if (raw !== before) out[k] = raw; } if ((now.draft === true) !== (orig.draft === true)) out.draft = now.draft === true; for (const k of ['categories', 'tags']) { const a = now[k] || []; const b = Array.isArray(orig[k]) ? orig[k] : []; if (JSON.stringify(a) !== JSON.stringify(b)) out[k] = a; } return out; } function markDirty() { const ed = state.ed; if (!ed) return; const changed = Object.keys(fmPatch()).length > 0 || ($('#poBody') && $('#poBody').value !== (ed.post.content || '')); ed.dirty = changed; const s = $('#poStat'); if (s) s.textContent = changed ? '● 有未保存的改动' : '尚未修改'; } /** 离开编辑器:有未保存改动就先拦一下,别让人默默丢字 */ function leaveEditor(next) { const ed = state.ed; if (ed && ed.dirty) { confirmBox('有未保存的改动', '正文或字段还没保存,离开就丢了。确定离开?', async () => { state.ed = null; await next(); }); return; } state.ed = null; next(); } /** * 保存当前文章。新建走 POST(第一次保存才落盘),已有走 PUT(只传变更字段)。 * 返回 true/false 表示成功与否;opts.silent 不弹 toast,opts.noReload 不重新拉取。 */ async function saveCurrentPost(opts = {}) { const ed = state.ed; if (!ed) return false; const btn = $('[data-act="po-save"]'); if (btn) btn.dataset.busy = '1'; try { const content = $('#poBody').value; if (ed.isNew) { const fm = readForm(); if (!String(fm.title || '').trim()) { toast('✕ 先写个标题再保存', true); return false; } if (!String(fm.slug || '').trim()) fm.slug = makeSlug(); // 兜底,正常已被前端预填 const r = await api('/editor/posts', { method: 'POST', body: { frontMatter: fm, content } }); toast('✓ 已创建:' + r.id); state.ed = null; await openPost(r.id); } else { await api('/editor/posts/' + encodeURIComponent(ed.id), { method: 'PUT', body: { content, frontMatter: fmPatch() }, }); if (!opts.silent) toast('✓ 已保存到文件'); if (!opts.noReload) await openPost(ed.id); // 以后端磁盘上的内容为准重载,避免界面状态和文件漂移 } return true; } catch (e) { toast('✕ ' + e.message, true); return false; } finally { const b2 = $('[data-act="po-save"]'); if (b2) delete b2.dataset.busy; } } /** 发布 = 先保存,再弹发布确认(commit + push,会触发线上构建) */ async function publishCurrentPost() { if (!state.ed) return; if (await saveCurrentPost({ silent: true, noReload: true })) openGitModal(); } /** 粘贴图片 → 传到文章同级目录 → 插入相对路径(仓库现行写法就是裸文件名) */ async function onPasteImage(e) { const ed = state.ed; if (!ed) return; const items = (e.clipboardData && e.clipboardData.items) || []; let f = null; for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file' && String(items[i].type).startsWith('image/')) { f = items[i].getAsFile(); break; } } if (!f) return; // 纯文字粘贴走默认行为 e.preventDefault(); await insertImage(f); } async function insertImage(file) { const ed = state.ed; const ta = $('#poBody'); if (!ed || !ta) return; const stat = $('#poStat'); const name = file.name || ('paste.' + (String(file.type).split('/')[1] || 'png')); if (stat) stat.textContent = '正在上传 ' + name + ' …'; let res; try { res = await fetch(API + '/editor/upload?name=' + encodeURIComponent(name) + '&key=' + encodeURIComponent(ed.id), { method: 'POST', headers: { 'Content-Type': file.type || 'image/png', ...(TOKEN ? { Authorization: 'Bearer ' + TOKEN } : {}) }, body: file, }); } catch (e) { if (stat) stat.textContent = '上传失败'; toast('✕ 图片上传失败:' + e.message, true); return; } const txt = await res.text(); let d = null; try { d = txt ? JSON.parse(txt) : null; } catch (e) { /* 透传 */ } if (!res.ok || !d || !d.fileName) { if (stat) stat.textContent = '上传失败'; toast('✕ ' + ((d && (d.error || d.msg)) || '上传失败 HTTP ' + res.status), true); return; } // Markdown 里图片必须前后各空一行才是独立段落 —— 统一规范化,避免「图片和文字粘在一起」 const at = ta.selectionStart == null ? ta.value.length : ta.selectionStart; const before = ta.value.slice(0, at).replace(/\n+$/, ''); const after = ta.value.slice(at).replace(/^\n+/, ''); const md = '![](' + d.fileName + ')'; ta.value = (before ? before + '\n\n' : '') + md + '\n\n' + after; const pos = (before ? before.length + 2 : 0) + md.length + 2; ta.focus(); ta.setSelectionRange(pos, pos); ed.dirty = true; if (stat) stat.textContent = '● 已插入图片 ' + d.fileName + '(' + Math.round((d.size || 0) / 1024) + ' KB),记得保存'; markDirty(); } /** 发布 / 同步:把改动提交并推到主仓(会触发线上构建) */ async function openGitModal() { modal('发布与同步', '
读取仓库状态…
'); let st; try { st = await api('/editor/git/status'); } catch (e) { $('#modalBody').innerHTML = '
读不到仓库状态' + esc(e.message) + '
'; return; } const files = (st.files || []).map((f) => '
' + esc(f.slice(0, 2).trim() || '·') + '' + '' + esc(f.slice(2).trim().replace(/^"|"$/g, '')) + '
').join('') || '
没有改动
'; $('#modalBody').innerHTML = '
' + '
分支
' + esc(st.branch) + '
' + '
改动
' + (st.dirty ? '' + nfmt(st.changed) + ' 个文件待发布' : '工作区干净') + '
' + '
最近提交
' + esc(st.lastCommit || '—') + '
' + '
' + '
待发布文件' + (st.scoped ? '(只列你自己名下的文章)' : '') + '
' + '
' + files + '
' + '' + '
' + (st.scoped ? '发布 = 只提交**你自己名下的文章目录**(别人还没发布的改动不会被顺走),再 commit + push 到主仓触发线上构建。' : '发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。') + '
' + '
' + '' + '' + '
'; $('#poSync').onclick = async () => { const b = $('#poSync'); b.dataset.busy = '1'; try { const r = await api('/editor/git/sync', { method: 'POST' }); toast('✓ 已同步:' + String(r.log || '').split('\n').slice(-1)[0].slice(0, 60)); closeModal(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; $('#poPub').onclick = async () => { const b = $('#poPub'); b.dataset.busy = '1'; try { const r = await api('/editor/git/publish', { method: 'POST', body: { message: ($('#poMsg') || {}).value || '' } }); closeModal(); toast('✓ 已发布到 ' + (r.pushed || []).join(' / ') + (r.warning ? '(部分备份远端失败,见控制台)' : '')); if (r.warning) console.warn('[editor] ' + r.warning); clearDirtyFlag(); if (state.ed && !state.ed.isNew) await openPost(state.ed.id); // 重新拉一遍,让状态栏回到「尚未修改」 } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; } /** 发布成功后前端这个「未保存」标记就该清了(文件已经进到 commit 里) */ function clearDirtyFlag() { if (!state.ed) return; state.ed.dirty = false; const s = $('#poStat'); if (s) s.textContent = '尚未修改'; } // ============================================================ 侧边栏抽屉 / 主题 function closeSidebar() { const sb = $('#sidebar'), sc = $('#scrim'); if (sb) sb.classList.remove('open'); if (sc) sc.hidden = true; } function toggleSidebar() { const sb = $('#sidebar'), sc = $('#scrim'); if (!sb) return; const open = sb.classList.toggle('open'); if (sc) sc.hidden = !open; } function applyTheme(t) { if (t === 'dark') document.documentElement.dataset.theme = 'dark'; else delete document.documentElement.dataset.theme; const b = $('#themeBtn'); if (b) b.innerHTML = ICONS.moon + '' + (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); } // ============================================================ 视图:证书管家 // // 数据全部来自 /api/v2/ssl/*(Worker 侧,鉴权放行 admin + ssl)。 // 国内机模式下同源请求会被本机 editor-api 反代到 Cloudflare // (见 editor-api/server.mjs 的 SSL_PATH_PREFIXES),前端代码完全一致。 // // ★ 前端**不做任何权限判断**:能不能用由服务端的 auth() 决定, // 这里只负责「服务端 403 了就别把界面留在半死状态」。 const SSL_ACCESS_TYPES = [ { v: 'tencentcloud', label: '腾讯云 CAM(DNSPod DNS-01)', fields: [['secretId', 'SecretId'], ['secretKey', 'SecretKey']] }, { v: 'cloudflare', label: 'Cloudflare API Token(DNS + 部署)', fields: [['apiToken', 'API Token']] }, { v: 'dogecloud', label: '多吉云 AK/SK(CDN 证书部署)', fields: [['accessKey', 'AccessKey'], ['secretKey', 'SecretKey']] }, { v: '1panel', label: '1Panel 面板(API Key)', fields: [['serverUrl', '面板地址'], ['apiKey', 'API Key']] }, { v: 'acme-eab', label: 'ACME EAB(LiteSSL / ZeroSSL)', fields: [['directoryUrl', 'Directory URL'], ['eabKid', 'EAB Kid'], ['eabHmacKey', 'EAB HmacKey']] }, ]; const SSL_DEPLOYS = [ { v: 'dogecloud', label: '多吉云 CDN' }, { v: '1panel', label: '1Panel' }, { v: 'tencentcloud-eo', label: '腾讯云 EdgeOne' }, ]; function sslApi(path, opt) { return api('/ssl' + path, opt); } async function viewSSL() { const v = $('#view'); v.innerHTML = '
正在读证书状态…
'; const ov = await sslApi('/overview'); if (state.tab !== 'ssl') return; state.ssl.ov = ov; state.ssl.accessItems = null; // 每次进这一页都重新拉,免得拿着过期列表 v.innerHTML = head('证书管家', 'CERT KEEPER', '域名 ' + ov.domains.length + ' 个 · 提醒阈值 ' + ov.notify.daysBefore + ' 天', '' + '' + '' + '' + '' + '' + (ov.mailEnabled ? '' : '邮件未配置')) + sslWarnBanner(ov) + '
' + sslDomainTable(ov) + '
' + '
' + '

凭据(密钥只显示尾 4 位)

' + '' + '
加载中…
' + '

最近执行记录

' + '' + '
加载中…
' + '
' + '

通知设置

' + '
' + sslNotifyForm(ov) + '
'; await Promise.all([loadSslAccess(), loadSslLog()]); } /** 顶部横幅:把最需要行动的信息顶到最上面 */ function sslWarnBanner(ov) { const expiring = ov.domains.filter((d) => d.level === 'danger' || d.level === 'warn'); const noCert = ov.domains.filter((d) => d.hasCert === false && !d.disabled); if (!expiring.length && !noCert.length) return ''; const parts = []; if (expiring.length) { parts.push('' + expiring.length + ' 个域名即将或已经过期:' + expiring.map((d) => esc(d.name) + '(' + (d.daysLeft == null ? '无记录' : d.daysLeft + ' 天') + ')').join('、')); } if (noCert.length) { parts.push('' + noCert.length + ' 个域名还没有证书记录(只影响监控,不影响线上):' + noCert.map((d) => esc(d.name)).join('、')); } return '
' + parts.join('
') + '
'; } function sslLevelTag(d) { if (d.disabled) return '已停用'; if (!d.hasCert) return '无记录'; if (d.level === 'danger') return '' + (d.daysLeft < 0 ? '已过期' : '紧急') + ''; if (d.level === 'warn') return '临期'; return '正常'; } function sslDomainTable(ov) { if (!ov.domains.length) { return '
还没有配置任何域名' + '点右上角「+ 域名」加第一个。域名不会立刻签发证书,只是纳入监控与后续自动化。
'; } const rows = ov.domains.map((d) => '' + '' + esc(d.name) + '' + sslLevelTag(d) + '
' + esc(d.san.join(' · ')) + '
' + '' + (d.daysLeft == null ? '—' : (d.daysLeft < 0 ? '' + d.daysLeft + '' : d.daysLeft)) + '' + '' + esc(d.issuer || '—') + '
' + esc(d.expireText || '—') + '
' + '' + esc(d.dns || '—') + '' + '' + (d.deploy.length ? d.deploy.map((t) => '' + esc(t) + '').join(' ') : '—') + '' + '' + ' ' + ' ' + ' ' + '' + '' + '' ).join(''); return '
' + '' + '' + rows + '
域名 / SAN剩余天签发方 / 到期DNS 凭据部署目标
' + (ov.orphans.length ? '
库里有 ' + ov.orphans.length + ' 条孤儿证书记录(域名已从配置里删掉):' + esc(ov.orphans.join('、')) + '。不影响使用,要清理点上面的「登记」旁的删除,或忽略即可。
' : ''); } async function loadSslAccess() { const box = $('#ssl-access'); if (!box) return; const d = await sslApi('/access'); if (state.tab !== 'ssl' || !document.body.contains(box)) return; const items = d.items || []; state.ssl.accessItems = items; // 弹窗要用(凭据下拉 + 脱敏回显) if (!items.length) { box.innerHTML = '
还没有凭据' + 'DNS-01 需要一把能改 DNS 的钥匙(腾讯云 CAM / Cloudflare Token),部署需要多吉云和 1Panel 的。' + '密钥用 TOKEN_SECRET 派生密钥 AES-GCM 加密后落 KV,列表只回显尾 4 位。
'; return; } box.innerHTML = '
' + items.map((a) => '
' + '' + esc(a.name) + '' + ' ' + esc(a.type || '未知') + '' + (a.unreadable ? ' 读不出' : '') + '
' + esc(Object.entries(a.fields).map(([k, v]) => k + '=' + v).join(' ') || (a.note || '(未填密钥)')) + '
' + ' ' + '' + '
').join('') + '
'; } /** 打开弹窗前确保凭据列表已加载(正常路径下 viewSSL 已经拉过,这里只是兜底) */ async function ensureAccessLoaded() { if (state.ssl.accessItems) return; const d = await sslApi('/access'); state.ssl.accessItems = d.items || []; } async function loadSslLog() { const box = $('#ssl-log'); if (!box) return; const d = await sslApi('/log?limit=40'); if (state.tab !== 'ssl' || !document.body.contains(box)) return; const items = d.items || []; box.innerHTML = items.length ? '
' + items.map((l) => { const color = l.level === 'error' ? 'var(--danger)' : l.level === 'warn' ? 'var(--warning)' : 'var(--muted)'; return '
' + esc(new Date(l.at + 8 * 3600e3).toISOString().slice(5, 16).replace('T', ' ')) + '' + '[' + esc(l.action) + '] ' + esc(l.message) + '
'; }).join('') + '
' : '
还没有执行记录
'; } function sslNotifyForm(ov) { return '
' + '' + '' + '' + '
'; } /** 域名的编辑弹窗。新建时 opts.name 为空 */ function sslDomainModal(name) { const ov = state.ssl.ov || { domains: [], notify: { emails: [], daysBefore: 30 } }; const cur = (ov.domains || []).find((d) => d.name === name) || { name: '', san: [], dns: '', deploy: [], dogecloud_domains: [], one_panel_sites: [], disabled: false, }; const isNew = !name; const accessNames = (state.ssl.accessItems || []).map((a) => a.name); if (!accessNames.length) { modal('先建凭据', '

配域名之前得先有 DNS 凭据 —— DNS-01 验证要往你的域名下写 TXT 记录。' + '先去「凭据」那一栏加一把腾讯云 CAM 或 Cloudflare Token。

' + '
'); return; } const accessOpts = accessNames.map((n) => '').join(''); modal(isNew ? '新增域名' : '编辑「' + name + '」', '' + '' + '' + '' + '' + '' + '' + '
' + (isNew ? '' : '') + '' + '' + '
'); $('#sdSave').onclick = async () => { const domains = JSON.parse(JSON.stringify((state.ssl.ov && state.ssl.ov.domains) || [])); const clean = (s) => String(s || '').split(/[,,\n]/).map((x) => x.trim()).filter(Boolean); const next = { name: $('#sdName').value.trim().toLowerCase(), san: clean($('#sdSan').value).map((s) => s.toLowerCase()), dns: $('#sdDns').value, deploy: SSL_DEPLOYS.filter((t) => $('#sdDeploy_' + t.v).checked).map((t) => t.v), dogecloud_domains: clean($('#sdDoge').value), one_panel_sites: clean($('#sdPanel').value), ...($('#sdOff').checked ? { disabled: true } : {}), }; if (!next.name) { toast('✕ 主域名要填', true); return; } if (!next.san.length) next.san = [next.name]; const i = domains.findIndex((d) => d.name === name || d.name === next.name); if (i >= 0) domains[i] = next; else domains.push(next); const b = $('#sdSave'); b.dataset.busy = '1'; try { await sslApi('/config', { method: 'POST', body: { version: 1, notify: state.ssl.ov.notify, domains }, }); closeModal(); toast('✓ 已保存'); await viewSSL(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; } /** 凭据的新建 / 编辑弹窗 */ function sslAccessModal(name) { const cur = (state.ssl.accessItems || []).find((a) => a.name === name); const isNew = !name; const type = cur ? cur.type : 'tencentcloud'; const def = SSL_ACCESS_TYPES.find((t) => t.v === type) || SSL_ACCESS_TYPES[0]; const typeOpts = SSL_ACCESS_TYPES.map((t) => '').join(''); const fieldHtml = (fields) => fields.map(([k, label]) => { const shown = cur && cur.fields && cur.fields[k] ? cur.fields[k] : ''; return ''; }).join(''); modal(isNew ? '新增凭据' : '编辑凭据「' + name + '」', '' + '' + '
' + fieldHtml(def.fields) + '
' + '' + '
' + '密钥用 TOKEN_SECRET 派生的密钥 AES-GCM 加密后才写进 KV,列表接口只回显尾 4 位。' + '编辑时留空的字段保持不变 —— 不用为了改个备注重新粘一遍密钥。
' + '
' + '' + '' + '
'); const rerenderFields = () => { const t = SSL_ACCESS_TYPES.find((x) => x.v === $('#saType').value) || SSL_ACCESS_TYPES[0]; const keep = {}; $$('#saFields [data-field]').forEach((i) => { keep[i.dataset.field] = i.value; }); $('#saFields').innerHTML = t.fields.map(([k, label]) => '').join(''); }; $('#saType').onchange = rerenderFields; $('#saSave').onclick = async () => { const fields = {}; $$('#saFields [data-field]').forEach((i) => { const v = i.value.trim(); // 回显的是脱敏值,没动过就丢掉(服务端也认这个规则,双保险) if (v && !v.includes('****')) fields[i.dataset.field] = v; }); const body = { name: $('#saName').value.trim(), type: $('#saType').value, note: $('#saNote').value.trim(), fields, }; if (!body.name) { toast('✕ 凭据名要填', true); return; } const b = $('#saSave'); b.dataset.busy = '1'; try { await sslApi('/access', { method: 'POST', body }); closeModal(); toast('✓ 已保存'); await viewSSL(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; } /** 登记证书(手工粘贴 PEM) */ function sslImportModal(domain) { modal('登记证书' + (domain ? ':' + domain : ''), '' + '' + '' + '' + ''); $('#siSave').onclick = async () => { const b = $('#siSave'); b.dataset.busy = '1'; try { await sslApi('/cert/import', { method: 'POST', body: { domain: $('#siDomain').value.trim(), cert: $('#siCert').value, key: $('#siKey').value, issuer: $('#siIssuer').value.trim(), }, }); closeModal(); toast('✓ 已登记'); await viewSSL(); } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } }; } /** 实测结果弹窗 —— 结构化展示,别再让用户去 openssl */ function sslSelfCheckModal(r) { const items = r.items || []; const bad = items.filter((x) => !x.ok); const kindLabel = { ca: 'CA', dns: 'DNS', deploy: '部署', target: '域名' }; const rows = items.map((x) => '' + '' + (x.ok ? '通过' : '失败') + '' + '' + esc(x.name) + '
' + esc(kindLabel[x.kind] || x.kind) + '
' + '' + esc(x.detail) + (x.hint ? '
建议:' + esc(x.hint) + '
' : '') + '' + '' ).join(''); modal('环境自检', (bad.length ? '
' + '' + bad.length + ' / ' + items.length + ' 项有问题 —— 这些就是「续期那天才会炸」的地方,建议现在修掉。
' : '
' + '全部 ' + items.length + ' 项通过 —— 凭据齐备,续期链路可用。
') + '
' + '' + '' + rows + '
项目结果 / 建议
' + '

自检不会签发证书、不会写 DNS、不会改站点,' + '只做只读探测(拉 CA 目录 / 读 TXT / 列站点)。所以它可以随时安全地跑。

' + '
'); } function sslProbeModal(host, r) { const dl = (ms) => ms == null ? '—' : new Date(ms + 8 * 3600e3).toISOString().slice(0, 19).replace('T', ' '); if (!r.ok) { modal('实测 ' + host, '
握手失败:' + esc(r.error || '未知原因') + '
' + '

如果这里是超时,也可能是对方 443 没开或者被墙 —— 不一定是证书问题。' + 'Worker 从 Cloudflare 的边缘节点出去,和你在本地看到的未必一样。

' + '
'); return; } modal('实测 ' + host, '
' + '
剩余天数
' + (r.daysLeft == null ? '—' : r.daysLeft + ' 天') + '
' + '
到期时间
' + esc(r.notAfterText || '—') + '
' + '
签发方
' + esc(r.issuer || '—') + '
' + '
主体
' + esc(r.subject || '—') + '
' + '
覆盖域名
' + esc((r.altNames || []).join(' · ') || '—') + '
' + '
' + (r.error ? '
' + esc(r.error) + '
' : '') + '
'); } // ============================================================ 事件委托 document.addEventListener('click', async (e) => { const el = e.target.closest('[data-act]'); if (!el) return; const act = el.dataset.act; // ★ 不能无脑 parseInt:友链申请的 id 是字符串(la + base36,如 lamus5uptzcdam), // parseInt 会得到 NaN,序列化成 JSON 变成 null —— 后端只看到空 id,一律 400 // 「id and action required」,表现就是「查看/通过/拒绝点了没反应」。 // 规矩:纯数字才转数字,其余原样保留。 const rawId = el.dataset.id || ''; const id = /^\d+$/.test(rawId) ? parseInt(rawId, 10) : rawId; // --- 通用 if (act === 'modal-close') { closeModal(); return; } if (act === 'menu') { toggleSidebar(); return; } if (act === 'theme') { toggleTheme(); return; } if (act === 'peek-pass') { // 登录页的「显示密码」:手机上输长密码很容易打错,看得见才好排查 const input = $('#lgPass'); const on = input.type === 'password'; input.type = on ? 'text' : 'password'; el.setAttribute('aria-pressed', String(on)); el.setAttribute('aria-label', on ? '隐藏密码' : '显示密码'); el.style.color = on ? 'var(--accent)' : ''; input.focus(); return; } if (act === 'tab') { const target = el.dataset.id; if (target === 'posts' && state.tab === 'posts' && state.ed) { // 再点一次「文章编辑」= 从编辑器退回列表 leaveEditor(() => go('posts')); return; } if (state.tab === target) return; if (target !== 'posts' && state.ed && state.ed.dirty) { leaveEditor(() => go(target)); return; } if (target !== 'posts') state.ed = null; go(target); return; } if (act === 'logout') { confirmBox('退出登录', '确定要退出当前会话吗?', () => logout()); return; } if (act === 'relogin') { // 从国内机模式切回完整后台:评论/订阅仍在 Cloudflare,得走那边的会话 state.direct = false; $('#app').hidden = true; showGate(); setTimeout(() => toast('完整后台需经 Cloudflare 登录,国内访问这一跳可能较慢'), 200); return; } if (act === 'cn-line') { // 一键跳国内机后台:先向 Worker 要一张「免登录票」(Worker 用共享令牌 // 签名,国内机验签后直接发会话 Cookie),失败就退回普通地址手动登录。 toast('正在打开国内线路…'); let url = 'https://writeapi.usj.cc/admin/'; try { const d = await api('/editor/handoff'); if (d && d.url) url = d.url; } catch (e) { /* 拿不到票就直开,让用户手动登录 */ } window.open(url, '_blank', 'noopener'); return; } // --- 文章编辑 if (act === 'po-reload') { el.dataset.busy = '1'; try { await loadPosts(); } finally { delete el.dataset.busy; } return; } if (act === 'po-prev') { state.po.page = Math.max(1, state.po.page - 1); await loadPosts(); return; } if (act === 'po-next') { state.po.page += 1; await loadPosts(); return; } if (act === 'po-open') { await openPost(el.dataset.id); return; } if (act === 'po-new') { newPost(); return; } if (act === 'po-git') { openGitModal(); return; } if (act === 'po-publish') { await publishCurrentPost(); return; } if (act === 'po-back') { leaveEditor(() => go('posts')); return; } if (act === 'po-save') { await saveCurrentPost(); return; } if (act === 'po-reload-post') { if (!state.ed) return; confirmBox('还原改动', '丢掉当前还没保存的修改,重新从文件读一遍?', async () => { await openPost(state.ed.id); toast('✓ 已从文件重载'); }); return; } if (act === 'po-del') { // 列表里的删除按钮带 data-id/data-title;编辑器里的只带 act const did = el.dataset.id || (state.ed && state.ed.id) || ''; const dtitle = el.dataset.title || (state.ed && state.ed.post && state.ed.post.title) || did; if (!did) return; confirmBox('删除「' + dtitle + '」', '文件会移到服务器上的回收目录(在仓库外,不进 git):后台立刻少一篇,线上要等你下次点「发布」后才会少。确定删除?', async () => { await api('/editor/posts/' + encodeURIComponent(did), { method: 'DELETE' }); toast('✓ 已删除(可在服务器回收目录找回)'); state.ed = null; await go('posts'); }); return; } // --- 评论 if (act === 'c-reload') { el.dataset.busy = '1'; try { await loadComments(); } finally { delete el.dataset.busy; } return; } if (act === 'c-prev') { state.c.offset = Math.max(0, state.c.offset - state.c.limit); await loadComments(); return; } if (act === 'c-next') { state.c.offset += state.c.limit; await loadComments(); return; } if (act === 'c-toggle') { if (state.openReps.has(id)) state.openReps.delete(id); else state.openReps.add(id); await loadComments(); return; } if (act === 'c-approve' || act === 'c-pin' || act === 'c-fold') { const body = {}; if (act === 'c-approve') body.is_pending = false; if (act === 'c-pin') body.is_pinned = el.dataset.v === '1'; if (act === 'c-fold') body.is_collapsed = el.dataset.v === '1'; el.dataset.busy = '1'; try { await api('/comments/' + id, { method: 'PUT', body: body }); toast('✓ 已更新 #' + id); await loadComments(); } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } return; } if (act === 'c-del') { confirmBox('删除评论 #' + id, '删除后前台不再显示(软删除,数据仍在库里)。确定继续?', async () => { await api('/comments/' + id, { method: 'DELETE' }); toast('✓ 已删除 #' + id); await loadComments(); }); return; } // --- 页面 if (act === 'p-prev') { state.p.offset = Math.max(0, state.p.offset - state.p.limit); go('pages'); return; } if (act === 'p-next') { state.p.offset += state.p.limit; go('pages'); return; } // --- 用户 if (act === 'u-new') { newUser(); return; } if (act === 'u-reload') { el.dataset.busy = '1'; try { await loadUsers(); } finally { delete el.dataset.busy; } return; } if (act === 'u-prev') { state.u.offset = Math.max(0, state.u.offset - state.u.limit); await loadUsers(); return; } if (act === 'u-next') { state.u.offset += state.u.limit; await loadUsers(); return; } if (act === 'u-edit') { editUser(id); return; } // --- 站点 if (act === 's-edit') { editSite(id); return; } // --- 设置(表单化) if (act === 'sf-save') { await saveSettings(); return; } // --- 订阅源 if (act === 'f-reload') { el.dataset.busy = '1'; try { await viewFeeds(); } finally { delete el.dataset.busy; } return; } if (act === 'f-add') { addFeed(); return; } if (act === 'f-edit') { editFeed(el.dataset.url); return; } if (act === 'f-del') { const u = el.dataset.url; confirmBox('删除订阅源', '确定删除「' + (u || '') + '」吗?', async () => { await rssApi('/api/feeds?url=' + encodeURIComponent(u), { method: 'DELETE' }); toast('✓ 已删除'); await viewFeeds(); }); return; } // --- 友链申请审核 if (act === 'l-ap-view' || act === 'l-ap-reject') { viewLinkApply(id); return; } if (act === 'l-ap-approve') { const a = (state.linkApps || []).find((x) => x.id === id); confirmBox('通过申请', '确定通过「' + (a ? a.name : '') + '」的友链申请吗?通过后自动加进友链并邮件通知对方。', async () => { await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'approve' } }); toast('✓ 已通过'); await viewLinks(); }); return; } if (act === 'l-ap-del') { const a = (state.linkApps || []).find((x) => x.id === id); confirmBox('删除申请记录', '确定删除「' + (a ? a.name : '') + '」这条申请吗?只清掉申请记录,已有的友链不受影响。', async () => { await rssApi('/api/link-apply/review', { method: 'POST', body: { id, action: 'delete' } }); toast('✓ 已删除申请记录'); await viewLinks(); }); return; } // --- 友链 if (act === 'l-reload') { el.dataset.busy = '1'; try { await viewLinks(); } finally { delete el.dataset.busy; } return; } if (act === 'l-add') { addLink(); return; } if (act === 'l-edit') { editLink(el.dataset.url); return; } if (act === 'l-del') { const u = el.dataset.url; confirmBox('删除友链', '确定删除「' + (u || '') + '」吗?', async () => { await rssApi('/api/links?url=' + encodeURIComponent(u), { method: 'DELETE' }); toast('✓ 已删除'); await viewLinks(); }); return; } // --- 证书管家 if (act === 'ssl-check') { el.dataset.busy = '1'; try { const r = await sslApi('/check', { method: 'POST' }); const bad = (r.items || []).filter((i) => i.verdict !== 'match'); toast(bad.length ? '⚠ ' + bad.length + ' 个域名需关注(见执行记录)' : '✓ 全部一致', !!bad.length); await viewSSL(); } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } return; } if (act === 'ssl-notify') { el.dataset.busy = '1'; try { const r = await sslApi('/notify', { method: 'POST' }); toast('✓ 已发给 ' + r.sent + '/' + r.total + ' 个收件人'); await loadSslLog(); } catch (ex) { toast('✕ ' + ex.message, true); } delete el.dataset.busy; return; } // 环境自检:不签发、不写 DNS,只验证凭据「连得上、认得对」 if (act === 'ssl-selfcheck') { el.dataset.busy = '1'; el.innerHTML = '自检中…'; try { const r = await sslApi('/selfcheck', { method: 'POST' }); sslSelfCheckModal(r); } catch (ex) { toast('✕ ' + ex.message, true); } el.innerHTML = '环境自检'; delete el.dataset.busy; return; } // 续期检查(只读,秒回):看哪些域名到了该续的天数 if (act === 'ssl-renew-check') { el.dataset.busy = '1'; try { const r = await sslApi('/renew-check'); const need = (r.items || []).filter((i) => i.action !== 'ok'); if (!need.length) toast('✓ 全部域名都还不需要续期(阈值 ' + r.threshold + ' 天)'); else toast('⚠ ' + need.map((i) => i.domain + '(' + (i.daysLeft == null ? '无记录' : i.daysLeft + ' 天') + ')').join('、'), true); state.ssl.renewCheck = r; await viewSSL(); } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } return; } // ★ 2026-10-06:签发/续期已**迁到国内机容器 cn-certkeeper**,Worker 侧只留只读。 // 这里不再调 /issue(后端也返回 501),改为给出可直接复制的操作指引。 // 为什么迁:免费版 CPU 硬顶 10ms(签发擦边)、CF 出口 IP 进不了 1Panel // 白名单、Worker 拿不到对端证书正文。详见 函数版证书管家-方案.md 第九章。 if (act === 'ssl-issue' || act === 'ssl-issue-all') { const domain = act === 'ssl-issue' ? el.dataset.domain : ''; const label = domain || '全部启用的域名'; const cmd = 'curl -s -X POST -H "X-Auth-Token: $AUTH_TOKEN" http://127.0.0.1:8019/renew'; modal('签发/续期已迁到国内机:' + label, '

签发 + 部署整条链路现在跑在国内机的 Docker 容器 cn-certkeeper' + '(只绑 127.0.0.1,不对外)。这个页面只做只读监控与配置。

' + '

在内侧机上执行:

' + '
' +
      esc(cmd) + '
' + '

· 只问「该不该续」:GET /renew-check
' + '· 看凭据是否齐备:GET /preflight
' + '· 看每个域名的真实剩余天数:GET /status

' + '
' + '
'); return; } if (act === 'ssl-notify-save') { const emails = $('#sslNotifyEmails').value.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean); const days = Number($('#sslNotifyDays').value); el.dataset.busy = '1'; try { await sslApi('/config', { method: 'POST', body: { version: 1, notify: { emails, daysBefore: days }, domains: (state.ssl.ov.domains || []) }, }); toast('✓ 已保存'); await viewSSL(); } catch (ex) { toast('✕ ' + ex.message, true); delete el.dataset.busy; } return; } if (act === 'ssl-add-domain') { await ensureAccessLoaded(); sslDomainModal(''); return; } if (act === 'ssl-edit-domain') { await ensureAccessLoaded(); sslDomainModal(el.dataset.domain); return; } if (act === 'ssl-del-domain') { const dn = el.dataset.domain; confirmBox('删除域名「' + dn + '」', '只从配置里移除,不会去删线上证书,也不会动 DNS。库里的证书记录会变成「孤儿」(不影响使用)。确定?', async () => { const domains = (state.ssl.ov.domains || []).filter((d) => d.name !== dn); await sslApi('/config', { method: 'POST', body: { version: 1, notify: state.ssl.ov.notify, domains } }); toast('✓ 已删除'); await viewSSL(); }); return; } if (act === 'ssl-access-new') { sslAccessModal(''); return; } if (act === 'ssl-access-edit') { await ensureAccessLoaded(); sslAccessModal(el.dataset.name); return; } if (act === 'ssl-access-del') { const an = el.dataset.name; confirmBox('删除凭据「' + an + '」', '被域名配置引用的凭据删不掉(服务端会拦),删了就再也取不回密钥。确定?', async () => { await sslApi('/access/delete', { method: 'POST', body: { name: an } }); toast('✓ 已删除'); await viewSSL(); }); return; } if (act === 'ssl-import') { sslImportModal(el.dataset.domain || ''); return; } if (act === 'ssl-probe') { const host = el.dataset.host; if (!host) return; el.dataset.busy = '1'; try { const r = await sslApi('/probe?host=' + encodeURIComponent(host)); sslProbeModal(host, r); } catch (ex) { toast('✕ ' + ex.message, true); } delete el.dataset.busy; return; } if (act === 'ssl-log-clear') { confirmBox('清空日志', '只清 KV 里的执行记录,不影响证书和配置。确定?', async () => { await sslApi('/log/clear', { method: 'POST' }); toast('✓ 已清空'); await loadSslLog(); }); return; } }); // ============================================================ 启动 $('#loginForm').addEventListener('submit', async (e) => { e.preventDefault(); const btn = $('#lgBtn'); const err = $('#lgErr'); err.hidden = true; btn.dataset.busy = '1'; try { // 国内机模式:换的是本机 Cookie 会话,登录成功直接进文章编辑 if (state.direct) { await directLogin($('#lgUser').value.trim(), $('#lgPass').value); enterDirect(); return; } await doLogin($('#lgUser').value.trim(), $('#lgPass').value); state.me = state.me || {}; if (!canEnterBackend() && !canUseCert()) { throw new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)'); } await bootstrap(); } catch (ex) { err.textContent = '✕ ' + ex.message; err.hidden = false; TOKEN = ''; localStorage.removeItem(LS_T); } finally { delete btn.dataset.busy; } }); async function bootstrap() { // 编辑角色没有仪表盘/评论/用户那些模块的权限(服务端 requireAdmin 一律 403), // 所以不能跟着管理员那套预取走 —— 否则第一句 /sites 就把登录打成「会话已失效」。 if (isEditor()) { showApp(); return; } // 站点列表 -> 默认站点 const sites = await api('/sites'); state.sites = sites || []; let def = state.sites[0] ? state.sites[0].name : ''; try { const c = await api('/conf'); if (c && c.frontend_conf && c.frontend_conf.site_default) def = c.frontend_conf.site_default; } catch (e) {} if (!def && state.sites[0]) def = state.sites[0].name; state.site = def; // 待审数量(供导航徽章) try { const p = await api('/comments?' + qs({ scope: 'site', site_name: state.site, type: 'pending', limit: 1 })); state.pending = p.count || 0; } catch (e) {} showApp(); } applyTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'); // Ctrl/⌘ + S 保存当前文章(浏览器默认的「保存网页」在这里没意义,直接接管) document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 's') { if (state.tab === 'posts' && state.ed) { e.preventDefault(); saveCurrentPost(); } } }); // 关标签页前提醒(只在编辑器里有未保存改动时) window.addEventListener('beforeunload', (e) => { if (state.ed && state.ed.dirty) { e.preventDefault(); e.returnValue = ''; } }); /** * 问一句「本机后台的登录态还在不在」。 * 只有 editor-api 会回 { mode:'direct' };Cloudflare 版后台没这条路由, * 回的不是这个形状 —— 用它来区分两种部署,比猜域名可靠。 */ async function probeSession() { try { const r = await fetch(API + '/admin/session', { credentials: 'same-origin' }); const d = await r.json().catch(() => null); if (!d || d.mode !== 'direct') return null; return d; } catch (e) { return null; } } /** 国内机模式:本机会话已就绪,直接进自己有权限的那一页(不经过 Cloudflare) */ function enterDirect() { state.direct = true; if (!state.me) state.me = { name: 'admin', is_admin: true, role: 'admin' }; $('#gate').hidden = true; $('#app').hidden = false; $('#who').innerHTML = esc(state.me.name || 'admin') + (isEditor() ? ' 编辑' : isSslOnly() ? ' SSL' : ''); $('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare'; go(isSslOnly() ? 'ssl' : 'posts'); } (async function boot() { // ① 本机后台(国内机部署):先看会话,未登录就亮自己的登录页 const sess = await probeSession(); if (sess) { state.direct = true; if (sess.ok) { state.me = sess.user || { name: 'admin', is_admin: true }; enterDirect(); } else { showGate(); } return; } // ② Cloudflare 版后台:沿用原来的令牌流程 if (!TOKEN) { showGate(); return; } try { // /user 未登录时返回 200 + {user:null},所以要显式判 is_login const me = await api('/user'); if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 }); state.me = me.user; // 三种角色各有各的去处:编辑去文章、SSL 去证书、其余(普通用户)一律拒绝 if (!canEnterBackend() && !canUseCert()) { throw Object.assign(new Error('该账号没有后台权限(需要管理员 / 编辑 / SSL 管理员)'), { status: 403 }); } localStorage.setItem(LS_U, JSON.stringify(state.me)); if (canEnterBackend()) await bootstrap(); else showApp(); // SSL 管理员:跳过站点/评论那套预取(它没有权限,预取必 403) } catch (e) { logout(e.status ? '会话已失效,请重新登录' : ('连接失败:' + e.message)); } })(); })();