feat: publish dbussmsforward refactor
This commit is contained in:
+358
@@ -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
@@ -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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user