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

487 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UAPI管理系统</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d2991d; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; padding: 40px 20px; }
.container { max-width: 960px; margin: 0 auto; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; flex-wrap: wrap; gap: 16px; }
header h1 { font-size: 24px; color: #fff; }
header .actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: #1c2333; }
.btn-danger { background: var(--card); color: var(--red); border: 1px solid var(--red); }
.btn-danger:hover { background: #49020233; }
.stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 20px; }
.stat .num { font-size: 20px; font-weight: 700; color: #fff; }
.stat .label { font-size: 11px; color: var(--muted); }
.stat.alive .num { color: var(--green); }
.stat.dead .num { color: var(--red); }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 16px; text-align: left; font-size: 13px; }
th { background: #1c2333; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
td { border-top: 1px solid var(--border); }
td.url { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: var(--accent); word-break: break-all; }
td.title { color: #fff; font-weight: 500; display: flex; align-items: center; }
td.actions { white-space: nowrap; }
td.actions button { padding: 4px 10px; font-size: 12px; margin-right: 6px; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; flex-shrink: 0; }
.status-dot.alive { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.dead { background: var(--red); }
.status-dot.checking { background: var(--yellow); animation: pulse 0.8s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.latency { font-size: 11px; color: var(--muted); margin-left: 6px; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 32px; width: 100%; max-width: 480px; }
.modal h2 { font-size: 18px; color: #fff; margin-bottom: 20px; }
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; margin-top: 12px; }
.modal input, .modal select { width: 100%; padding: 9px 12px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; color: #fff; font-size: 14px; outline: none; }
.modal input:focus { border-color: var(--accent); }
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
.toast { position: fixed; bottom: 24px; right: 24px; background: #238636; color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 13px; z-index: 200; display: none; animation: fadeIn 0.2s ease; }
.toast.error { background: #da3633; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty h3 { font-size: 16px; color: #fff; margin-bottom: 8px; }
.tab { display: inline-flex; gap: 2px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 4px; margin-bottom: 24px; }
.tab button { padding: 6px 16px; border: none; background: none; color: var(--muted); border-radius: 6px; cursor: pointer; font-size: 13px; }
.tab button.active { background: #1f6feb; color: #fff; }
.feed-url-bar { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; font-size: 13px; }
.feed-url-bar code { font-family: 'SF Mono', 'Fira Code', monospace; color: var(--accent); font-size: 12px; }
.link-avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; background: var(--border); }
.link-name { color: #fff; font-weight: 500; display: flex; align-items: center; gap: 10px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>UAPI管理系统</h1>
<div class="actions">
<a href="/" class="btn btn-secondary" target="_blank">API 文档</a>
<button class="btn btn-secondary" onclick="checkHealth()" id="healthBtn">检测存活</button>
<button class="btn btn-primary" onclick="openAddModal()">+ 添加订阅源</button>
</div>
</header>
<div class="feed-url-bar">
<span style="color:var(--muted)">订阅地址</span>
<code id="feedUrlText">https://api.200181.xyz/api/feeds</code>
<button class="btn btn-secondary" style="padding:4px 10px;font-size:11px" onclick="copyFeedUrl()">复制</button>
</div>
<div class="stats">
<div class="stat"><div class="num" id="feedCount">0</div><div class="label">订阅源</div></div>
<div class="stat"><div class="num" id="linkCount">0</div><div class="label">友链</div></div>
<div class="stat alive" id="aliveStat" style="display:none"><div class="num" id="aliveCount">0</div><div class="label">存活</div></div>
<div class="stat dead" id="deadStat" style="display:none"><div class="num" id="deadCount">0</div><div class="label">异常</div></div>
</div>
<div class="tab">
<button class="active" onclick="showTab('feeds')">订阅源</button>
<button onclick="showTab('links')">友链</button>
<button onclick="showTab('password')">访问口令</button>
</div>
<div id="tab-feeds">
<table><thead><tr><th style="width:220px">博客名称</th><th>URL</th><th style="width:100px">操作</th></tr></thead><tbody id="feedTableBody"></tbody></table>
<div class="empty" id="emptyMsg" style="display:none"><h3>暂无订阅源</h3><p>点击上方按钮添加第一个</p></div>
</div>
<div id="tab-links" style="display:none">
<div style="margin-bottom:16px">
<button class="btn btn-primary" onclick="openLinkModal()">+ 添加友链</button>
</div>
<table><thead><tr><th style="width:50px"></th><th style="width:180px">站点名称</th><th>URL</th><th style="width:200px">描述</th><th style="width:100px">操作</th></tr></thead><tbody id="linkTableBody"></tbody></table>
<div class="empty" id="linkEmpty" style="display:none"><h3>暂无友链</h3><p>点击上方按钮添加</p></div>
</div>
<div id="tab-password" style="display:none">
<div class="modal" style="max-width:400px; margin:0 auto; position:static; border-radius:8px;">
<h2>修改访问口令</h2>
<label>当前口令</label>
<input type="password" id="oldPwd" placeholder="当前口令">
<label>新口令(留空则清除保护)</label>
<input type="password" id="newPwd" placeholder="新口令">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="document.getElementById('oldPwd').value='';document.getElementById('newPwd').value='';">取消</button>
<button class="btn btn-primary" onclick="changePassword()">保存</button>
</div>
</div>
</div>
</div>
<!-- 添加/编辑弹窗 -->
<div class="modal-overlay" id="feedModal">
<div class="modal">
<h2 id="modalTitle">添加订阅源</h2>
<label>博客 URL</label>
<input type="text" id="feedUrl" placeholder="https://example.com">
<label>博客名称</label>
<input type="text" id="feedTitle" placeholder="示例博客">
<label>抓取格式</label>
<select id="feedFormat">
<option value="">自动检测 (auto)</option>
<option value="xml">RSS/XML</option>
<option value="json">JSON Feed</option>
</select>
<label>JSON 数据路径(format=json 时填写)</label>
<input type="text" id="feedPath" placeholder="例如 data.list">
<label>强制使用代理</label>
<select id="feedProxy">
<option value="">否</option>
<option value="true">是</option>
</select>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="saveBtn" onclick="saveFeed()">保存</button>
<button class="btn btn-danger" id="deleteBtn" style="display:none" onclick="deleteFeed()">删除</button>
</div>
</div>
</div>
<!-- 友链弹窗 -->
<div class="modal-overlay" id="linkModal">
<div class="modal">
<h2 id="linkModalTitle">添加友链</h2>
<label>站点名称</label>
<input type="text" id="linkName" placeholder="示例博客">
<label>站点 URL</label>
<input type="text" id="linkUrl" placeholder="https://example.com">
<label>头像图片 URL(留空自动获取 favicon)</label>
<input type="text" id="linkImage" placeholder="自动获取">
<label>描述</label>
<input type="text" id="linkDesc" placeholder="一个很棒的博客">
<label>RSS 订阅地址(选填)</label>
<input type="text" id="linkRss" placeholder="https://example.com/feed">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-top:12px">
<input type="checkbox" id="linkHidden" style="width:auto;accent-color:var(--red)"> 隐藏(不在公开接口展示)
</label>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeLinkModal()">取消</button>
<button class="btn btn-primary" id="linkSaveBtn" onclick="saveLink()">保存</button>
<button class="btn btn-danger" id="linkDeleteBtn" style="display:none" onclick="deleteLink()">删除</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let token = '';
const cMatch = document.cookie.match(/site_token=([^;]+)/);
if (cMatch) token = cMatch[1];
if (!token) { location.href = '/feed'; }
let feeds = [];
let editingUrl = null;
function showToast(msg, isError) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 2500);
}
function copyFeedUrl() {
navigator.clipboard.writeText('https://api.200181.xyz/api/feeds').then(() => showToast('已复制订阅地址'));
}
function showTab(name) {
document.querySelectorAll('.tab button').forEach((b, i) => b.classList.toggle('active', (i === 0 && name === 'feeds') || (i === 1 && name === 'links') || (i === 2 && name === 'password')));
document.getElementById('tab-feeds').style.display = name === 'feeds' ? '' : 'none';
document.getElementById('tab-links').style.display = name === 'links' ? '' : 'none';
document.getElementById('tab-password').style.display = name === 'password' ? '' : 'none';
}
function renderTable(healthMap) {
const tbody = document.getElementById('feedTableBody');
if (feeds.length === 0) {
tbody.innerHTML = '';
document.getElementById('emptyMsg').style.display = '';
return;
}
document.getElementById('emptyMsg').style.display = 'none';
tbody.innerHTML = feeds.map((f, i) => {
const h = healthMap ? healthMap[f.url] : null;
let dot = '';
let latency = '';
if (h) {
if (h.ok) {
dot = '<span class="status-dot alive" title="HTTP ' + h.status + '"></span>';
latency = '<span class="latency">' + h.latency + 'ms</span>';
} else {
dot = '<span class="status-dot dead" title="' + (h.error || 'HTTP ' + h.status) + '"></span>';
}
}
return '<tr>' +
'<td class="title">' + dot + (f.feedTitle || '-') + latency + '</td>' +
'<td class="url">' + f.url + '</td>' +
'<td class="actions"><button class="btn btn-secondary" onclick="openEditModal(' + i + ')">编辑</button></td>' +
'</tr>';
}).join('');
}
async function loadFeeds() {
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token));
if (res.status === 401) { location.href = '/feed'; return; }
const data = await res.json();
feeds = data.feeds || [];
document.getElementById('feedCount').textContent = feeds.length;
renderTable(null);
} catch (err) {
showToast('加载失败: ' + err.message, true);
}
}
async function checkHealth() {
const btn = document.getElementById('healthBtn');
if (feeds.length === 0) { showToast('暂无订阅源', true); return; }
btn.textContent = '检测中 0/' + feeds.length;
btn.disabled = true;
const healthMap = {};
let done = 0;
// 每批 5 个并发,逐批更新 UI
for (let i = 0; i < feeds.length; i += 5) {
const batch = feeds.slice(i, i + 5);
await Promise.all(batch.map(async (f) => {
try {
const res = await fetch('/api/health?url=' + encodeURIComponent(f.url));
healthMap[f.url] = await res.json();
} catch {
healthMap[f.url] = { ok: false, error: 'network error' };
}
done++;
btn.textContent = '检测中 ' + done + '/' + feeds.length;
}));
renderTable(healthMap);
}
const alive = Object.values(healthMap).filter(r => r.ok).length;
const dead = Object.values(healthMap).filter(r => !r.ok).length;
document.getElementById('aliveStat').style.display = '';
document.getElementById('deadStat').style.display = '';
document.getElementById('aliveCount').textContent = alive;
document.getElementById('deadCount').textContent = dead;
btn.textContent = '检测存活';
btn.disabled = false;
showToast('存活 ' + alive + ' / 异常 ' + dead);
}
function openAddModal() {
editingUrl = null;
document.getElementById('modalTitle').textContent = '添加订阅源';
document.getElementById('feedUrl').value = '';
document.getElementById('feedTitle').value = '';
document.getElementById('feedFormat').value = '';
document.getElementById('feedPath').value = '';
document.getElementById('feedProxy').value = '';
document.getElementById('deleteBtn').style.display = 'none';
document.getElementById('feedModal').classList.add('active');
}
function openEditModal(idx) {
const f = feeds[idx];
editingUrl = f.url;
document.getElementById('modalTitle').textContent = '编辑订阅源';
document.getElementById('feedUrl').value = f.url || '';
document.getElementById('feedTitle').value = f.feedTitle || '';
document.getElementById('feedFormat').value = f.format || '';
document.getElementById('feedPath').value = f.path || '';
document.getElementById('feedProxy').value = f.proxy ? 'true' : '';
document.getElementById('deleteBtn').style.display = '';
document.getElementById('feedModal').classList.add('active');
}
function closeModal() {
document.getElementById('feedModal').classList.remove('active');
}
async function saveFeed() {
const url = document.getElementById('feedUrl').value.trim();
const feedTitle = document.getElementById('feedTitle').value.trim();
if (!url) { showToast('URL 不能为空', true); return; }
const body = { url };
if (feedTitle) body.feedTitle = feedTitle;
const format = document.getElementById('feedFormat').value;
if (format) body.format = format;
const path = document.getElementById('feedPath').value.trim();
if (path) body.path = path;
const proxy = document.getElementById('feedProxy').value;
if (proxy) body.proxy = true;
if (editingUrl) body.oldUrl = editingUrl;
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('HTTP ' + res.status);
closeModal();
showToast(editingUrl ? '已更新' : '已添加');
loadFeeds();
} catch (err) {
showToast('保存失败: ' + err.message, true);
}
}
async function deleteFeed() {
if (!editingUrl || !confirm('确定删除 ' + editingUrl + ' ?')) return;
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token) + '&url=' + encodeURIComponent(editingUrl), { method: 'DELETE' });
if (!res.ok) throw new Error('HTTP ' + res.status);
closeModal();
showToast('已删除');
loadFeeds();
} catch (err) {
showToast('删除失败: ' + err.message, true);
}
}
// ─── 友链管理 ───
let links = [];
let editingLinkUrl = null;
function renderLinkTable() {
const tbody = document.getElementById('linkTableBody');
if (links.length === 0) {
tbody.innerHTML = '';
document.getElementById('linkEmpty').style.display = '';
return;
}
document.getElementById('linkEmpty').style.display = 'none';
tbody.innerHTML = links.map((l, i) => {
var img = l.image || l.avatar || ('https://api.200181.xyz/api/favicon?url=' + encodeURIComponent(l.url));
var hiddenBadge = l.hidden ? ' <span style="font-size:10px;color:var(--red);border:1px solid var(--red);border-radius:4px;padding:1px 5px;margin-left:6px">隐藏</span>' : '';
return '<tr>' +
'<td><img src="' + img + '" class="link-avatar" onerror="this.style.opacity=0"></td>' +
'<td class="link-name">' + (l.name || '-') + hiddenBadge + '</td>' +
'<td class="url">' + l.url + '</td>' +
'<td style="font-size:12px;color:var(--muted)">' + (l.description || '') + '</td>' +
'<td class="actions"><button class="btn btn-secondary" onclick="openEditLink(' + i + ')">编辑</button></td>' +
'</tr>';
}).join('');
}
async function loadLinks() {
try {
const res = await fetch('/api/links?all=1');
const data = await res.json();
links = data.links || [];
document.getElementById('linkCount').textContent = links.length;
renderLinkTable();
} catch (err) {
showToast('加载友链失败: ' + err.message, true);
}
}
function openLinkModal() {
editingLinkUrl = null;
document.getElementById('linkModalTitle').textContent = '添加友链';
document.getElementById('linkName').value = '';
document.getElementById('linkUrl').value = '';
document.getElementById('linkImage').value = '';
document.getElementById('linkDesc').value = '';
document.getElementById('linkRss').value = '';
document.getElementById('linkHidden').checked = false;
document.getElementById('linkDeleteBtn').style.display = 'none';
document.getElementById('linkModal').classList.add('active');
}
function openEditLink(idx) {
const l = links[idx];
editingLinkUrl = l.url;
document.getElementById('linkModalTitle').textContent = '编辑友链';
document.getElementById('linkName').value = l.name || '';
document.getElementById('linkUrl').value = l.url || '';
document.getElementById('linkImage').value = l.image || '';
document.getElementById('linkDesc').value = l.description || '';
document.getElementById('linkRss').value = l.rss || '';
document.getElementById('linkHidden').checked = !!l.hidden;
document.getElementById('linkDeleteBtn').style.display = '';
document.getElementById('linkModal').classList.add('active');
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('active');
}
async function saveLink() {
const name = document.getElementById('linkName').value.trim();
const url = document.getElementById('linkUrl').value.trim();
if (!name && !url) { showToast('名称和 URL 至少填一个', true); return; }
if (!url) { showToast('URL 不能为空', true); return; }
const body = { name: name || url, url: url };
const image = document.getElementById('linkImage').value.trim();
if (image) body.image = image;
const desc = document.getElementById('linkDesc').value.trim();
if (desc) body.description = desc;
const rss = document.getElementById('linkRss').value.trim();
if (rss) body.rss = rss;
body.hidden = document.getElementById('linkHidden').checked;
if (editingLinkUrl) body.oldUrl = editingLinkUrl;
try {
const res = await fetch('/api/links?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('HTTP ' + res.status);
closeLinkModal();
showToast(editingLinkUrl ? '已更新' : '已添加');
loadLinks();
} catch (err) {
showToast('保存失败: ' + err.message, true);
}
}
async function deleteLink() {
if (!editingLinkUrl || !confirm('确定删除 ' + editingLinkUrl + ' ?')) return;
try {
const res = await fetch('/api/links?token=' + encodeURIComponent(token) + '&url=' + encodeURIComponent(editingLinkUrl), { method: 'DELETE' });
if (!res.ok) throw new Error('HTTP ' + res.status);
closeLinkModal();
showToast('已删除');
loadLinks();
} catch (err) {
showToast('删除失败: ' + err.message, true);
}
}
async function changePassword() {
const oldPwd = document.getElementById('oldPwd').value;
const newPwd = document.getElementById('newPwd').value;
try {
const res = await fetch('/api/auth?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
if (newPwd) {
token = newPwd;
document.cookie = 'site_token=' + newPwd + ';path=/;max-age=2592000;SameSite=Lax';
}
document.getElementById('oldPwd').value = '';
document.getElementById('newPwd').value = '';
showToast(newPwd ? '口令已更新' : '口令保护已清除');
} catch (err) {
showToast('修改失败: ' + err.message, true);
}
}
loadFeeds();
loadLinks();
</script>
</body>
</html>