Initial daily news digest web

This commit is contained in:
wuyanwanwu
2026-08-09 17:43:21 +08:00
commit 8e735ece88
17 changed files with 1230 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
const $ = (selector) => document.querySelector(selector);
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
const categoryNames = { general: '综合', ai_agent: 'AI 与 Agent', agri_hardware: '农业硬件', agri_solutions: '方案落地', agri_services: '农业服务', policy_market: '政策市场' };
function today() { return new Date().toISOString().slice(0, 10); }
function formatDate(value) { return value ? new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }).format(new Date(`${value}T00:00:00`)) : '—'; }
function formatTime(value) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(new Date(value)) : '近期'; }
async function loadHealth() {
try {
const response = await fetch('/api/health');
const label = $('#healthLabel');
if (response.ok) label.innerHTML = '<span class="status-dot"></span>数据库连接正常';
else throw new Error();
} catch { $('#healthLabel').innerHTML = '<span class="status-dot error"></span>数据库暂不可用'; }
}
function renderArticle(article) {
const title = article.translated_title || article.title || '无标题';
const summary = article.translated_summary || article.summary || '暂无摘要';
const category = categoryNames[article.section] || categoryNames[article.category] || '综合';
return `<article class="article">
<div class="article-meta"><span class="tag">${escapeHtml(category)}</span><span>${escapeHtml(article.source_name || '未知来源')}</span><span>·</span><span>${escapeHtml(formatTime(article.published_at))}</span></div>
<h3><a href="${escapeHtml(article.url)}" target="_blank" rel="noreferrer">${escapeHtml(title)}</a></h3>
<p>${escapeHtml(summary)}</p>
</article>`;
}
function renderReports(payload) {
$('#reportCount').textContent = payload.total ?? 0;
const reports = payload.reports || [];
const articleCount = reports.reduce((sum, report) => sum + (report.articles || []).length, 0);
$('#articleCount').textContent = articleCount;
$('#sourceCount').textContent = new Set(reports.flatMap((report) => (report.articles || []).map((item) => item.source_name).filter(Boolean))).size;
if (!reports.length) {
$('#reportList').innerHTML = '<div class="empty-state"><strong>还没有符合条件的日报</strong><span>n8n 完成第一次抓取后,内容会出现在这里。</span></div>';
return;
}
$('#reportList').innerHTML = reports.map((report) => `<article class="report-card card">
<div class="report-top"><div><div class="report-date">${escapeHtml(formatDate(report.report_date))}</div><h2>${escapeHtml(report.title)}</h2><p class="report-intro">${escapeHtml(report.introduction || '今日资讯已按主题整理。')}</p></div><span class="report-status">${report.status === 'published' ? '已发布' : '草稿'}</span></div>
<div class="article-grid">${(report.articles || []).map(renderArticle).join('')}</div>
</article>`).join('');
}
async function loadReports() {
const params = new URLSearchParams();
const date = $('#dateInput').value;
const q = $('#searchInput').value.trim();
const category = $('#categoryInput').value;
if (date) params.set('date', date);
if (q) params.set('q', q);
if (category) params.set('category', category);
$('#reportList').innerHTML = '<div class="empty-state"><span>正在读取日报…</span></div>';
try {
const response = await fetch(`/api/reports?${params}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || '读取失败');
renderReports(payload);
} catch (error) {
$('#reportList').innerHTML = `<div class="empty-state"><strong>暂时无法读取日报</strong><span>${escapeHtml(error.message)}</span></div>`;
}
}
$('#todayLabel').textContent = formatDate(today());
$('#dateInput').value = today();
$('#dateInput').addEventListener('change', loadReports);
$('#categoryInput').addEventListener('change', loadReports);
let searchTimer;
$('#searchInput').addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadReports, 280); });
$('#clearFilters').addEventListener('click', () => { $('#dateInput').value = ''; $('#categoryInput').value = ''; $('#searchInput').value = ''; loadReports(); });
loadHealth();
loadReports();
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#123c34">
<title>每日情报 · Daily Digest</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark">D</span>
<span><strong>Daily Digest</strong><small>每日行业情报</small></span>
</a>
<nav><a class="nav-link active" href="/">日报</a><a class="nav-link" href="/settings">设置管理</a></nav>
</header>
<main class="shell">
<section class="hero">
<div class="hero-copy">
<div class="eyebrow"><span class="live-dot"></span> DAILY SIGNALS</div>
<h1>今天值得读的<br><em>行业情报</em></h1>
<p>把 AI、Agent 与农业产业链的重要动态,按主题整理成一份可追踪的日报。</p>
</div>
<div class="hero-side">
<div class="date-label">今日日期</div>
<div id="todayLabel" class="today-date"></div>
<div id="healthLabel" class="health-label"><span class="status-dot"></span>正在检查数据库</div>
</div>
</section>
<section class="toolbar card">
<div class="toolbar-heading"><span class="section-kicker">ARCHIVE</span><h2>日报档案</h2></div>
<div class="filters">
<label class="search-box"><span></span><input id="searchInput" type="search" placeholder="搜索标题、摘要或关键词" aria-label="搜索日报"></label>
<label class="date-box"><span>日期</span><input id="dateInput" type="date" aria-label="选择日期"></label>
<select id="categoryInput" aria-label="选择栏目">
<option value="">全部栏目</option>
<option value="ai_agent">AI 与 Agent</option>
<option value="agri_hardware">农业硬件</option>
<option value="agri_solutions">农业方案落地</option>
<option value="agri_services">农业服务</option>
<option value="policy_market">政策与市场</option>
</select>
<button id="clearFilters" class="button button-ghost" type="button">清除</button>
</div>
</section>
<section class="summary-grid" aria-label="日报统计">
<div class="stat-card"><span class="stat-label">报告数量</span><strong id="reportCount"></strong><small>按筛选条件</small></div>
<div class="stat-card"><span class="stat-label">今日文章</span><strong id="articleCount"></strong><small>已收录资讯</small></div>
<div class="stat-card accent-card"><span class="stat-label">信息状态</span><strong id="sourceCount"></strong><small>来源持续更新中</small></div>
</section>
<section id="reportList" class="report-list" aria-live="polite"></section>
</main>
<footer class="footer"><span>DAILY DIGEST</span><span>资讯原文版权归原作者所有</span></footer>
<script src="/app.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#123c34">
<title>设置管理 · Daily Digest</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/"><span class="brand-mark">D</span><span><strong>Daily Digest</strong><small>每日行业情报</small></span></a>
<nav><a class="nav-link" href="/">日报</a><a class="nav-link active" href="/settings">设置管理</a></nav>
</header>
<main class="shell settings-shell">
<section class="page-heading"><div class="eyebrow">CONTROL ROOM</div><h1>推送设置</h1><p>管理日报收件邮箱,为每个邮箱选择一个或多个内容模块。</p></section>
<section class="token-card card">
<div><span class="section-kicker">PRIVATE ACCESS</span><h2>管理令牌</h2><p>令牌只保存在当前浏览器会话中,用于保护邮箱管理接口。</p></div>
<div class="token-controls"><input id="tokenInput" type="password" autocomplete="off" placeholder="输入服务器 APP_ADMIN_TOKEN"><button id="saveToken" class="button button-dark" type="button">保存令牌</button></div>
<div id="tokenMessage" class="form-message" role="status"></div>
</section>
<div class="settings-grid">
<section class="card form-card">
<div class="card-heading"><div><span class="section-kicker">RECIPIENT</span><h2 id="formTitle">添加收件邮箱</h2></div><button id="cancelEdit" class="text-button hidden" type="button">取消编辑</button></div>
<form id="recipientForm">
<input id="recipientId" type="hidden">
<label>邮箱地址<input id="emailInput" type="email" required placeholder="name@example.com"></label>
<label>显示名称<span class="optional">可选</span><input id="nameInput" type="text" maxlength="120" placeholder="我的日报"></label>
<div class="form-row"><label>发送时间<input id="timeInput" type="time" value="07:30"></label><label>时区<input id="timezoneInput" type="text" value="Asia/Shanghai"></label></div>
<label class="check-line"><input id="allModulesInput" type="checkbox"><span>订阅全部模块</span></label>
<div class="module-picker"><div class="picker-label">选择推送模块 <span>可多选</span></div><div id="moduleOptions" class="module-options"></div></div>
<button class="button button-primary full-button" type="submit"><span id="submitLabel">保存邮箱设置</span><span></span></button>
<div id="formMessage" class="form-message" role="alert"></div>
</form>
</section>
<section class="card recipients-card">
<div class="card-heading"><div><span class="section-kicker">DELIVERY LIST</span><h2>已配置邮箱</h2></div><span id="recipientCount" class="count-pill">0</span></div>
<div id="recipientList" class="recipient-list"><div class="loading-line">正在加载…</div></div>
</section>
</div>
</main>
<footer class="footer"><span>DAILY DIGEST</span><span>只向已启用邮箱发送日报</span></footer>
<script src="/settings.js"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
const $ = (selector) => document.querySelector(selector);
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
let modules = [];
let editingId = null;
function token() { return sessionStorage.getItem('daily_digest_admin_token') || ''; }
function headers(json = false) { const value = { 'X-Admin-Token': token() }; if (json) value['Content-Type'] = 'application/json'; return value; }
function message(selector, text, type = '') { const el = $(selector); el.textContent = text; el.className = `form-message ${type}`; }
async function api(path, options = {}) {
const response = await fetch(path, { ...options, headers: { ...headers(Boolean(options.body)), ...(options.headers || {}) } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `请求失败 (${response.status})`);
return payload;
}
function renderModuleOptions(selected = []) {
$('#moduleOptions').innerHTML = modules.map((module) => `<label class="module-option"><input type="checkbox" value="${escapeHtml(module.module_key)}" ${selected.includes(module.module_key) ? 'checked' : ''}><span>${escapeHtml(module.module_name)}</span></label>`).join('');
}
function selectedModules() { return [...document.querySelectorAll('#moduleOptions input:checked')].map((input) => input.value); }
function renderRecipients(recipients) {
$('#recipientCount').textContent = recipients.length;
if (!recipients.length) { $('#recipientList').innerHTML = '<div class="empty-state"><strong>还没有收件邮箱</strong><span>在左侧添加第一个日报收件人。</span></div>'; return; }
const names = Object.fromEntries(modules.map((module) => [module.module_key, module.module_name]));
$('#recipientList').innerHTML = recipients.map((recipient) => `<div class="recipient-item">
<div><div class="recipient-email">${escapeHtml(recipient.email)} ${recipient.enabled ? '' : '<span class="tag">已停用</span>'}</div><div class="recipient-sub">${escapeHtml(recipient.display_name || '未设置名称')} · 每天 ${escapeHtml(String(recipient.send_time).slice(0, 5))} · ${escapeHtml(recipient.timezone)}</div><div class="chips">${recipient.send_all_modules ? '<span class="chip">全部模块</span>' : (recipient.modules || []).map((key) => `<span class="chip">${escapeHtml(names[key] || key)}</span>`).join('')}</div></div>
<div class="recipient-actions"><button class="icon-button" type="button" data-edit="${recipient.id}" title="编辑">✎</button><button class="icon-button delete" type="button" data-delete="${recipient.id}" title="删除">×</button></div>
</div>`).join('');
document.querySelectorAll('[data-edit]').forEach((button) => button.addEventListener('click', () => editRecipient(recipients.find((item) => item.id === Number(button.dataset.edit)))));
document.querySelectorAll('[data-delete]').forEach((button) => button.addEventListener('click', () => removeRecipient(Number(button.dataset.delete))));
}
async function loadRecipients() {
try { renderRecipients((await api('/api/recipients')).recipients); }
catch (error) { $('#recipientList').innerHTML = `<div class="empty-state"><strong>无法读取邮箱设置</strong><span>${escapeHtml(error.message)}</span></div>`; }
}
function editRecipient(recipient) {
editingId = recipient.id; $('#formTitle').textContent = '编辑收件邮箱'; $('#submitLabel').textContent = '保存修改'; $('#cancelEdit').classList.remove('hidden');
$('#recipientId').value = recipient.id; $('#emailInput').value = recipient.email; $('#emailInput').disabled = true; $('#nameInput').value = recipient.display_name || ''; $('#timeInput').value = String(recipient.send_time).slice(0, 5); $('#timezoneInput').value = recipient.timezone || 'Asia/Shanghai'; $('#allModulesInput').checked = recipient.send_all_modules; renderModuleOptions(recipient.modules || []); window.scrollTo({ top: 0, behavior: 'smooth' });
}
function resetForm() { editingId = null; $('#formTitle').textContent = '添加收件邮箱'; $('#submitLabel').textContent = '保存邮箱设置'; $('#cancelEdit').classList.add('hidden'); $('#recipientForm').reset(); $('#emailInput').disabled = false; $('#timeInput').value = '07:30'; $('#timezoneInput').value = 'Asia/Shanghai'; renderModuleOptions([]); message('#formMessage', ''); }
async function removeRecipient(id) {
if (!window.confirm('确定删除这个收件邮箱吗?历史发送记录会保留,但以后不会再发送。')) return;
try { await api(`/api/recipients/${id}`, { method: 'DELETE' }); message('#tokenMessage', '邮箱已删除', 'success'); await loadRecipients(); }
catch (error) { message('#tokenMessage', error.message, 'error'); }
}
$('#saveToken').addEventListener('click', async () => { const value = $('#tokenInput').value.trim(); if (!value) return message('#tokenMessage', '请输入管理令牌', 'error'); sessionStorage.setItem('daily_digest_admin_token', value); message('#tokenMessage', '令牌已保存,正在验证…'); await loadRecipients(); });
$('#tokenInput').value = token();
$('#cancelEdit').addEventListener('click', resetForm);
$('#allModulesInput').addEventListener('change', (event) => { document.querySelectorAll('#moduleOptions input').forEach((input) => { input.disabled = event.target.checked; }); });
$('#recipientForm').addEventListener('submit', async (event) => {
event.preventDefault();
const body = { email: $('#emailInput').value.trim(), display_name: $('#nameInput').value.trim(), send_time: $('#timeInput').value, timezone: $('#timezoneInput').value.trim(), send_all_modules: $('#allModulesInput').checked, modules: selectedModules() };
try { await api(editingId ? `/api/recipients/${editingId}` : '/api/recipients', { method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body) }); message('#formMessage', editingId ? '邮箱设置已更新' : '邮箱已添加', 'success'); resetForm(); await loadRecipients(); }
catch (error) { message('#formMessage', error.message, 'error'); }
});
(async function init() { try { modules = (await fetch('/api/modules').then((response) => response.json())).modules || []; renderModuleOptions(); await loadRecipients(); } catch (error) { message('#tokenMessage', error.message, 'error'); } })();
+88
View File
@@ -0,0 +1,88 @@
:root {
--ink: #17342e;
--muted: #70827d;
--line: #dbe6e0;
--paper: #f6f8f5;
--card: #ffffff;
--green: #1d594c;
--green-dark: #123c34;
--mint: #cfe7d9;
--orange: #e57b4b;
--shadow: 0 14px 36px rgba(31, 67, 56, .07);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; line-height: 1.55; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.topbar { height: 76px; padding: 0 max(24px, calc((100vw - 1180px) / 2)); display: flex; align-items: center; justify-content: space-between; background: rgba(246,248,245,.9); border-bottom: 1px solid var(--line); }
.brand { display: inline-flex; align-items: center; gap: 11px; }
.brand-mark { display: grid; place-items: center; width: 33px; height: 33px; border-radius: 9px; color: #fff; background: var(--green-dark); font-weight: 800; letter-spacing: -.08em; }
.brand strong, .brand small { display: block; line-height: 1.15; }
.brand strong { font-size: 14px; letter-spacing: .04em; }
.brand small { margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .12em; }
nav { display: flex; gap: 8px; }
.nav-link { padding: 9px 14px; border-radius: 10px; color: var(--muted); font-size: 13px; }
.nav-link:hover, .nav-link.active { color: var(--green-dark); background: #e9f1eb; }
.shell { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 54px 0 72px; }
.hero { display: flex; justify-content: space-between; gap: 32px; padding: 26px 32px 34px; min-height: 238px; border-radius: 25px; color: #fff; background: var(--green-dark); overflow: hidden; position: relative; box-shadow: var(--shadow); }
.hero::after { content: ""; width: 350px; height: 350px; position: absolute; right: -80px; bottom: -210px; border: 1px solid rgba(211,241,220,.24); border-radius: 50%; box-shadow: 0 0 0 36px rgba(211,241,220,.05), 0 0 0 72px rgba(211,241,220,.04); }
.hero-copy, .hero-side { position: relative; z-index: 1; }
.eyebrow, .section-kicker { color: var(--orange); font-size: 10px; font-weight: 800; letter-spacing: .2em; }
.hero h1 { margin: 14px 0 10px; font-size: clamp(34px, 5vw, 56px); line-height: .98; letter-spacing: -.06em; }
.hero h1 em { color: #bde7ca; font-style: normal; }
.hero p { max-width: 470px; margin: 0; color: #b7d1c4; font-size: 14px; }
.live-dot { display: inline-block; width: 7px; height: 7px; margin-right: 6px; border-radius: 50%; background: #8ee4a9; box-shadow: 0 0 0 5px rgba(142,228,169,.12); }
.hero-side { align-self: flex-end; min-width: 180px; padding: 14px 0 0 30px; border-left: 1px solid rgba(207,231,217,.24); }
.date-label { color: #8fb7a7; font-size: 11px; letter-spacing: .12em; }
.today-date { margin: 3px 0 10px; font-size: 21px; font-weight: 700; }
.health-label { color: #a9cfba; font-size: 11px; }
.status-dot { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #8ee4a9; }
.status-dot.error { background: #ff9e7c; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); }
.toolbar { display: flex; gap: 24px; align-items: center; justify-content: space-between; margin-top: -28px; padding: 19px 22px; position: relative; z-index: 2; }
.toolbar-heading h2, .card-heading h2, .token-card h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
.filters { display: flex; flex: 1; justify-content: flex-end; gap: 8px; }
.search-box, .date-box, select { height: 40px; display: flex; align-items: center; border: 1px solid var(--line); border-radius: 9px; background: #fbfcfb; color: var(--muted); font-size: 12px; }
.search-box { width: min(260px, 32vw); padding: 0 11px; gap: 7px; }
.search-box span { font-size: 21px; line-height: 0; }
.search-box input, .date-box input, select { min-width: 0; border: 0; outline: 0; color: var(--ink); background: transparent; }
.search-box input { width: 100%; }
.date-box { padding: 0 9px; gap: 5px; }
.date-box input { width: 120px; font-size: 12px; }
select { padding: 0 9px; }
.button { border: 0; border-radius: 9px; padding: 10px 14px; font-size: 12px; font-weight: 700; transition: transform .15s, background .15s; }
.button:hover { transform: translateY(-1px); }
.button-primary { display: flex; justify-content: space-between; align-items: center; color: #fff; background: var(--green); }
.button-dark { color: #fff; background: var(--green-dark); }
.button-ghost { color: var(--green); background: #edf4ef; }
.full-button { width: 100%; margin-top: 23px; }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; margin: 21px 0 30px; }
.stat-card { padding: 19px 21px; border: 1px solid var(--line); border-radius: 15px; background: #fff; }
.stat-card strong { display: block; margin: 4px 0 0; font-size: 28px; letter-spacing: -.05em; }
.stat-label, .stat-card small { color: var(--muted); font-size: 11px; }
.stat-card small { display: block; margin-top: 2px; }
.accent-card { background: #e5f1e7; border-color: #c5dfcb; }
.report-list { display: grid; gap: 15px; }
.report-card { padding: 25px 27px; }
.report-top { display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; border-bottom: 1px solid var(--line); padding-bottom: 17px; }
.report-date { color: var(--muted); font-size: 12px; letter-spacing: .08em; }
.report-card h2 { margin: 4px 0 5px; font-size: 22px; letter-spacing: -.04em; }
.report-intro { margin: 0; color: var(--muted); font-size: 13px; }
.report-status { padding: 5px 9px; border-radius: 99px; color: var(--green); background: #e4f1e8; font-size: 10px; white-space: nowrap; }
.article-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px 28px; margin-top: 11px; }
.article { padding: 16px 0; border-bottom: 1px solid #edf1ee; }
.article-meta { display: flex; gap: 7px; align-items: center; color: var(--muted); font-size: 10px; }
.tag { display: inline-block; padding: 3px 7px; border-radius: 5px; color: var(--green); background: #e8f2ea; font-weight: 700; }
.article h3 { margin: 7px 0 5px; font-size: 15px; line-height: 1.4; }
.article h3 a:hover { color: var(--orange); }
.article p { display: -webkit-box; margin: 0; overflow: hidden; color: var(--muted); font-size: 12px; line-height: 1.6; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.empty-state { padding: 64px 24px; text-align: center; border: 1px dashed #cbdad0; border-radius: 18px; color: var(--muted); background: rgba(255,255,255,.56); }
.empty-state strong { display: block; margin-bottom: 5px; color: var(--ink); }
.loading-line { color: var(--muted); font-size: 13px; }
.footer { display: flex; justify-content: space-between; width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 0 0 28px; color: #94a49e; font-size: 10px; letter-spacing: .1em; }
.page-heading { margin: 8px 0 31px; }.page-heading h1 { margin: 9px 0 4px; font-size: 42px; letter-spacing: -.06em; }.page-heading p { margin: 0; color: var(--muted); font-size: 14px; }
.settings-shell { padding-top: 46px; }.token-card { display: grid; grid-template-columns: 1fr minmax(310px, 430px); gap: 20px; align-items: center; padding: 22px 25px; margin-bottom: 18px; }.token-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }.token-controls { display: flex; gap: 8px; }.token-controls input, .form-card input[type="email"], .form-card input[type="text"], .form-card input[type="time"] { min-width: 0; width: 100%; height: 40px; padding: 0 11px; border: 1px solid var(--line); border-radius: 8px; outline: none; color: var(--ink); background: #fbfcfb; }.token-controls input:focus, .form-card input:focus { border-color: #8ebda0; box-shadow: 0 0 0 3px #e8f2eb; }.settings-grid { display: grid; grid-template-columns: .88fr 1.12fr; gap: 18px; }.form-card, .recipients-card { padding: 24px; }.card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 23px; }.form-card form > label, .form-row label { display: block; margin-bottom: 15px; color: var(--ink); font-size: 12px; font-weight: 700; }.form-card label input { display: block; margin-top: 6px; }.optional, .picker-label span { margin-left: 5px; color: var(--muted); font-size: 10px; font-weight: 400; }.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.check-line { display: flex !important; gap: 8px; align-items: center; font-weight: 600 !important; }.check-line input { accent-color: var(--green); }.module-picker { padding: 14px; border-radius: 11px; background: #f4f8f4; }.picker-label { margin-bottom: 9px; font-size: 12px; font-weight: 700; }.module-options { display: flex; flex-wrap: wrap; gap: 7px; }.module-option { position: relative; }.module-option input { position: absolute; opacity: 0; }.module-option span { display: block; padding: 7px 9px; border: 1px solid #d5e4d8; border-radius: 7px; color: #5d7169; background: #fff; font-size: 11px; cursor: pointer; }.module-option input:checked + span { border-color: #9bc7a8; color: var(--green-dark); background: #dcefe0; font-weight: 700; }.text-button { border: 0; color: var(--green); background: transparent; font-size: 12px; }.hidden { display: none !important; }.form-message { min-height: 18px; margin-top: 9px; color: var(--muted); font-size: 11px; }.form-message.error { color: #b74d37; }.form-message.success { color: var(--green); }.count-pill { min-width: 25px; padding: 3px 8px; border-radius: 20px; color: var(--green); background: #e6f1e8; font-size: 11px; text-align: center; }.recipient-list { display: grid; gap: 10px; }.recipient-item { display: flex; justify-content: space-between; gap: 12px; padding: 14px 0; border-bottom: 1px solid #edf1ee; }.recipient-item:last-child { border-bottom: 0; }.recipient-email { font-size: 13px; font-weight: 700; }.recipient-sub { margin-top: 4px; color: var(--muted); font-size: 11px; }.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }.chip { padding: 3px 6px; border-radius: 5px; color: var(--green); background: #edf5ee; font-size: 10px; }.recipient-actions { display: flex; align-items: center; gap: 7px; }.icon-button { width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: #fff; }.icon-button:hover { color: var(--green); border-color: #9bc7a8; }.icon-button.delete:hover { color: #bd523c; border-color: #e9b0a1; }
@media (max-width: 840px) { .hero { min-height: auto; flex-direction: column; }.hero-side { align-self: flex-start; padding: 14px 0 0; border-left: 0; border-top: 1px solid rgba(207,231,217,.24); width: 100%; }.toolbar { align-items: flex-start; flex-direction: column; margin-top: 15px; }.filters { width: 100%; justify-content: flex-start; flex-wrap: wrap; }.search-box { flex: 1; width: auto; }.settings-grid { grid-template-columns: 1fr; }.token-card { grid-template-columns: 1fr; }.article-grid { grid-template-columns: 1fr; } }
@media (max-width: 540px) { .topbar { height: 66px; padding: 0 18px; }.nav-link { padding: 8px; font-size: 11px; }.shell { width: calc(100% - 28px); padding-top: 30px; }.hero { padding: 24px 22px 26px; border-radius: 20px; }.hero h1 { font-size: 39px; }.summary-grid { gap: 7px; }.stat-card { padding: 13px 12px; }.stat-card strong { font-size: 22px; }.stat-card small { font-size: 9px; }.report-card { padding: 20px 17px; }.report-top { flex-direction: column; gap: 9px; }.footer { width: calc(100% - 28px); }.page-heading h1 { font-size: 35px; }.token-card, .form-card, .recipients-card { padding: 19px; }.token-controls { flex-direction: column; } }
+316
View File
@@ -0,0 +1,316 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const { Pool } = require('pg');
const PORT = Number(process.env.APP_PORT || 3000);
const PUBLIC_DIR = path.join(__dirname, 'public');
const ADMIN_TOKEN = process.env.APP_ADMIN_TOKEN || '';
const pool = new Pool({
host: process.env.PGHOST || '127.0.0.1',
port: Number(process.env.PGPORT || 5432),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined,
max: 5,
idleTimeoutMillis: 30000,
});
const MIME_TYPES = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
};
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(body);
}
function sendError(res, status, message) {
sendJson(res, status, { error: message });
}
function requireAdmin(req, res) {
if (!ADMIN_TOKEN) {
sendError(res, 503, 'APP_ADMIN_TOKEN 尚未配置');
return false;
}
const supplied = req.headers['x-admin-token'] || '';
const expected = Buffer.from(ADMIN_TOKEN);
const actual = Buffer.from(String(supplied));
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
sendError(res, 401, '管理令牌无效');
return false;
}
return true;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
if (data.length > 1024 * 1024) reject(new Error('请求内容过大'));
});
req.on('end', () => {
if (!data) return resolve({});
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('请求必须是 JSON'));
}
});
req.on('error', reject);
});
}
function validateEmail(value) {
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function normalizeModules(modules) {
if (!Array.isArray(modules)) return [];
return [...new Set(modules.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
}
async function listModules() {
const result = await pool.query(
'SELECT module_key, module_name, description, sort_order FROM newsletter_modules WHERE enabled = TRUE ORDER BY sort_order, module_key',
);
return result.rows;
}
async function listRecipients() {
const result = await pool.query(`
SELECT r.id, r.email, r.display_name, r.enabled, r.send_time, r.timezone,
r.send_all_modules,
COALESCE(array_agg(rm.module_key ORDER BY rm.module_key)
FILTER (WHERE rm.module_key IS NOT NULL), '{}') AS modules
FROM newsletter_recipients r
LEFT JOIN recipient_modules rm ON rm.recipient_id = r.id
GROUP BY r.id
ORDER BY r.enabled DESC, r.email
`);
return result.rows;
}
async function createRecipient(body) {
const email = String(body.email || '').trim().toLowerCase();
if (!validateEmail(email)) throw new Error('请输入有效邮箱');
const modules = normalizeModules(body.modules);
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
INSERT INTO newsletter_recipients
(email, display_name, send_time, timezone, send_all_modules)
VALUES ($1, NULLIF($2, ''), COALESCE(NULLIF($3, '')::time, TIME '07:30'),
COALESCE(NULLIF($4, ''), 'Asia/Shanghai'), $5)
RETURNING id
`, [email, String(body.display_name || '').trim(), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
const recipientId = result.rows[0].id;
if (modules.length) {
await client.query(
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
[recipientId, modules],
);
}
await client.query('COMMIT');
return recipientId;
} catch (error) {
await client.query('ROLLBACK');
if (error.code === '23505') throw new Error('这个邮箱已经存在');
if (error.code === '23503') throw new Error('选择了不存在的模块');
throw error;
} finally {
client.release();
}
}
async function updateRecipient(id, body) {
const modules = normalizeModules(body.modules);
if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块');
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
UPDATE newsletter_recipients
SET display_name = NULLIF($2, ''),
enabled = COALESCE($3, enabled),
send_time = COALESCE(NULLIF($4, '')::time, send_time),
timezone = COALESCE(NULLIF($5, ''), timezone),
send_all_modules = $6,
updated_at = NOW()
WHERE id = $1
RETURNING id
`, [id, String(body.display_name || '').trim(), body.enabled === undefined ? null : Boolean(body.enabled), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]);
if (!result.rowCount) throw new Error('收件邮箱不存在');
await client.query('DELETE FROM recipient_modules WHERE recipient_id = $1', [id]);
if (modules.length) {
await client.query(
'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING',
[id, modules],
);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async function deleteRecipient(id) {
const result = await pool.query('DELETE FROM newsletter_recipients WHERE id = $1 RETURNING id', [id]);
if (!result.rowCount) throw new Error('收件邮箱不存在');
}
async function listReports(query) {
const page = Math.max(1, Number(query.get('page') || 1));
const pageSize = Math.min(30, Math.max(1, Number(query.get('page_size') || 10)));
const params = [];
const conditions = [];
const date = query.get('date');
const q = query.get('q');
const category = query.get('category');
if (date) {
params.push(date);
conditions.push(`r.report_date = $${params.length}::date`);
}
if (q) {
params.push(`%${q}%`);
conditions.push(`EXISTS (
SELECT 1 FROM report_articles rqa
JOIN news_articles nqa ON nqa.id = rqa.article_id
WHERE rqa.report_id = r.id
AND (nqa.title ILIKE $${params.length}
OR COALESCE(nqa.summary, '') ILIKE $${params.length}
OR COALESCE(array_to_string(nqa.keywords, ' '), '') ILIKE $${params.length})
)`);
}
if (category) {
params.push(category);
conditions.push(`EXISTS (
SELECT 1 FROM report_articles rca
JOIN news_articles nca ON nca.id = rca.article_id
WHERE rca.report_id = r.id
AND (rca.section = $${params.length} OR nca.category = $${params.length})
)`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await pool.query(`SELECT COUNT(*)::int AS count FROM daily_reports r ${where}`, params);
const total = countResult.rows[0].count;
params.push(pageSize, (page - 1) * pageSize);
const result = await pool.query(`
SELECT r.id, r.report_date, r.title, r.introduction, r.item_count,
r.status, r.email_status, r.created_at,
COALESCE(json_agg(json_build_object(
'id', a.id, 'title', a.title, 'translated_title', a.translated_title,
'summary', a.summary, 'translated_summary', a.translated_summary,
'url', a.url, 'source_name', s.name, 'category', a.category,
'topics', a.topics, 'region', a.region, 'published_at', a.published_at,
'section', ra.section, 'display_order', ra.display_order,
'is_highlight', ra.is_highlight
) ORDER BY ra.section, ra.display_order)
FILTER (WHERE a.id IS NOT NULL), '[]') AS articles
FROM daily_reports r
LEFT JOIN report_articles ra ON ra.report_id = r.id
LEFT JOIN news_articles a ON a.id = ra.article_id
LEFT JOIN news_sources s ON s.id = a.source_id
${where}
GROUP BY r.id
ORDER BY r.report_date DESC
LIMIT $${params.length - 1} OFFSET $${params.length}
`, params);
return { page, page_size: pageSize, total, reports: result.rows };
}
async function routeApi(req, res, requestUrl) {
const pathname = requestUrl.pathname;
if (req.method === 'GET' && pathname === '/api/health') {
try {
await pool.query('SELECT 1');
return sendJson(res, 200, { ok: true });
} catch (error) {
return sendError(res, 503, `数据库连接失败: ${error.message}`);
}
}
if (req.method === 'GET' && pathname === '/api/modules') {
try { return sendJson(res, 200, { modules: await listModules() }); }
catch (error) { return sendError(res, 500, error.message); }
}
if (req.method === 'GET' && pathname === '/api/reports') {
try { return sendJson(res, 200, await listReports(requestUrl.searchParams)); }
catch (error) { return sendError(res, 500, error.message); }
}
if (pathname.startsWith('/api/recipients')) {
if (!requireAdmin(req, res)) return;
try {
if (req.method === 'GET' && pathname === '/api/recipients') return sendJson(res, 200, { recipients: await listRecipients() });
if (req.method === 'POST' && pathname === '/api/recipients') {
const body = await readBody(req);
const id = await createRecipient(body);
return sendJson(res, 201, { id });
}
const id = Number(pathname.split('/').pop());
if (!Number.isInteger(id)) return sendError(res, 400, '无效的收件邮箱 ID');
if (req.method === 'PUT') {
await updateRecipient(id, await readBody(req));
return sendJson(res, 200, { ok: true });
}
if (req.method === 'DELETE') {
await deleteRecipient(id);
return sendJson(res, 200, { ok: true });
}
} catch (error) {
return sendError(res, error.message.includes('不存在') || error.message.includes('有效邮箱') || error.message.includes('选择') || error.message.includes('已经存在') ? 400 : 500, error.message);
}
}
return sendError(res, 404, '接口不存在');
}
function serveStatic(req, res, pathname) {
let relative = pathname === '/' ? 'index.html' : pathname.slice(1);
if (relative === 'settings') relative = 'settings.html';
if (relative.includes('..')) return sendError(res, 400, '非法路径');
const filePath = path.join(PUBLIC_DIR, relative);
fs.readFile(filePath, (error, content) => {
if (error) return sendError(res, 404, '页面不存在');
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
res.end(content);
});
}
const server = http.createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (requestUrl.pathname.startsWith('/api/')) return await routeApi(req, res, requestUrl);
if (req.method !== 'GET') return sendError(res, 405, '只支持 GET');
return serveStatic(req, res, requestUrl.pathname);
} catch (error) {
sendError(res, 500, error.message || '服务器错误');
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Daily news digest web listening on ${PORT}`);
});
process.on('SIGTERM', async () => {
await pool.end();
server.close(() => process.exit(0));
});