feat: 添加推送通道管理
This commit is contained in:
+90
-1
@@ -20,7 +20,10 @@
|
||||
<a href="#console">AT 控制台</a>
|
||||
<a href="#logs">日志</a>
|
||||
</nav>
|
||||
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
|
||||
<div class="top-nav__actions">
|
||||
<button class="button button--secondary" id="pushSettingsButton" type="button">推送渠道</button>
|
||||
<button class="button button--secondary" id="refreshButton" type="button">刷新状态</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -165,6 +168,92 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal" id="pushModal" aria-hidden="true">
|
||||
<div class="modal__backdrop" data-close-push-modal></div>
|
||||
<section class="modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pushModalTitle">
|
||||
<div class="modal__header">
|
||||
<div>
|
||||
<span class="badge badge--soft">push</span>
|
||||
<h2 id="pushModalTitle">推送渠道设置</h2>
|
||||
</div>
|
||||
<button class="icon-button" id="pushModalClose" type="button" aria-label="关闭推送渠道设置">×</button>
|
||||
</div>
|
||||
|
||||
<div class="push-settings">
|
||||
<aside class="channel-list-panel" aria-label="推送渠道列表">
|
||||
<div class="panel-header panel-header--light">
|
||||
<span>channels</span>
|
||||
<button class="button button--secondary button--compact" id="pushAddButton" type="button">新增</button>
|
||||
</div>
|
||||
<div class="channel-list" id="pushChannelList"></div>
|
||||
</aside>
|
||||
|
||||
<form class="channel-form" id="pushChannelForm">
|
||||
<fieldset class="form-fieldset" id="pushFormFields" disabled>
|
||||
<div class="channel-form__grid">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" name="enabled">
|
||||
<span>启用通道</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>通道名称</span>
|
||||
<input class="text-input" type="text" name="name" maxlength="80" placeholder="钉钉通知">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>推送类型</span>
|
||||
<select class="text-input select-input" name="type">
|
||||
<option value="dingtalk">钉钉机器人</option>
|
||||
<option value="feishu">飞书机器人</option>
|
||||
<option value="telegram">Telegram Bot</option>
|
||||
<option value="pushplus">PushPlus</option>
|
||||
<option value="serverchan">Server酱</option>
|
||||
<option value="post_json">POST JSON</option>
|
||||
<option value="bark">Bark</option>
|
||||
<option value="get">GET</option>
|
||||
<option value="custom">自定义 POST</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="url">
|
||||
<span id="pushUrlLabel">Webhook URL</span>
|
||||
<input class="text-input" type="text" name="url" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="secret">
|
||||
<span id="pushSecretLabel">加签密钥</span>
|
||||
<input class="text-input" type="password" name="secret" autocomplete="new-password">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key1">
|
||||
<span id="pushKey1Label">Key1</span>
|
||||
<input class="text-input" type="text" name="key1" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row" data-push-field="key2">
|
||||
<span id="pushKey2Label">Key2</span>
|
||||
<input class="text-input" type="text" name="key2" autocomplete="off">
|
||||
</label>
|
||||
|
||||
<label class="field-row field-row--wide" data-push-field="customBody">
|
||||
<span>自定义请求体 JSON</span>
|
||||
<textarea class="text-input text-area text-area--code" name="customBody" rows="7" spellcheck="false"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions channel-form__actions">
|
||||
<button class="button button--primary" id="pushSaveButton" type="submit">保存设置</button>
|
||||
<button class="button button--secondary" id="pushTestButton" type="button">发送测试</button>
|
||||
<button class="button button--danger" id="pushDeleteButton" type="button">删除通道</button>
|
||||
<p class="form-status" id="pushStatus" role="status"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer__inner">
|
||||
<span class="brand brand--footer"><span class="brand__mark" aria-hidden="true">✣</span> SMS Gateway</span>
|
||||
|
||||
+248
-5
@@ -45,7 +45,8 @@ body {
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -106,13 +107,19 @@ a {
|
||||
|
||||
.nav-links a:focus-visible,
|
||||
.button:focus-visible,
|
||||
.text-input:focus-visible {
|
||||
.icon-button:focus-visible,
|
||||
.channel-list__item:focus-visible,
|
||||
.text-input:focus-visible,
|
||||
.select-input:focus-visible {
|
||||
outline: 3px solid rgba(204, 120, 92, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.top-nav .button {
|
||||
.top-nav__actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -154,6 +161,33 @@ a {
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border: 1px solid rgba(198, 69, 69, 0.28);
|
||||
background: rgba(198, 69, 69, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.button--compact {
|
||||
min-height: 32px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 9999px;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
padding: 72px 0 96px;
|
||||
background: var(--canvas);
|
||||
@@ -480,11 +514,22 @@ h3 {
|
||||
outline: 3px solid rgba(204, 120, 92, 0.15);
|
||||
}
|
||||
|
||||
.select-input {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
min-height: 144px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.text-area--code {
|
||||
min-height: 160px;
|
||||
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -621,9 +666,178 @@ h3 {
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(20, 20, 19, 0.48);
|
||||
}
|
||||
|
||||
.modal__dialog {
|
||||
position: relative;
|
||||
width: min(1040px, 100%);
|
||||
max-height: min(86vh, 860px);
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: var(--canvas);
|
||||
box-shadow: 0 24px 80px rgba(20, 20, 19, 0.24);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal__header h2 {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.push-settings {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.channel-list-panel {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header--light {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.channel-list__item {
|
||||
width: 100%;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 8px;
|
||||
background: var(--canvas);
|
||||
color: var(--body);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-list__item.is-active {
|
||||
border-color: var(--surface-dark);
|
||||
background: var(--surface-dark);
|
||||
color: var(--on-dark);
|
||||
}
|
||||
|
||||
.channel-list__name {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.channel-list__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-list__item.is-active .channel-list__meta {
|
||||
color: var(--on-dark-soft);
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
border-radius: 12px;
|
||||
background: var(--surface-card);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-fieldset {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-fieldset:disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.channel-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.channel-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--body-strong);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.switch-row input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.field-row--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.channel-form__actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-grid,
|
||||
.workbench-grid {
|
||||
.workbench-grid,
|
||||
.push-settings {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -656,7 +870,7 @@ h3 {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.top-nav .button {
|
||||
.top-nav__actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -720,4 +934,33 @@ h3 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal__dialog {
|
||||
max-height: calc(100vh - 24px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
gap: 16px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.channel-form,
|
||||
.channel-list-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.channel-form__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.switch-row,
|
||||
.field-row--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
+387
-1
@@ -1,8 +1,26 @@
|
||||
const state = {
|
||||
busy: false
|
||||
busy: false,
|
||||
pushChannels: [],
|
||||
selectedPushChannel: -1,
|
||||
pushLoaded: false
|
||||
};
|
||||
|
||||
const elements = {
|
||||
pushSettingsButton: document.querySelector('#pushSettingsButton'),
|
||||
pushModal: document.querySelector('#pushModal'),
|
||||
pushModalClose: document.querySelector('#pushModalClose'),
|
||||
pushAddButton: document.querySelector('#pushAddButton'),
|
||||
pushDeleteButton: document.querySelector('#pushDeleteButton'),
|
||||
pushSaveButton: document.querySelector('#pushSaveButton'),
|
||||
pushTestButton: document.querySelector('#pushTestButton'),
|
||||
pushChannelList: document.querySelector('#pushChannelList'),
|
||||
pushChannelForm: document.querySelector('#pushChannelForm'),
|
||||
pushFormFields: document.querySelector('#pushFormFields'),
|
||||
pushStatus: document.querySelector('#pushStatus'),
|
||||
pushUrlLabel: document.querySelector('#pushUrlLabel'),
|
||||
pushSecretLabel: document.querySelector('#pushSecretLabel'),
|
||||
pushKey1Label: document.querySelector('#pushKey1Label'),
|
||||
pushKey2Label: document.querySelector('#pushKey2Label'),
|
||||
refreshButton: document.querySelector('#refreshButton'),
|
||||
logsButton: document.querySelector('#logsButton'),
|
||||
readyBadge: document.querySelector('#readyBadge'),
|
||||
@@ -29,6 +47,60 @@ const elements = {
|
||||
logWindow: document.querySelector('#logWindow')
|
||||
};
|
||||
|
||||
const PUSH_TYPE_OPTIONS = {
|
||||
dingtalk: '钉钉机器人',
|
||||
feishu: '飞书机器人',
|
||||
telegram: 'Telegram Bot',
|
||||
pushplus: 'PushPlus',
|
||||
serverchan: 'Server酱',
|
||||
post_json: 'POST JSON',
|
||||
bark: 'Bark',
|
||||
get: 'GET',
|
||||
custom: '自定义 POST'
|
||||
};
|
||||
|
||||
const PUSH_TYPE_META = {
|
||||
dingtalk: {
|
||||
urlLabel: '钉钉 Webhook URL',
|
||||
secretLabel: '加签密钥',
|
||||
show: ['url', 'secret']
|
||||
},
|
||||
feishu: {
|
||||
urlLabel: '飞书 Webhook URL',
|
||||
secretLabel: '加签密钥',
|
||||
show: ['url', 'secret']
|
||||
},
|
||||
telegram: {
|
||||
urlLabel: 'Bot Token',
|
||||
key1Label: 'Chat ID',
|
||||
show: ['url', 'key1']
|
||||
},
|
||||
pushplus: {
|
||||
key1Label: 'PushPlus Token',
|
||||
show: ['key1']
|
||||
},
|
||||
serverchan: {
|
||||
key1Label: 'Server酱 SendKey',
|
||||
show: ['key1']
|
||||
},
|
||||
post_json: {
|
||||
urlLabel: 'POST URL',
|
||||
show: ['url']
|
||||
},
|
||||
bark: {
|
||||
urlLabel: 'Bark 推送 URL',
|
||||
show: ['url']
|
||||
},
|
||||
get: {
|
||||
urlLabel: 'GET URL',
|
||||
show: ['url']
|
||||
},
|
||||
custom: {
|
||||
urlLabel: 'POST URL',
|
||||
show: ['url', 'customBody']
|
||||
}
|
||||
};
|
||||
|
||||
function setText(node, value) {
|
||||
node.textContent = value || '--';
|
||||
}
|
||||
@@ -207,6 +279,303 @@ function renderMessages(messages) {
|
||||
elements.messageList.append(fragment);
|
||||
}
|
||||
|
||||
function getPushTypeLabel(type) {
|
||||
return PUSH_TYPE_OPTIONS[type] || type || '推送通道';
|
||||
}
|
||||
|
||||
function createDefaultPushChannel(type = 'dingtalk') {
|
||||
return {
|
||||
enabled: true,
|
||||
type,
|
||||
name: `${getPushTypeLabel(type)}通知`,
|
||||
url: '',
|
||||
secret: '',
|
||||
key1: '',
|
||||
key2: '',
|
||||
customBody: type === 'custom' ? '{"from":"{sender}","message":"{message}","timestamp":"{timestamp}"}' : ''
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePushChannel(channel = {}) {
|
||||
const fallback = createDefaultPushChannel(channel.type || 'dingtalk');
|
||||
return {
|
||||
...fallback,
|
||||
enabled: Boolean(channel.enabled),
|
||||
type: String(channel.type || fallback.type),
|
||||
name: String(channel.name || fallback.name),
|
||||
url: String(channel.url || ''),
|
||||
secret: String(channel.secret || ''),
|
||||
key1: String(channel.key1 || ''),
|
||||
key2: String(channel.key2 || ''),
|
||||
customBody: String(channel.customBody || '')
|
||||
};
|
||||
}
|
||||
|
||||
function setPushStatus(message, type = '') {
|
||||
setStatusMessage(elements.pushStatus, message, type);
|
||||
}
|
||||
|
||||
function getPushControls() {
|
||||
return elements.pushChannelForm.elements;
|
||||
}
|
||||
|
||||
function readPushChannelForm() {
|
||||
const controls = getPushControls();
|
||||
return {
|
||||
enabled: controls.enabled.checked,
|
||||
type: String(controls.type.value || 'dingtalk'),
|
||||
name: String(controls.name.value || '').trim(),
|
||||
url: String(controls.url.value || '').trim(),
|
||||
secret: String(controls.secret.value || '').trim(),
|
||||
key1: String(controls.key1.value || '').trim(),
|
||||
key2: String(controls.key2.value || '').trim(),
|
||||
customBody: String(controls.customBody.value || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function writePushChannelForm(channel) {
|
||||
const controls = getPushControls();
|
||||
controls.enabled.checked = Boolean(channel.enabled);
|
||||
controls.type.value = channel.type;
|
||||
controls.name.value = channel.name;
|
||||
controls.url.value = channel.url;
|
||||
controls.secret.value = channel.secret;
|
||||
controls.key1.value = channel.key1;
|
||||
controls.key2.value = channel.key2;
|
||||
controls.customBody.value = channel.customBody;
|
||||
updatePushFieldVisibility(channel.type);
|
||||
}
|
||||
|
||||
function clearPushChannelForm() {
|
||||
writePushChannelForm(createDefaultPushChannel());
|
||||
}
|
||||
|
||||
function setPushFieldVisible(name, visible) {
|
||||
const row = elements.pushChannelForm.querySelector(`[data-push-field="${name}"]`);
|
||||
if (row) {
|
||||
row.classList.toggle('is-hidden', !visible);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePushFieldVisibility(type) {
|
||||
const meta = PUSH_TYPE_META[type] || PUSH_TYPE_META.dingtalk;
|
||||
const visibleFields = new Set(meta.show || []);
|
||||
|
||||
elements.pushUrlLabel.textContent = meta.urlLabel || 'Webhook URL';
|
||||
elements.pushSecretLabel.textContent = meta.secretLabel || '密钥';
|
||||
elements.pushKey1Label.textContent = meta.key1Label || 'Key1';
|
||||
elements.pushKey2Label.textContent = meta.key2Label || 'Key2';
|
||||
|
||||
['url', 'secret', 'key1', 'key2', 'customBody'].forEach((name) => {
|
||||
setPushFieldVisible(name, visibleFields.has(name));
|
||||
});
|
||||
}
|
||||
|
||||
function syncCurrentPushChannel() {
|
||||
const index = state.selectedPushChannel;
|
||||
if (index < 0 || !state.pushChannels[index] || elements.pushFormFields.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pushChannels[index] = normalizePushChannel(readPushChannelForm());
|
||||
}
|
||||
|
||||
function renderPushChannelList() {
|
||||
elements.pushChannelList.replaceChildren();
|
||||
|
||||
if (!state.pushChannels.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'empty-state empty-state--light';
|
||||
empty.textContent = '暂无推送通道。';
|
||||
elements.pushChannelList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
state.pushChannels.forEach((channel, index) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = `channel-list__item${index === state.selectedPushChannel ? ' is-active' : ''}`;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'channel-list__name';
|
||||
name.textContent = channel.name || getPushTypeLabel(channel.type);
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'channel-list__meta';
|
||||
|
||||
const type = document.createElement('span');
|
||||
type.textContent = getPushTypeLabel(channel.type);
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.textContent = channel.enabled ? '启用' : '停用';
|
||||
|
||||
meta.append(type, status);
|
||||
item.append(name, meta);
|
||||
item.addEventListener('click', () => {
|
||||
selectPushChannel(index);
|
||||
});
|
||||
fragment.append(item);
|
||||
});
|
||||
|
||||
elements.pushChannelList.append(fragment);
|
||||
}
|
||||
|
||||
function renderPushChannelForm() {
|
||||
const channel = state.pushChannels[state.selectedPushChannel];
|
||||
const hasChannel = Boolean(channel);
|
||||
|
||||
elements.pushFormFields.disabled = !hasChannel;
|
||||
elements.pushTestButton.disabled = !hasChannel;
|
||||
elements.pushDeleteButton.disabled = !hasChannel;
|
||||
elements.pushSaveButton.disabled = !state.pushLoaded;
|
||||
|
||||
if (!hasChannel) {
|
||||
clearPushChannelForm();
|
||||
return;
|
||||
}
|
||||
|
||||
writePushChannelForm(channel);
|
||||
}
|
||||
|
||||
function renderPushSettings() {
|
||||
renderPushChannelList();
|
||||
renderPushChannelForm();
|
||||
}
|
||||
|
||||
function selectPushChannel(index) {
|
||||
syncCurrentPushChannel();
|
||||
state.selectedPushChannel = index;
|
||||
renderPushSettings();
|
||||
setPushStatus('');
|
||||
}
|
||||
|
||||
function addPushChannel() {
|
||||
syncCurrentPushChannel();
|
||||
state.pushChannels.push(createDefaultPushChannel());
|
||||
state.selectedPushChannel = state.pushChannels.length - 1;
|
||||
renderPushSettings();
|
||||
setPushStatus('');
|
||||
}
|
||||
|
||||
function deletePushChannel() {
|
||||
if (state.selectedPushChannel < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.pushChannels.splice(state.selectedPushChannel, 1);
|
||||
state.selectedPushChannel = Math.min(state.selectedPushChannel, state.pushChannels.length - 1);
|
||||
renderPushSettings();
|
||||
setPushStatus('');
|
||||
}
|
||||
|
||||
async function loadPushChannels() {
|
||||
elements.pushAddButton.disabled = true;
|
||||
elements.pushSaveButton.disabled = true;
|
||||
elements.pushTestButton.disabled = true;
|
||||
elements.pushDeleteButton.disabled = true;
|
||||
setPushStatus('加载中...');
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/push/channels');
|
||||
state.pushChannels = (result.data || []).map(normalizePushChannel);
|
||||
state.selectedPushChannel = state.pushChannels.length ? 0 : -1;
|
||||
state.pushLoaded = true;
|
||||
renderPushSettings();
|
||||
setPushStatus('');
|
||||
} catch (err) {
|
||||
setPushStatus(err.message, 'error');
|
||||
} finally {
|
||||
elements.pushAddButton.disabled = false;
|
||||
elements.pushSaveButton.disabled = !state.pushLoaded;
|
||||
}
|
||||
}
|
||||
|
||||
function openPushModal() {
|
||||
elements.pushModal.classList.add('is-open');
|
||||
elements.pushModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
if (!state.pushLoaded) {
|
||||
loadPushChannels();
|
||||
} else {
|
||||
renderPushSettings();
|
||||
setPushStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
function closePushModal() {
|
||||
syncCurrentPushChannel();
|
||||
elements.pushModal.classList.remove('is-open');
|
||||
elements.pushModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
async function savePushChannels(event) {
|
||||
event.preventDefault();
|
||||
syncCurrentPushChannel();
|
||||
|
||||
elements.pushSaveButton.disabled = true;
|
||||
setPushStatus('保存中...');
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/push/channels', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ channels: state.pushChannels })
|
||||
});
|
||||
state.pushChannels = (result.data || []).map(normalizePushChannel);
|
||||
if (state.selectedPushChannel >= state.pushChannels.length) {
|
||||
state.selectedPushChannel = state.pushChannels.length - 1;
|
||||
}
|
||||
renderPushSettings();
|
||||
setPushStatus(result.message || '推送通道配置已保存。', 'success');
|
||||
await refreshLogs();
|
||||
} catch (err) {
|
||||
setPushStatus(err.message, 'error');
|
||||
} finally {
|
||||
elements.pushSaveButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testPushChannel() {
|
||||
syncCurrentPushChannel();
|
||||
|
||||
const channel = state.pushChannels[state.selectedPushChannel];
|
||||
if (!channel) {
|
||||
setPushStatus('请先新增一个推送通道。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.pushTestButton.disabled = true;
|
||||
setPushStatus('测试发送中...');
|
||||
|
||||
try {
|
||||
const result = await requestJSON('/api/push/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel })
|
||||
});
|
||||
setPushStatus(result.message || '测试推送已发送。', 'success');
|
||||
await refreshLogs();
|
||||
} catch (err) {
|
||||
setPushStatus(err.message, 'error');
|
||||
} finally {
|
||||
elements.pushTestButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePushTypeChange() {
|
||||
const controls = getPushControls();
|
||||
const type = controls.type.value;
|
||||
|
||||
updatePushFieldVisibility(type);
|
||||
if (!controls.name.value.trim()) {
|
||||
controls.name.value = `${getPushTypeLabel(type)}通知`;
|
||||
}
|
||||
if (type === 'custom' && !controls.customBody.value.trim()) {
|
||||
controls.customBody.value = createDefaultPushChannel('custom').customBody;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
const logs = await requestJSON('/api/logs');
|
||||
renderLogs(logs.data || []);
|
||||
@@ -304,11 +673,28 @@ async function runATCommand(event) {
|
||||
}
|
||||
}
|
||||
|
||||
elements.pushSettingsButton.addEventListener('click', openPushModal);
|
||||
elements.pushModalClose.addEventListener('click', closePushModal);
|
||||
elements.pushModal.addEventListener('click', (event) => {
|
||||
if (event.target.matches('[data-close-push-modal]')) {
|
||||
closePushModal();
|
||||
}
|
||||
});
|
||||
elements.pushAddButton.addEventListener('click', addPushChannel);
|
||||
elements.pushDeleteButton.addEventListener('click', deletePushChannel);
|
||||
elements.pushChannelForm.addEventListener('submit', savePushChannels);
|
||||
elements.pushTestButton.addEventListener('click', testPushChannel);
|
||||
elements.pushChannelForm.elements.type.addEventListener('change', handlePushTypeChange);
|
||||
elements.refreshButton.addEventListener('click', refreshAll);
|
||||
elements.logsButton.addEventListener('click', refreshLogs);
|
||||
elements.messagesButton.addEventListener('click', refreshMessages);
|
||||
elements.smsForm.addEventListener('submit', sendSMS);
|
||||
elements.atForm.addEventListener('submit', runATCommand);
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && elements.pushModal.classList.contains('is-open')) {
|
||||
closePushModal();
|
||||
}
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
setInterval(() => {
|
||||
|
||||
+238
-1
@@ -1,3 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
@@ -7,13 +8,39 @@ import logger from './logger.js';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PUSH_CHANNEL_TYPES = new Set([
|
||||
'post_json',
|
||||
'bark',
|
||||
'get',
|
||||
'dingtalk',
|
||||
'pushplus',
|
||||
'serverchan',
|
||||
'custom',
|
||||
'feishu',
|
||||
'telegram'
|
||||
]);
|
||||
|
||||
const PUSH_CHANNEL_TYPE_LABELS = {
|
||||
post_json: 'POST JSON',
|
||||
bark: 'Bark',
|
||||
get: 'GET',
|
||||
dingtalk: '钉钉',
|
||||
pushplus: 'PushPlus',
|
||||
serverchan: 'Server酱',
|
||||
custom: '自定义',
|
||||
feishu: '飞书',
|
||||
telegram: 'Telegram'
|
||||
};
|
||||
|
||||
class APIServer {
|
||||
constructor(config, modem, smsProcessor) {
|
||||
constructor(config, modem, smsProcessor, pushManager) {
|
||||
this.config = config;
|
||||
this.modem = modem;
|
||||
this.smsProcessor = smsProcessor;
|
||||
this.pushManager = pushManager;
|
||||
this.app = express();
|
||||
this.publicDir = path.join(__dirname, '../public');
|
||||
this.configPath = path.join(__dirname, '../config.json');
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
@@ -194,6 +221,135 @@ class APIServer {
|
||||
</html>`);
|
||||
}
|
||||
|
||||
async readConfigFile() {
|
||||
const configContent = await fs.promises.readFile(this.configPath, 'utf8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
|
||||
async savePushChannels(channels) {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
diskConfig.pushChannels = channels;
|
||||
|
||||
await fs.promises.writeFile(
|
||||
this.configPath,
|
||||
`${JSON.stringify(diskConfig, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
this.config.pushChannels = channels;
|
||||
if (this.pushManager?.config) {
|
||||
this.pushManager.config.pushChannels = channels;
|
||||
}
|
||||
}
|
||||
|
||||
getPushChannelsFromConfig(config = this.config) {
|
||||
return Array.isArray(config.pushChannels) ? config.pushChannels : [];
|
||||
}
|
||||
|
||||
normalizePushChannels(channels) {
|
||||
if (!Array.isArray(channels)) {
|
||||
throw new Error('pushChannels 必须是数组');
|
||||
}
|
||||
|
||||
if (channels.length > 30) {
|
||||
throw new Error('推送通道数量不能超过 30 个');
|
||||
}
|
||||
|
||||
return channels.map((channel, index) => this.normalizePushChannel(channel, index));
|
||||
}
|
||||
|
||||
normalizePushChannel(channel, index = 0) {
|
||||
if (!channel || typeof channel !== 'object' || Array.isArray(channel)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道格式不正确`);
|
||||
}
|
||||
|
||||
const type = this.normalizeString(channel.type, '推送类型', 32);
|
||||
if (!PUSH_CHANNEL_TYPES.has(type)) {
|
||||
throw new Error(`第 ${index + 1} 个推送通道类型不支持`);
|
||||
}
|
||||
|
||||
const typeLabel = PUSH_CHANNEL_TYPE_LABELS[type] || type;
|
||||
const fallbackName = `${typeLabel}通知`;
|
||||
const name = this.normalizeString(channel.name || fallbackName, '通道名称', 80);
|
||||
|
||||
return {
|
||||
enabled: Boolean(channel.enabled),
|
||||
type,
|
||||
name: name || fallbackName,
|
||||
url: this.normalizeString(channel.url, 'URL', 2048),
|
||||
secret: this.normalizeString(channel.secret, '密钥', 2048),
|
||||
key1: this.normalizeString(channel.key1, 'Key1', 2048),
|
||||
key2: this.normalizeString(channel.key2, 'Key2', 2048),
|
||||
customBody: this.normalizeString(channel.customBody, '自定义请求体', 10000)
|
||||
};
|
||||
}
|
||||
|
||||
normalizeString(value, fieldName, maxLength) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maxLength) {
|
||||
throw new Error(`${fieldName}长度不能超过 ${maxLength} 个字符`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
validatePushChannels(channels) {
|
||||
channels.forEach((channel, index) => {
|
||||
this.validatePushChannel(channel, { index });
|
||||
});
|
||||
}
|
||||
|
||||
validatePushChannel(channel, options = {}) {
|
||||
const index = options.index ?? 0;
|
||||
const requireDestination = Boolean(options.requireDestination || channel.enabled);
|
||||
const prefix = `第 ${index + 1} 个推送通道`;
|
||||
const urlTypes = new Set(['post_json', 'bark', 'get', 'dingtalk', 'custom', 'feishu']);
|
||||
|
||||
if (urlTypes.has(channel.type)) {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 URL`);
|
||||
}
|
||||
|
||||
if (channel.url && !this.isHttpUrl(channel.url)) {
|
||||
throw new Error(`${prefix}的 URL 格式不正确`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'telegram') {
|
||||
if (requireDestination && !channel.url) {
|
||||
throw new Error(`${prefix}缺少 Bot Token`);
|
||||
}
|
||||
|
||||
if (requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Chat ID`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channel.type === 'pushplus' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 PushPlus Token`);
|
||||
}
|
||||
|
||||
if (channel.type === 'serverchan' && requireDestination && !channel.key1) {
|
||||
throw new Error(`${prefix}缺少 Server酱 SendKey`);
|
||||
}
|
||||
|
||||
if (channel.type === 'custom' && channel.customBody) {
|
||||
try {
|
||||
JSON.parse(channel.customBody);
|
||||
} catch (err) {
|
||||
throw new Error(`${prefix}的自定义请求体不是有效 JSON`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.app.get('/', (req, res) => {
|
||||
res.redirect('/admin');
|
||||
@@ -357,6 +513,87 @@ class APIServer {
|
||||
}
|
||||
});
|
||||
|
||||
// 查询推送通道配置
|
||||
this.app.get('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const diskConfig = await this.readConfigFile();
|
||||
const channels = this.normalizePushChannels(this.getPushChannelsFromConfig(diskConfig));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('读取推送通道配置失败:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存推送通道配置
|
||||
this.app.put('/api/push/channels', async (req, res) => {
|
||||
try {
|
||||
const rawChannels = Array.isArray(req.body) ? req.body : req.body?.channels;
|
||||
const channels = this.normalizePushChannels(rawChannels);
|
||||
this.validatePushChannels(channels);
|
||||
await this.savePushChannels(channels);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '推送通道配置已保存',
|
||||
data: channels
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('保存推送通道配置失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 测试推送通道
|
||||
this.app.post('/api/push/test', async (req, res) => {
|
||||
try {
|
||||
if (!this.pushManager) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '推送管理器未初始化'
|
||||
});
|
||||
}
|
||||
|
||||
const channel = this.normalizePushChannel(req.body?.channel || req.body);
|
||||
this.validatePushChannel(channel, { requireDestination: true });
|
||||
|
||||
const success = await this.pushManager.pushToChannel(
|
||||
{ ...channel, enabled: true },
|
||||
'测试号码',
|
||||
'这是一条短信网关推送测试消息',
|
||||
new Date().toISOString()
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error: '测试推送发送失败,请查看运行日志'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '测试推送已发送'
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('测试推送失败:', err);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ async function main() {
|
||||
concatManager.startTimeoutChecker();
|
||||
|
||||
// 启动API服务器
|
||||
const apiServer = new APIServer(config, modem, smsProcessor);
|
||||
const apiServer = new APIServer(config, modem, smsProcessor, pushManager);
|
||||
apiServer.start();
|
||||
|
||||
logger.info('========================================');
|
||||
|
||||
Reference in New Issue
Block a user