feat: publish dbussmsforward refactor

This commit is contained in:
2026-05-08 23:54:03 +08:00
commit 9f1ee9ffe6
77 changed files with 15585 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1115;
color: #f5f7fa;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 30%),
linear-gradient(180deg, #0b1020 0%, #0f1115 100%);
}
.layout {
max-width: 1180px;
margin: 0 auto;
padding: 28px;
display: grid;
gap: 18px;
}
.card {
background: rgba(24, 28, 36, 0.92);
border: 1px solid #2a3140;
border-radius: 16px;
padding: 22px;
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(10px);
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.hero-actions,
.actions,
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.panel-header {
margin-bottom: 14px;
}
.eyebrow {
margin: 0 0 10px;
color: #60a5fa;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 10px;
font-size: 34px;
}
h2 {
margin-bottom: 6px;
}
.muted {
color: #94a3b8;
}
.metrics-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metric-card {
display: grid;
gap: 8px;
}
.metric-label {
color: #94a3b8;
font-size: 14px;
}
.metric-value {
font-size: 32px;
line-height: 1.1;
}
.metric-value.small {
font-size: 24px;
word-break: break-all;
}
.metric-value.danger {
color: #f87171;
}
.summary-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-item {
padding: 14px;
border-radius: 12px;
background: #111827;
border: 1px solid #263041;
display: grid;
gap: 6px;
}
.summary-label,
.event-meta,
.event-detail,
.empty-state {
color: #94a3b8;
font-size: 14px;
}
.summary-value {
font-size: 16px;
word-break: break-word;
}
.event-toolbar {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr);
margin-bottom: 14px;
}
.events-list {
display: grid;
gap: 12px;
}
.event-item {
border: 1px solid #263041;
border-radius: 12px;
padding: 14px;
background: #0b1220;
display: grid;
gap: 8px;
}
.event-topline {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.event-badges {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.event-mode-badge,
.event-status,
.event-error-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 72px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
text-transform: uppercase;
}
.event-mode-badge {
background: rgba(59, 130, 246, 0.16);
color: #93c5fd;
}
.event-error-badge {
min-width: auto;
background: rgba(239, 68, 68, 0.16);
color: #fca5a5;
}
.status-success {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.status-failed {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
}
.event-item-failed {
border-color: rgba(239, 68, 68, 0.3);
}
.event-failure-meta {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
color: #fecaca;
font-size: 13px;
}
.event-body {
white-space: pre-wrap;
word-break: break-word;
}
pre,
textarea,
input,
select {
width: 100%;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1220;
color: #e2e8f0;
padding: 12px;
font: 14px/1.5 'SFMono-Regular', ui-monospace, monospace;
}
textarea {
min-height: 260px;
}
label,
.form-grid {
display: grid;
gap: 12px;
}
button {
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
}
button:hover {
filter: brightness(1.08);
}
.empty-state {
padding: 18px;
border: 1px dashed #334155;
border-radius: 12px;
background: rgba(15, 23, 42, 0.55);
}
.mode-hint {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #263041;
background: rgba(15, 23, 42, 0.7);
color: #cbd5e1;
font-size: 14px;
line-height: 1.6;
}
.mode-hint-warning {
border-color: rgba(250, 204, 21, 0.35);
background: rgba(120, 53, 15, 0.28);
color: #fde68a;
}
.result-panel {
display: grid;
gap: 10px;
}
.result-title {
font-size: 16px;
font-weight: 700;
}
.result-title.success {
color: #4ade80;
}
.result-title.failed {
color: #f87171;
}
.result-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.result-item {
padding: 12px;
border-radius: 12px;
border: 1px solid #263041;
background: #0b1220;
display: grid;
gap: 4px;
}
.result-label {
color: #94a3b8;
font-size: 13px;
}
.result-value {
color: #f8fafc;
font-size: 14px;
word-break: break-word;
}
@media (max-width: 960px) {
.metrics-grid,
.summary-grid,
.event-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hero,
.panel-header,
.actions,
.event-topline {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 640px) {
.layout {
padding: 16px;
}
.metrics-grid,
.summary-grid,
.event-toolbar {
grid-template-columns: 1fr;
}
}
+390
View File
@@ -0,0 +1,390 @@
const statusView = document.getElementById('status-view');
const settingsEditor = document.getElementById('settings-editor');
const resultView = document.getElementById('result-view');
const dashboardSummary = document.getElementById('dashboard-summary');
const eventsList = document.getElementById('events-list');
const eventsEmpty = document.getElementById('events-empty');
const refreshDashboardButton = document.getElementById('refresh-dashboard');
const refreshStatusButton = document.getElementById('refresh-status');
const refreshEventsButton = document.getElementById('refresh-events');
const reloadSettingsButton = document.getElementById('reload-settings');
const saveSettingsButton = document.getElementById('save-settings');
const smsForm = document.getElementById('sms-form');
const deliveryModeSelect = document.getElementById('sms-delivery-mode');
const modeHint = document.getElementById('mode-hint');
const eventSearchInput = document.getElementById('event-search');
const eventResultFilter = document.getElementById('event-result-filter');
const metricEventCount = document.getElementById('metric-event-count');
const metricManualSendCount = document.getElementById('metric-manual-send-count');
const metricFailureCount = document.getElementById('metric-failure-count');
const metricLastPhone = document.getElementById('metric-last-phone');
const deliveryModeLabelMap = {
dispatch: '通知转发',
real_sms: '真实短信',
};
let latestEvents = [];
function getDeliveryModeLabel(mode) {
return deliveryModeLabelMap[mode] || mode || '未知';
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatText(value, fallback = '-') {
const text = String(value ?? '').trim();
return text || fallback;
}
function getResultLabel(result) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return formatText(result, '未知');
}
function updateModeHint() {
const deliveryMode = deliveryModeSelect?.value || 'dispatch';
if (!modeHint) {
return;
}
if (deliveryMode === 'real_sms') {
modeHint.textContent = '当前为真实短信:需要已配置 ModemManager 发送能力;若发送失败,将展示错误分类与失败原因。';
modeHint.classList.add('mode-hint-warning');
return;
}
modeHint.textContent = '当前为通知转发:会按已启用通道分发,并写入事件留痕。';
modeHint.classList.remove('mode-hint-warning');
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
throw new Error(`响应不是合法 JSON: ${text}`);
}
if (!response.ok) {
const failure = new Error(data.detail || data.error || `HTTP ${response.status}`);
failure.payload = data;
failure.status = response.status;
throw failure;
}
return data;
}
function renderJson(element, payload) {
element.textContent = JSON.stringify(payload, null, 2);
}
function renderResult(payload, options = {}) {
const status = options.status || 'success';
const title = options.title || (status === 'failed' ? '发送失败' : '操作成功');
const rows = Object.entries(payload || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
if (!rows.length) {
renderJson(resultView, payload || {});
return;
}
resultView.innerHTML = `
<div class="result-panel">
<div class="result-title ${escapeHtml(status)}">${escapeHtml(title)}</div>
<div class="result-grid">
${rows
.map(
([label, value]) => `
<div class="result-item">
<span class="result-label">${escapeHtml(label)}</span>
<span class="result-value">${escapeHtml(formatText(value))}</span>
</div>
`
)
.join('')}
</div>
</div>
`;
}
function updateMetric(element, value, fallback = '-') {
element.textContent = value ?? fallback;
}
function renderSummaryItem(label, value) {
return `
<article class="summary-item">
<span class="summary-label">${escapeHtml(label)}</span>
<strong class="summary-value">${escapeHtml(formatText(value))}</strong>
</article>
`;
}
function renderDashboard(payload) {
const { summary = {}, runtime = {} } = payload;
updateMetric(metricEventCount, summary.event_count ?? 0, '0');
updateMetric(metricManualSendCount, summary.manual_send_count ?? 0, '0');
updateMetric(metricFailureCount, summary.failure_count ?? 0, '0');
updateMetric(metricLastPhone, summary.last_sms_phone_number || '-');
dashboardSummary.innerHTML = [
renderSummaryItem('服务状态', runtime.status || 'unknown'),
renderSummaryItem('启动时间', runtime.started_at || '-'),
renderSummaryItem('请求次数', runtime.request_count ?? 0),
renderSummaryItem('配置文件', runtime.config_path || '未落盘'),
renderSummaryItem('运行模式', runtime.daemon?.mode || '-'),
renderSummaryItem('监听地址', `${runtime.daemon?.host || '-'}:${runtime.daemon?.port || '-'}`),
renderSummaryItem('日志级别', runtime.daemon?.log_level || '-'),
renderSummaryItem('设备名', runtime.forwarding?.device_name || '-'),
renderSummaryItem('启用通道', (runtime.notifications?.enabled_channels || []).join(', ') || '无'),
renderSummaryItem('短信源', runtime.sms_source?.kind || '-'),
renderSummaryItem('源路径', runtime.sms_source?.path || '-'),
renderSummaryItem('轮询间隔', runtime.sms_source?.poll_interval_seconds ?? '-'),
renderSummaryItem('当前事件数', summary.filtered_event_count ?? summary.event_count ?? 0),
].join('');
}
function getEventSearchText(event) {
return [
event.phone_number,
event.content,
event.detail,
event.error_category,
getDeliveryModeLabel(event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch')),
(event.channels || []).join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
function getFilteredEvents(events = latestEvents) {
const keyword = (eventSearchInput?.value || '').trim().toLowerCase();
const resultFilter = eventResultFilter?.value || 'all';
return events.filter((event) => {
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
if (resultFilter !== 'all' && normalizedResult !== resultFilter) {
return false;
}
if (!keyword) {
return true;
}
return `${normalizedResult} ${getResultLabel(normalizedResult)} ${getEventSearchText(event)}`.includes(keyword);
});
}
function renderEvents(events = latestEvents) {
latestEvents = Array.isArray(events) ? events : [];
const filteredEvents = getFilteredEvents(latestEvents);
if (!filteredEvents.length) {
eventsList.innerHTML = '';
eventsEmpty.style.display = 'block';
eventsEmpty.textContent = latestEvents.length
? '没有匹配当前筛选条件的事件,换个关键字或筛选项试试。'
: '还没有事件,先去下面的短信测试台发一条试试。';
return;
}
eventsEmpty.style.display = 'none';
eventsList.innerHTML = filteredEvents
.map((event) => {
const channels = (event.channels || []).join(', ') || '无';
const normalizedResult = event.result === 'success' || event.result === 'failed' ? event.result : 'unknown';
const statusClass = normalizedResult === 'success' ? 'status-success' : 'status-failed';
const deliveryMode = event.delivery_mode || ((event.channels || []).includes('real_sms') ? 'real_sms' : 'dispatch');
const deliveryModeLabel = getDeliveryModeLabel(deliveryMode);
const errorCategory = formatText(event.error_category, '');
return `
<article class="event-item ${normalizedResult === 'failed' ? 'event-item-failed' : ''}">
<div class="event-topline">
<strong>${escapeHtml(formatText(event.phone_number))}</strong>
<div class="event-badges">
<span class="event-mode-badge">${escapeHtml(deliveryModeLabel)}</span>
<span class="event-status ${statusClass}">${escapeHtml(getResultLabel(normalizedResult))}</span>
</div>
</div>
<div class="event-meta">时间:${escapeHtml(formatText(event.created_at))} · 来源:${escapeHtml(formatText(event.received_at))}</div>
<div class="event-body">${escapeHtml(formatText(event.content))}</div>
<div class="event-meta">通知数:${escapeHtml(formatText(event.notification_count, '0'))} · 通道:${escapeHtml(channels)}</div>
${errorCategory ? `<div class="event-failure-meta"><span class="event-error-badge">错误分类</span><span>${escapeHtml(errorCategory)}</span></div>` : ''}
<div class="event-detail">${escapeHtml(formatText(event.detail, '无补充说明'))}</div>
</article>
`;
})
.join('');
}
async function loadStatus() {
const status = await fetchJson('/api/system/status');
renderJson(statusView, status);
}
async function loadSettings() {
const settings = await fetchJson('/api/settings');
settingsEditor.value = JSON.stringify(settings, null, 2);
}
async function loadDashboard() {
const payload = await fetchJson('/api/dashboard');
renderDashboard(payload);
renderEvents(payload.latest_events || []);
}
async function loadEvents() {
const payload = await fetchJson('/api/events');
renderEvents(payload.events || []);
}
function bindEventFilters() {
eventSearchInput?.addEventListener('input', () => renderEvents(latestEvents));
eventResultFilter?.addEventListener('change', () => renderEvents(latestEvents));
}
async function refreshAll() {
await Promise.all([loadDashboard(), loadStatus(), loadSettings()]);
}
async function saveSettings() {
const parsed = JSON.parse(settingsEditor.value || '{}');
const payload = await fetchJson('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed),
});
renderResult(
{
操作: '保存配置',
状态: '成功',
说明: '配置已通过校验并写入当前运行实例。',
},
{ title: '配置保存成功' }
);
await refreshAll();
}
async function sendSms(event) {
event.preventDefault();
const formData = new FormData(smsForm);
const deliveryMode = formData.get('delivery_mode') || 'dispatch';
const delivery_mode_label = getDeliveryModeLabel(deliveryMode);
try {
const payload = await fetchJson('/api/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone_number: formData.get('phone_number'),
content: formData.get('content'),
delivery_mode: deliveryMode === 'real_sms' ? 'real_sms' : 'dispatch',
}),
});
renderResult(
{
操作: '发送测试短信',
状态: '成功',
delivery_mode_label,
手机号: payload.phone_number,
来源: payload.received_at,
MessagePath: payload.message_path,
ModemPath: payload.modem_path,
},
{ title: `${delivery_mode_label}发送成功` }
);
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
} catch (error) {
renderResult(
{
操作: '发送测试短信',
状态: '发送失败',
delivery_mode_label,
错误分类: error.payload?.error_category || error.payload?.error || 'unknown',
失败原因: error.payload?.detail || error.message,
HTTP状态: error.status || '-',
},
{ status: 'failed', title: `${delivery_mode_label}发送失败` }
);
await Promise.all([loadDashboard(), loadEvents(), loadStatus()]);
}
}
async function boot() {
try {
updateModeHint();
bindEventFilters();
await refreshAll();
} catch (error) {
resultView.textContent = error.message;
}
}
refreshDashboardButton.addEventListener('click', async () => {
try {
await loadDashboard();
} catch (error) {
resultView.textContent = error.message;
}
});
refreshStatusButton.addEventListener('click', async () => {
try {
await loadStatus();
} catch (error) {
resultView.textContent = error.message;
}
});
refreshEventsButton.addEventListener('click', async () => {
try {
await loadEvents();
} catch (error) {
resultView.textContent = error.message;
}
});
reloadSettingsButton.addEventListener('click', async () => {
try {
await loadSettings();
renderResult(
{
操作: '重载配置',
状态: '成功',
说明: '编辑器已同步当前运行配置。',
},
{ title: '配置已重载' }
);
} catch (error) {
resultView.textContent = error.message;
}
});
saveSettingsButton.addEventListener('click', async () => {
try {
await saveSettings();
} catch (error) {
resultView.textContent = error.message;
}
});
if (deliveryModeSelect) {
deliveryModeSelect.addEventListener('change', updateModeHint);
}
smsForm.addEventListener('submit', async (event) => {
try {
await sendSms(event);
} catch (error) {
resultView.textContent = error.message;
}
});
boot();
+149
View File
@@ -0,0 +1,149 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DbusSmsForward Web</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<main class="layout">
<section class="hero card">
<div>
<p class="eyebrow">DbusSmsForward · Debian-first</p>
<h1>短信转发控制台</h1>
<p class="muted">
一个可直接交付的成品界面:运行总览、最近事件、配置中心、短信测试台,全部放到同一个 Web 面板里。
</p>
</div>
<div class="hero-actions">
<button id="refresh-dashboard" type="button">刷新总览</button>
<button id="refresh-status" type="button">刷新状态</button>
</div>
</section>
<section class="metrics-grid">
<article class="metric-card card">
<span class="metric-label">累计事件</span>
<strong id="metric-event-count" class="metric-value">-</strong>
</article>
<article class="metric-card card">
<span class="metric-label">手工发送成功</span>
<strong id="metric-manual-send-count" class="metric-value">-</strong>
</article>
<article class="metric-card card">
<span class="metric-label">失败次数</span>
<strong id="metric-failure-count" class="metric-value danger">-</strong>
</article>
<article class="metric-card card">
<span class="metric-label">最近手机号</span>
<strong id="metric-last-phone" class="metric-value small">-</strong>
</article>
</section>
<section class="card split-card">
<div class="panel-header">
<div>
<h2>运行总览</h2>
<p class="muted">聚合运行状态、配置来源、通知通道与轮询源信息。</p>
</div>
</div>
<div id="dashboard-summary" class="summary-grid"></div>
</section>
<section class="card split-card">
<div class="panel-header">
<div>
<h2>最近事件</h2>
<p class="muted">展示最近短信转发记录,便于直接看成功/失败、发送方式与错误分类。</p>
</div>
<button id="refresh-events" type="button">刷新事件</button>
</div>
<div class="event-toolbar">
<label>
事件搜索
<input id="event-search" type="search" placeholder="搜手机号、状态、方式、错误分类、明细" />
</label>
<label>
结果筛选
<select id="event-result-filter">
<option value="all">全部事件</option>
<option value="failed">只看失败</option>
<option value="success">只看成功</option>
</select>
</label>
</div>
<div id="events-empty" class="empty-state">还没有事件,先去下面的短信测试台发一条试试。</div>
<div id="events-list" class="events-list"></div>
</section>
<section class="card">
<div class="panel-header">
<div>
<h2>系统状态</h2>
<p class="muted">原始接口状态,适合排错时直接查看 JSON。</p>
</div>
</div>
<pre id="status-view">加载中...</pre>
</section>
<section class="card">
<div class="panel-header">
<div>
<h2>配置中心</h2>
<p class="muted">直接在线改配置并落盘,适合 Debian/OpenWRT 迁移后的单机维护。</p>
</div>
<div class="actions">
<button id="reload-settings" type="button">重载配置</button>
<button id="save-settings" type="button">保存配置</button>
</div>
</div>
<textarea id="settings-editor" rows="20"></textarea>
</section>
<section class="card">
<div class="panel-header">
<div>
<h2>短信测试台</h2>
<p class="muted">用于手工发送测试短信,验证通知链路与事件留痕。</p>
</div>
</div>
<form id="sms-form" class="form-grid">
<label>
手机号
<input id="sms-phone" name="phone_number" placeholder="10086" required />
</label>
<label>
短信内容
<textarea id="sms-content" name="content" rows="5" placeholder="测试短信" required></textarea>
</label>
<label>
发送方式
<select id="sms-delivery-mode" name="delivery_mode">
<option value="dispatch">通知转发</option>
<option value="real_sms">真实短信</option>
</select>
</label>
<div id="mode-hint" class="mode-hint">
当前为通知转发:会按已启用通道分发,并写入事件留痕。
</div>
<div class="actions">
<button type="submit">发送测试短信</button>
</div>
</form>
</section>
<section class="card">
<div class="panel-header">
<div>
<h2>操作结果</h2>
<p class="muted">显示最近一次保存配置、刷新或手工发送的回执。</p>
</div>
</div>
<pre id="result-view">等待操作...</pre>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>