залил
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,414 @@
|
||||
/* ---------- БАЗОВЫЕ СТИЛИ И ТЕМЫ ---------- */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f4f4f4;
|
||||
color: #333;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
button, input[type="submit"] {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
input[type="text"], input[type="password"], textarea, select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
.status.online {
|
||||
color: green;
|
||||
}
|
||||
.status.offline {
|
||||
color: red;
|
||||
}
|
||||
.theme-switch {
|
||||
cursor: pointer;
|
||||
background: #555;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
.modal-content pre {
|
||||
background: #f0f0f0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.provider-fields {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.provider-fields label {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.test-result-block {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.action-item {
|
||||
background: #f9f9f9;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.action-fields {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.action-fields label {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
button.remove-action {
|
||||
background: #d9534f;
|
||||
margin-top: 5px;
|
||||
}
|
||||
#log-container {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
height: 70vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- ТЁМНАЯ ТЕМА ---------- */
|
||||
body.dark {
|
||||
background: #1e1e1e;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark nav {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
body.dark nav a {
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark nav a:hover {
|
||||
color: white;
|
||||
}
|
||||
body.dark .card {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
body.dark input, body.dark textarea, body.dark select {
|
||||
background: #3c3c3c;
|
||||
border-color: #555;
|
||||
color: #eee;
|
||||
}
|
||||
body.dark table th {
|
||||
background-color: #3c3c3c;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark table td {
|
||||
border-color: #444;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark button {
|
||||
background: #0d6efd;
|
||||
}
|
||||
body.dark .modal-content {
|
||||
background: #2d2d2d;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark .modal-content pre {
|
||||
background: #1e1e1e;
|
||||
color: #ddd;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
body.dark .test-result-block {
|
||||
background: #1e1e1e;
|
||||
color: #ddd;
|
||||
border-color: #555;
|
||||
}
|
||||
body.dark .theme-switch {
|
||||
background: #444;
|
||||
color: #ddd;
|
||||
}
|
||||
body.dark table thead th,
|
||||
body.dark table th {
|
||||
background-color: #3c3c3c !important;
|
||||
color: #eee !important;
|
||||
border-color: #555 !important;
|
||||
}
|
||||
body.dark table tbody td {
|
||||
background-color: #2d2d2d;
|
||||
color: #ddd;
|
||||
border-color: #555;
|
||||
}
|
||||
body.dark table {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
body.dark .action-item {
|
||||
background: #2d2d2d;
|
||||
border-color: #555;
|
||||
}
|
||||
body.dark #log-container {
|
||||
background: #0a0a0a;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
body.dark #notification {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* ---------- АДАПТИВНОСТЬ ---------- */
|
||||
@media (max-width: 768px) {
|
||||
nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
nav div:first-child {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
nav a {
|
||||
margin-right: 0;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
.card {
|
||||
padding: 15px;
|
||||
}
|
||||
button, input[type="submit"] {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th, td {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
padding: 15px;
|
||||
}
|
||||
.action-item button {
|
||||
width: auto;
|
||||
margin-right: 5px;
|
||||
}
|
||||
#notification {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
input, textarea, select {
|
||||
font-size: 16px;
|
||||
}
|
||||
#users-table button {
|
||||
margin: 2px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
nav div:first-child a {
|
||||
font-size: 0.9rem;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.card h2, .card h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
button {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Модальное окно */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
body.dark .modal-content {
|
||||
background: #2d2d2d;
|
||||
color: #ddd;
|
||||
}
|
||||
.modal-content h3, .modal-content h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.modal-content label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.modal-content input[type="text"],
|
||||
.modal-content input[type="number"],
|
||||
.modal-content select,
|
||||
.modal-content textarea {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
margin-top: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-content button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.preview-box {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-top: 15px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
body.dark .preview-box {
|
||||
background: #1e1e1e;
|
||||
border-color: #555;
|
||||
}
|
||||
.chat-preview {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.alert-preview {
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.alert-preview img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.alert-preview .content {
|
||||
flex: 1;
|
||||
}
|
||||
.alert-preview .title {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.action-item {
|
||||
background: #f9f9f9;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
body.dark .action-item {
|
||||
background: #2d2d2d;
|
||||
border-color: #555;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{{define "content"}}
|
||||
<h2>Настройки нейросетей</h2>
|
||||
<div class="card">
|
||||
<form id="ai-form">
|
||||
<label>Провайдер:</label>
|
||||
<select id="provider" name="provider">
|
||||
<option value="ollama">Ollama (локальная)</option>
|
||||
<option value="chatgpt">ChatGPT (OpenAI)</option>
|
||||
<option value="gigachat">GigaChat (Сбер)</option>
|
||||
</select>
|
||||
|
||||
<div id="ollama-fields" class="provider-fields">
|
||||
<label>Endpoint:</label>
|
||||
<input type="text" id="endpoint" name="endpoint" placeholder="http://localhost:11434">
|
||||
<label>Модель:</label>
|
||||
<input type="text" id="model" name="model" placeholder="llama2, mistral, ...">
|
||||
</div>
|
||||
<div id="chatgpt-fields" class="provider-fields" style="display:none;">
|
||||
<label>API Key:</label>
|
||||
<input type="password" id="api_key" name="api_key" placeholder="sk-...">
|
||||
<label>Модель:</label>
|
||||
<input type="text" id="model_gpt" name="model_gpt" placeholder="gpt-3.5-turbo">
|
||||
</div>
|
||||
<div id="gigachat-fields" class="provider-fields" style="display:none;">
|
||||
<label>Client ID:</label>
|
||||
<input type="text" id="client_id" name="client_id" placeholder="ваш client_id">
|
||||
<label>Client Secret:</label>
|
||||
<input type="password" id="client_secret" name="client_secret" placeholder="ваш client_secret">
|
||||
<label>Endpoint (опционально):</label>
|
||||
<input type="text" id="endpoint_giga" name="endpoint_giga" placeholder="https://gigachat.devices.sberbank.ru/api/v1">
|
||||
<label>Модель (опционально):</label>
|
||||
<input type="text" id="model_giga" name="model_giga" placeholder="GigaChat">
|
||||
</div>
|
||||
|
||||
<label>Системный промпт (префикс):</label>
|
||||
<textarea id="system_prompt" name="system_prompt" rows="3">ты в чате твитча, ответь одним предложением.</textarea>
|
||||
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Тестирование</h3>
|
||||
<input type="text" id="test-prompt" placeholder="Введите вопрос для нейросети" style="width: 70%;">
|
||||
<button id="test-btn">Отправить</button>
|
||||
<div id="test-result" class="test-result-block"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const providerSelect = document.getElementById('provider');
|
||||
const ollamaFields = document.getElementById('ollama-fields');
|
||||
const chatgptFields = document.getElementById('chatgpt-fields');
|
||||
const gigachatFields = document.getElementById('gigachat-fields');
|
||||
|
||||
function showProviderFields() {
|
||||
const prov = providerSelect.value;
|
||||
ollamaFields.style.display = 'none';
|
||||
chatgptFields.style.display = 'none';
|
||||
gigachatFields.style.display = 'none';
|
||||
if (prov === 'ollama') ollamaFields.style.display = 'block';
|
||||
else if (prov === 'chatgpt') chatgptFields.style.display = 'block';
|
||||
else if (prov === 'gigachat') gigachatFields.style.display = 'block';
|
||||
}
|
||||
providerSelect.addEventListener('change', showProviderFields);
|
||||
|
||||
async function loadConfig() {
|
||||
const res = await fetch('/api/ai/config');
|
||||
const cfg = await res.json();
|
||||
providerSelect.value = cfg.provider || 'ollama';
|
||||
document.getElementById('endpoint').value = cfg.endpoint || '';
|
||||
document.getElementById('model').value = cfg.model || '';
|
||||
document.getElementById('api_key').value = cfg.api_key || '';
|
||||
document.getElementById('model_gpt').value = cfg.model || '';
|
||||
document.getElementById('client_id').value = cfg.client_id || '';
|
||||
document.getElementById('client_secret').value = cfg.client_secret || '';
|
||||
document.getElementById('endpoint_giga').value = cfg.endpoint || '';
|
||||
document.getElementById('model_giga').value = cfg.model || '';
|
||||
document.getElementById('system_prompt').value = cfg.system_prompt || 'ты в чате твитча, ответь одним предложением.';
|
||||
showProviderFields();
|
||||
}
|
||||
|
||||
document.getElementById('ai-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const provider = providerSelect.value;
|
||||
let apiKey = '', endpoint = '', model = '', clientId = '', clientSecret = '';
|
||||
if (provider === 'ollama') {
|
||||
endpoint = document.getElementById('endpoint').value;
|
||||
model = document.getElementById('model').value;
|
||||
} else if (provider === 'chatgpt') {
|
||||
apiKey = document.getElementById('api_key').value;
|
||||
model = document.getElementById('model_gpt').value;
|
||||
} else if (provider === 'gigachat') {
|
||||
clientId = document.getElementById('client_id').value;
|
||||
clientSecret = document.getElementById('client_secret').value;
|
||||
endpoint = document.getElementById('endpoint_giga').value;
|
||||
model = document.getElementById('model_giga').value;
|
||||
}
|
||||
const systemPrompt = document.getElementById('system_prompt').value;
|
||||
const body = {
|
||||
provider,
|
||||
api_key: apiKey,
|
||||
endpoint,
|
||||
model,
|
||||
system_prompt: systemPrompt,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/ai/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Настройки сохранены');
|
||||
await loadConfig();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
alert('Ошибка сохранения: ' + err);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка соединения: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('test-btn').addEventListener('click', async () => {
|
||||
const prompt = document.getElementById('test-prompt').value;
|
||||
if (!prompt) return;
|
||||
try {
|
||||
const res = await fetch('/api/ai/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
document.getElementById('test-result').innerHTML = `<strong>Ответ:</strong> ${escapeHtml(data.answer)}`;
|
||||
} else {
|
||||
const err = await res.text();
|
||||
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка: ${escapeHtml(err)}</span>`;
|
||||
}
|
||||
} catch (err) {
|
||||
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка соединения: ${err.message}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||
<title>TTW_Bot</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div>
|
||||
<a href="/">Главная</a>
|
||||
<a href="/platforms">Платформы</a>
|
||||
<a href="/commands">Команды</a>
|
||||
<a href="/events">События</a>
|
||||
<a href="/hotkeys">Горячие клавиши</a>
|
||||
<a href="/webservices">Веб-сервисы</a>
|
||||
<a href="/logs">Логи</a>
|
||||
<a href="/ai">Нейросети</a>
|
||||
<a href="/notifications">Уведомления</a>
|
||||
</div>
|
||||
<div class="theme-switch" id="theme-toggle">🌙 Тёмная тема</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
<footer style="text-align: center; margin-top: 20px; font-size: 0.8em; color: gray;">
|
||||
TTW_Bot версия 11.0.43
|
||||
</footer>
|
||||
<script>
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const currentTheme = localStorage.getItem('theme');
|
||||
if (currentTheme === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
themeToggle.textContent = '☀️ Светлая тема';
|
||||
}
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark');
|
||||
const isDark = document.body.classList.contains('dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
themeToggle.textContent = isDark ? '☀️ Светлая тема' : '🌙 Тёмная тема';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
{{define "content"}}
|
||||
<h2>Команды чата</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Добавить / редактировать команду</h3>
|
||||
<form id="command-form">
|
||||
<input type="hidden" id="command-id" value="0">
|
||||
<label>Триггер (без !):</label>
|
||||
<input type="text" id="trigger" required placeholder="ping">
|
||||
|
||||
<label>Шаблон ответа:</label>
|
||||
<textarea id="template" rows="5" required placeholder="Pong! <USERNAME/>"></textarea>
|
||||
|
||||
<!-- Панель кнопок для вставки тегов -->
|
||||
<div style="margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<button type="button" class="tag-btn" data-tag="<USERNAME/>">👤 USERNAME</button>
|
||||
<button type="button" class="tag-btn" data-tag="<ARG/>">📝 ARG</button>
|
||||
<button type="button" class="tag-btn" data-tag="<AI/>">🤖 AI</button>
|
||||
<button type="button" class="tag-btn" data-tag="<RANDOMUSER/>">🎲 RANDOMUSER</button>
|
||||
<button type="button" id="random-btn">🔢 Случайное число</button>
|
||||
<button type="button" id="song-btn">🎵 Вставить звук</button>
|
||||
<button type="button" id="group-btn">📦 Обертка group</button>
|
||||
<button type="button" id="timeout-btn">⏱ Отстранение</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button" id="test-btn">🧪 Тестировать</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" id="enabled" checked> Включена
|
||||
</label>
|
||||
<label>Кулдаун (секунды):</label>
|
||||
<input type="number" id="cooldown" value="0" min="0">
|
||||
<label>Права доступа:</label>
|
||||
<select id="permission">
|
||||
<option value="everyone">Все</option>
|
||||
<option value="moderator">Модераторы</option>
|
||||
<option value="subscriber">Подписчики</option>
|
||||
<option value="vip">VIP</option>
|
||||
<option value="broadcaster">Стример</option>
|
||||
</select>
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" id="cancel-edit" style="background: #6c757d;">Отмена</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Список команд</h3>
|
||||
<table style="width:100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr><th>Триггер</th><th>Шаблон</th><th>Кулдаун</th><th>Права</th><th>Статус</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody id="commands-table">
|
||||
<tr><td colspan="6">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для тестирования -->
|
||||
<div id="test-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Результат тестирования</h3>
|
||||
<p><strong>Текст для чата:</strong></p>
|
||||
<pre id="test-result" style="background:#f0f0f0; padding:10px; border-radius:4px; white-space:pre-wrap;"></pre>
|
||||
<p><strong>Звуковые файлы:</strong> <span id="test-sounds"></span></p>
|
||||
<button id="close-modal" style="margin-top:10px;">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для случайного числа -->
|
||||
<div id="random-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Вставить случайное число</h3>
|
||||
<label>Минимум:</label>
|
||||
<input type="number" id="random-min" value="1">
|
||||
<label>Максимум:</label>
|
||||
<input type="number" id="random-max" value="100">
|
||||
<div style="margin-top:15px;">
|
||||
<button id="insert-random-btn">Вставить</button>
|
||||
<button id="cancel-random-btn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для звука -->
|
||||
<div id="song-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Вставить звук</h3>
|
||||
<label>Выберите звуковой файл:</label>
|
||||
<select id="song-select" style="width:100%;">
|
||||
<option value="">-- нет звуков --</option>
|
||||
</select>
|
||||
<div style="margin-top:15px;">
|
||||
<button id="insert-song-btn">Вставить</button>
|
||||
<button id="cancel-song-btn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для группы -->
|
||||
<div id="group-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Создать группу вариантов</h3>
|
||||
<div id="group-variants">
|
||||
<div class="group-variant" style="margin-bottom:8px;">
|
||||
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
|
||||
</div>
|
||||
<div class="group-variant" style="margin-bottom:8px;">
|
||||
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="add-variant-btn">+ Добавить вариант</button>
|
||||
<button type="button" id="remove-variant-btn" style="margin-left:10px;">− Удалить последний</button>
|
||||
<div style="margin-top:15px;">
|
||||
<button id="insert-group-btn">Вставить</button>
|
||||
<button id="cancel-group-btn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для отстранения (timeout) -->
|
||||
<div id="timeout-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>Отстранение (таймаут)</h3>
|
||||
<label>Количество минут:</label>
|
||||
<input type="number" id="timeout-minutes" value="5" min="1" max="1440">
|
||||
<div style="margin-top:15px;">
|
||||
<button id="insert-timeout-btn">Вставить</button>
|
||||
<button id="cancel-timeout-btn">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let commands = [];
|
||||
let soundList = [];
|
||||
|
||||
async function loadSoundList() {
|
||||
try {
|
||||
const res = await fetch('/api/sounds/list');
|
||||
if (res.ok) {
|
||||
soundList = await res.json();
|
||||
const select = document.getElementById('song-select');
|
||||
select.innerHTML = '<option value="">-- выберите звук --</option>';
|
||||
soundList.forEach(sound => {
|
||||
const option = document.createElement('option');
|
||||
option.value = sound;
|
||||
option.textContent = sound;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch(e) { console.error('Failed to load sounds', e); }
|
||||
}
|
||||
|
||||
function insertAtCursor(textareaId, text) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
textarea.value = value.slice(0, start) + text + value.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// Простые теги
|
||||
document.querySelectorAll('.tag-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
insertAtCursor('template', btn.dataset.tag);
|
||||
});
|
||||
});
|
||||
|
||||
// Random
|
||||
document.getElementById('random-btn').addEventListener('click', () => {
|
||||
document.getElementById('random-modal').style.display = 'flex';
|
||||
});
|
||||
document.getElementById('insert-random-btn').addEventListener('click', () => {
|
||||
const min = document.getElementById('random-min').value;
|
||||
const max = document.getElementById('random-max').value;
|
||||
const tag = `<random s=${min} e=${max}/>`;
|
||||
insertAtCursor('template', tag);
|
||||
document.getElementById('random-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('cancel-random-btn').addEventListener('click', () => {
|
||||
document.getElementById('random-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Song
|
||||
document.getElementById('song-btn').addEventListener('click', async () => {
|
||||
await loadSoundList();
|
||||
document.getElementById('song-modal').style.display = 'flex';
|
||||
});
|
||||
document.getElementById('insert-song-btn').addEventListener('click', () => {
|
||||
const selected = document.getElementById('song-select').value;
|
||||
if (!selected) {
|
||||
alert('Выберите звуковой файл');
|
||||
return;
|
||||
}
|
||||
const tag = `<song f="data/sounds/${selected}"/>`;
|
||||
insertAtCursor('template', tag);
|
||||
document.getElementById('song-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('cancel-song-btn').addEventListener('click', () => {
|
||||
document.getElementById('song-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Group
|
||||
document.getElementById('group-btn').addEventListener('click', () => {
|
||||
const container = document.getElementById('group-variants');
|
||||
container.innerHTML = `
|
||||
<div class="group-variant" style="margin-bottom:8px;">
|
||||
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
|
||||
</div>
|
||||
<div class="group-variant" style="margin-bottom:8px;">
|
||||
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('group-modal').style.display = 'flex';
|
||||
});
|
||||
document.getElementById('add-variant-btn').addEventListener('click', () => {
|
||||
const container = document.getElementById('group-variants');
|
||||
const newDiv = document.createElement('div');
|
||||
newDiv.className = 'group-variant';
|
||||
newDiv.style.marginBottom = '8px';
|
||||
newDiv.innerHTML = `<input type="text" class="variant-input" placeholder="Новый вариант" style="width:80%;">`;
|
||||
container.appendChild(newDiv);
|
||||
});
|
||||
document.getElementById('remove-variant-btn').addEventListener('click', () => {
|
||||
const container = document.getElementById('group-variants');
|
||||
if (container.children.length > 2) {
|
||||
container.removeChild(container.lastChild);
|
||||
} else {
|
||||
alert('Должно быть хотя бы два варианта');
|
||||
}
|
||||
});
|
||||
document.getElementById('insert-group-btn').addEventListener('click', () => {
|
||||
const inputs = document.querySelectorAll('#group-variants .variant-input');
|
||||
let variants = [];
|
||||
inputs.forEach(inp => {
|
||||
let val = inp.value.trim();
|
||||
if (val !== '') variants.push(val);
|
||||
});
|
||||
if (variants.length < 2) {
|
||||
alert('Введите хотя бы два непустых варианта');
|
||||
return;
|
||||
}
|
||||
let groupHtml = '<group>\n';
|
||||
variants.forEach(v => {
|
||||
groupHtml += ` <g>${escapeHtmlForGroup(v)}</g>\n`;
|
||||
});
|
||||
groupHtml += '</group>';
|
||||
insertAtCursor('template', groupHtml);
|
||||
document.getElementById('group-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('cancel-group-btn').addEventListener('click', () => {
|
||||
document.getElementById('group-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Timeout
|
||||
document.getElementById('timeout-btn').addEventListener('click', () => {
|
||||
document.getElementById('timeout-modal').style.display = 'flex';
|
||||
});
|
||||
document.getElementById('insert-timeout-btn').addEventListener('click', () => {
|
||||
const minutes = document.getElementById('timeout-minutes').value;
|
||||
const tag = `<timeout minutes="${minutes}"/>`;
|
||||
insertAtCursor('template', tag);
|
||||
document.getElementById('timeout-modal').style.display = 'none';
|
||||
});
|
||||
document.getElementById('cancel-timeout-btn').addEventListener('click', () => {
|
||||
document.getElementById('timeout-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
function escapeHtmlForGroup(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
// Закрытие модалок по клику вне области
|
||||
window.addEventListener('click', (e) => {
|
||||
const modals = ['random-modal', 'song-modal', 'group-modal', 'test-modal', 'timeout-modal'];
|
||||
modals.forEach(id => {
|
||||
const modal = document.getElementById(id);
|
||||
if (e.target === modal) modal.style.display = 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// --- Остальной код команд (без изменений) ---
|
||||
async function loadCommands() {
|
||||
const res = await fetch('/api/commands');
|
||||
commands = await res.json();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('commands-table');
|
||||
if (!commands.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">Нет команд. Добавьте первую!</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = commands.map(cmd => `
|
||||
<tr>
|
||||
<td>!${escapeHtml(cmd.Trigger)}</td>
|
||||
<td style="max-width: 300px; overflow-x: auto;">${escapeHtml(cmd.Template)}</td>
|
||||
<td>${cmd.CooldownSec}</td>
|
||||
<td>${cmd.Permission}</td>
|
||||
<td>${cmd.Enabled ? '✅ Вкл' : '❌ Выкл'}</td>
|
||||
<td>
|
||||
<button onclick="editCommand(${cmd.ID})">✏️</button>
|
||||
<button onclick="deleteCommand(${cmd.ID})">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
function editCommand(id) {
|
||||
const cmd = commands.find(c => c.ID === id);
|
||||
if (!cmd) return;
|
||||
document.getElementById('command-id').value = cmd.ID;
|
||||
document.getElementById('trigger').value = cmd.Trigger;
|
||||
document.getElementById('template').value = cmd.Template;
|
||||
document.getElementById('enabled').checked = cmd.Enabled;
|
||||
document.getElementById('cooldown').value = cmd.CooldownSec;
|
||||
document.getElementById('permission').value = cmd.Permission;
|
||||
document.querySelector('.card').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function deleteCommand(id) {
|
||||
if (!confirm('Удалить команду?')) return;
|
||||
await fetch(`/api/commands?id=${id}`, { method: 'DELETE' });
|
||||
loadCommands();
|
||||
if (document.getElementById('command-id').value == id) resetForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('command-id').value = 0;
|
||||
document.getElementById('trigger').value = '';
|
||||
document.getElementById('template').value = '';
|
||||
document.getElementById('enabled').checked = true;
|
||||
document.getElementById('cooldown').value = 0;
|
||||
document.getElementById('permission').value = 'everyone';
|
||||
}
|
||||
|
||||
document.getElementById('cancel-edit').addEventListener('click', resetForm);
|
||||
|
||||
document.getElementById('command-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const id = parseInt(document.getElementById('command-id').value);
|
||||
const trigger = document.getElementById('trigger').value.trim();
|
||||
const template = document.getElementById('template').value;
|
||||
const enabled = document.getElementById('enabled').checked;
|
||||
const cooldown = parseInt(document.getElementById('cooldown').value);
|
||||
const permission = document.getElementById('permission').value;
|
||||
|
||||
if (!trigger || !template) {
|
||||
alert('Заполните триггер и шаблон');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = id === 0 ? 'POST' : 'PUT';
|
||||
const url = '/api/commands';
|
||||
const body = JSON.stringify({ ID: id, Trigger: trigger, Template: template, Enabled: enabled, CooldownSec: cooldown, Permission: permission });
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
|
||||
if (res.ok) {
|
||||
loadCommands();
|
||||
resetForm();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
alert('Ошибка: ' + err);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка соединения: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('test-btn').addEventListener('click', async () => {
|
||||
const template = document.getElementById('template').value;
|
||||
if (!template) {
|
||||
alert('Введите шаблон для тестирования');
|
||||
return;
|
||||
}
|
||||
const username = prompt('Введите имя пользователя для подстановки (без @):', 'TestUser');
|
||||
if (!username) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/commands/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template, username })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
alert('Ошибка тестирования: ' + err);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
document.getElementById('test-result').textContent = data.result;
|
||||
const soundsSpan = document.getElementById('test-sounds');
|
||||
if (data.soundFiles && data.soundFiles.length) {
|
||||
soundsSpan.innerHTML = data.soundFiles.join('<br>');
|
||||
} else {
|
||||
soundsSpan.textContent = 'нет';
|
||||
}
|
||||
document.getElementById('test-modal').style.display = 'flex';
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('close-modal').addEventListener('click', () => {
|
||||
document.getElementById('test-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
loadCommands();
|
||||
loadSoundList();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,142 @@
|
||||
{{define "content"}}
|
||||
<h2>Пользователи чата</h2>
|
||||
<div style="overflow-x: auto;">
|
||||
<table id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Пользователь</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Последняя активность</th>
|
||||
<th>Модератор</th>
|
||||
<th>VIP</th>
|
||||
<th>Подписчик</th>
|
||||
<th>Действия</th>
|
||||
<th>Отмечать</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let users = [];
|
||||
let refreshInterval = null;
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/users');
|
||||
users = await res.json();
|
||||
renderTable();
|
||||
} catch(err) {
|
||||
console.error('Failed to load users', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.querySelector('#users-table tbody');
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8">Нет активных пользователей</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = users.map(user => `
|
||||
<tr data-username="${escapeHtml(user.username)}">
|
||||
<td>${escapeHtml(user.username)}</td>
|
||||
<td>${user.message_count}</td>
|
||||
<td>${formatRelativeTime(user.last_active)}</td>
|
||||
<td><input type="checkbox" class="mod-checkbox" ${user.is_mod ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||
<td><input type="checkbox" class="vip-checkbox" ${user.is_vip ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||
<td><input type="checkbox" disabled ${user.is_subscriber ? 'checked' : ''}></td>
|
||||
<td>
|
||||
<button class="warn-btn" data-user="${escapeHtml(user.username)}">Предупредить</button>
|
||||
<button class="timeout10s-btn" data-user="${escapeHtml(user.username)}">10 сек</button>
|
||||
<button class="timeout10m-btn" data-user="${escapeHtml(user.username)}">10 мин</button>
|
||||
<button class="ban-btn" data-user="${escapeHtml(user.username)}">Забанить</button>
|
||||
<button class="unban-btn" data-user="${escapeHtml(user.username)}">Разбанить</button>
|
||||
</td>
|
||||
<td><input type="checkbox" class="mark-checkbox" ${user.is_marked ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.querySelectorAll('.mod-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => toggleMod(cb.dataset.username, cb.checked));
|
||||
});
|
||||
document.querySelectorAll('.vip-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => toggleVip(cb.dataset.username, cb.checked));
|
||||
});
|
||||
document.querySelectorAll('.warn-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => userAction(btn.dataset.user, 'warn'));
|
||||
});
|
||||
document.querySelectorAll('.timeout10s-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10sec'));
|
||||
});
|
||||
document.querySelectorAll('.timeout10m-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10min'));
|
||||
});
|
||||
document.querySelectorAll('.ban-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => userAction(btn.dataset.user, 'ban'));
|
||||
});
|
||||
document.querySelectorAll('.unban-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => userAction(btn.dataset.user, 'unban'));
|
||||
});
|
||||
document.querySelectorAll('.mark-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => toggleMark(cb.dataset.username, cb.checked));
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleMod(username, isMod) {
|
||||
const action = isMod ? 'set_mod' : 'unset_mod';
|
||||
await userAction(username, action);
|
||||
}
|
||||
async function toggleVip(username, isVip) {
|
||||
const action = isVip ? 'set_vip' : 'unset_vip';
|
||||
await userAction(username, action);
|
||||
}
|
||||
async function toggleMark(username, isMarked) {
|
||||
await userAction(username, 'toggle_mark');
|
||||
}
|
||||
async function userAction(username, action) {
|
||||
try {
|
||||
const res = await fetch('/api/users/action', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ username, action, platform: 'twitch' })
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await loadUsers();
|
||||
} catch(err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'никогда';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now - date) / 1000);
|
||||
if (seconds < 60) return 'только что';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} мин. назад`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} ч. назад`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} дн. назад`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months} мес. назад`;
|
||||
const years = Math.floor(months / 12);
|
||||
return `${years} г. назад`;
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
refreshInterval = setInterval(loadUsers, 3000);
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,296 @@
|
||||
{{define "content"}}
|
||||
<h2>События Twitch</h2>
|
||||
<p>Настройте цепочку действий, которые будут выполняться при наступлении события (подписка, рейд, награда и т.д.)</p>
|
||||
|
||||
<div class="card">
|
||||
<label for="event-select">Выберите событие:</label>
|
||||
<select id="event-select">
|
||||
<option value="follow">Подписка на канал (follow)</option>
|
||||
<option value="subscribe">Подписка (subscribe)</option>
|
||||
<option value="gift_sub">Подарочная подписка (gift_sub)</option>
|
||||
<option value="raid">Рейд (raid)</option>
|
||||
<option value="reward_redemption">Награда за баллы (reward_redemption)</option>
|
||||
</select>
|
||||
<button id="load-actions">Загрузить действия</button>
|
||||
</div>
|
||||
|
||||
<div class="card" id="actions-editor" style="display: none;">
|
||||
<h3>Цепочка действий</h3>
|
||||
<div id="actions-list"></div>
|
||||
<button id="add-action">➕ Добавить действие</button>
|
||||
<button id="save-actions">💾 Сохранить</button>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для создания/редактирования действия -->
|
||||
<div id="action-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3 id="modal-title">Действие</h3>
|
||||
<form id="action-form">
|
||||
<input type="hidden" id="action-index" value="-1">
|
||||
<label>Тип действия:</label>
|
||||
<select id="action-type">
|
||||
<option value="send_message">Отправить сообщение</option>
|
||||
<option value="play_sound">Воспроизвести звук</option>
|
||||
<option value="press_hotkey">Нажать горячую клавишу</option>
|
||||
<option value="http_request">HTTP запрос (GET)</option>
|
||||
<option value="run_program">Запустить программу</option>
|
||||
<option value="send_alert">Отправить уведомление (alert)</option>
|
||||
</select>
|
||||
|
||||
<div id="fields-container"></div>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" id="close-action-modal">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPlatform = "twitch";
|
||||
let currentEvent = "";
|
||||
let actions = [];
|
||||
let editingIndex = -1;
|
||||
|
||||
// Списки для выпадающих полей
|
||||
let soundFiles = [];
|
||||
let imageFiles = [];
|
||||
|
||||
const eventSelect = document.getElementById('event-select');
|
||||
const loadBtn = document.getElementById('load-actions');
|
||||
const actionsDiv = document.getElementById('actions-list');
|
||||
const editorDiv = document.getElementById('actions-editor');
|
||||
const addBtn = document.getElementById('add-action');
|
||||
const saveBtn = document.getElementById('save-actions');
|
||||
|
||||
const actionModal = document.getElementById('action-modal');
|
||||
const actionTypeSelect = document.getElementById('action-type');
|
||||
const fieldsContainer = document.getElementById('fields-container');
|
||||
const actionForm = document.getElementById('action-form');
|
||||
const actionIndexInput = document.getElementById('action-index');
|
||||
const closeModalBtn = document.getElementById('close-action-modal');
|
||||
|
||||
// Загрузка списков звуков и изображений
|
||||
async function loadSoundList() {
|
||||
const res = await fetch('/api/sounds/list');
|
||||
if (res.ok) {
|
||||
soundFiles = await res.json();
|
||||
} else {
|
||||
soundFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImageList() {
|
||||
const res = await fetch('/api/images/list');
|
||||
if (res.ok) {
|
||||
imageFiles = await res.json();
|
||||
} else {
|
||||
imageFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Функция отображения полей в зависимости от типа
|
||||
function renderFieldsForType(type, data = {}) {
|
||||
let html = '';
|
||||
switch (type) {
|
||||
case 'send_message':
|
||||
html = `<label>Текст сообщения:</label><textarea id="action-text" rows="3" style="width:100%;">${escapeHtml(data.text || '')}</textarea>
|
||||
<small>Доступны плейсхолдеры: {{.username}}, {{.reward_title}}, {{.tier}}, {{.viewers}} и др.</small>`;
|
||||
break;
|
||||
case 'play_sound':
|
||||
html = `<label>Звуковой файл:</label>
|
||||
<select id="action-sound-file" style="width:100%;">
|
||||
<option value="">— без звука —</option>
|
||||
${soundFiles.map(f => `<option value="data/sounds/${f}" ${data.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||
</select>`;
|
||||
break;
|
||||
case 'press_hotkey':
|
||||
html = `<label>Комбинация клавиш:</label><input type="text" id="action-keys" style="width:100%;" value="${escapeHtml(data.keys || '')}" placeholder="CTRL+ALT+Q">
|
||||
<small>Поддерживаемые модификаторы: CTRL, ALT, SHIFT, WIN. Основные клавиши: A-Z, 0-9, F1-F12, SPACE, ENTER и др.</small>`;
|
||||
break;
|
||||
case 'http_request':
|
||||
html = `<label>URL (GET):</label><input type="text" id="action-url" style="width:100%;" value="${escapeHtml(data.url || '')}">`;
|
||||
break;
|
||||
case 'run_program':
|
||||
html = `<label>Полный путь к исполняемому файлу:</label><input type="text" id="action-executable" style="width:100%;" value="${escapeHtml(data.executable || '')}">
|
||||
<label>Аргументы (через пробел):</label><input type="text" id="action-args" style="width:100%;" value="${escapeHtml(data.args || '')}">`;
|
||||
break;
|
||||
case 'send_alert':
|
||||
html = `<label>Заголовок:</label><input type="text" id="action-title" style="width:100%;" value="${escapeHtml(data.title || '')}">
|
||||
<label>Текст:</label><textarea id="action-alert-text" rows="2" style="width:100%;">${escapeHtml(data.alert_text || '')}</textarea>
|
||||
<label>Изображение:</label>
|
||||
<select id="action-image" style="width:100%;">
|
||||
<option value="">— без изображения —</option>
|
||||
${imageFiles.map(f => `<option value="/static/${f}" ${data.image === `/static/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||
</select>
|
||||
<label>Звук:</label>
|
||||
<select id="action-sound" style="width:100%;">
|
||||
<option value="">— без звука —</option>
|
||||
${soundFiles.map(f => `<option value="/sounds/${f}" ${data.sound_file === `/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||
</select>
|
||||
<label>Длительность (сек):</label><input type="number" id="action-duration" value="${data.duration || 5}" min="1" max="30">
|
||||
<label>Целевой веб-сервис (ID):</label><input type="number" id="action-target-id" value="${data.target_web_service_id || 0}" min="0">
|
||||
<small>0 = все alert-сервисы. Узнать ID можно на странице "Веб-сервисы".</small>`;
|
||||
break;
|
||||
default:
|
||||
html = '<p>Неизвестный тип действия</p>';
|
||||
}
|
||||
fieldsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
// Открыть модалку для создания/редактирования
|
||||
async function openActionModal(index) {
|
||||
editingIndex = index;
|
||||
actionIndexInput.value = index;
|
||||
let data = {};
|
||||
if (index >= 0 && index < actions.length) {
|
||||
data = actions[index];
|
||||
actionTypeSelect.value = data.type;
|
||||
} else {
|
||||
actionTypeSelect.value = 'send_message';
|
||||
}
|
||||
// Загружаем списки, если ещё не загружены
|
||||
if (soundFiles.length === 0) await loadSoundList();
|
||||
if (imageFiles.length === 0) await loadImageList();
|
||||
renderFieldsForType(actionTypeSelect.value, data);
|
||||
actionModal.style.display = 'flex';
|
||||
document.getElementById('modal-title').innerText = index >= 0 ? 'Редактировать действие' : 'Новое действие';
|
||||
}
|
||||
|
||||
// Собрать данные из формы
|
||||
function collectActionData() {
|
||||
const type = actionTypeSelect.value;
|
||||
const base = { type: type };
|
||||
switch (type) {
|
||||
case 'send_message':
|
||||
base.text = document.getElementById('action-text')?.value || '';
|
||||
break;
|
||||
case 'play_sound':
|
||||
base.sound_file = document.getElementById('action-sound-file')?.value || '';
|
||||
break;
|
||||
case 'press_hotkey':
|
||||
base.keys = document.getElementById('action-keys')?.value || '';
|
||||
break;
|
||||
case 'http_request':
|
||||
base.url = document.getElementById('action-url')?.value || '';
|
||||
break;
|
||||
case 'run_program':
|
||||
base.executable = document.getElementById('action-executable')?.value || '';
|
||||
base.args = document.getElementById('action-args')?.value || '';
|
||||
break;
|
||||
case 'send_alert':
|
||||
base.title = document.getElementById('action-title')?.value || '';
|
||||
base.alert_text = document.getElementById('action-alert-text')?.value || '';
|
||||
base.image = document.getElementById('action-image')?.value || '';
|
||||
base.sound_file = document.getElementById('action-sound')?.value || '';
|
||||
base.duration = parseInt(document.getElementById('action-duration')?.value) || 5;
|
||||
base.target_web_service_id = parseInt(document.getElementById('action-target-id')?.value) || 0;
|
||||
break;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Отобразить список действий
|
||||
function renderActions() {
|
||||
actionsDiv.innerHTML = '';
|
||||
if (actions.length === 0) {
|
||||
actionsDiv.innerHTML = '<p>Нет действий. Нажмите "Добавить действие".</p>';
|
||||
return;
|
||||
}
|
||||
actions.forEach((act, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'action-item';
|
||||
let typeName = '';
|
||||
switch (act.type) {
|
||||
case 'send_message': typeName = '📨 Отправить сообщение'; break;
|
||||
case 'play_sound': typeName = '🔊 Воспроизвести звук'; break;
|
||||
case 'press_hotkey': typeName = '⌨️ Нажать клавиши'; break;
|
||||
case 'http_request': typeName = '🌐 HTTP запрос'; break;
|
||||
case 'run_program': typeName = '⚙️ Запустить программу'; break;
|
||||
case 'send_alert': typeName = '🔔 Уведомление (alert)'; break;
|
||||
default: typeName = act.type;
|
||||
}
|
||||
let summary = '';
|
||||
if (act.type === 'send_message') summary = act.text;
|
||||
else if (act.type === 'play_sound') summary = act.sound_file;
|
||||
else if (act.type === 'press_hotkey') summary = act.keys;
|
||||
else if (act.type === 'http_request') summary = act.url;
|
||||
else if (act.type === 'run_program') summary = act.executable + (act.args ? ' ' + act.args : '');
|
||||
else if (act.type === 'send_alert') summary = act.title + ' / ' + act.alert_text;
|
||||
div.innerHTML = `
|
||||
<strong>${typeName}</strong><br>
|
||||
<small>${escapeHtml(summary)}</small><br>
|
||||
<button class="edit-action" data-idx="${idx}">✏️ Редактировать</button>
|
||||
<button class="remove-action" data-idx="${idx}">🗑️ Удалить</button>
|
||||
`;
|
||||
actionsDiv.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('.edit-action').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => openActionModal(parseInt(btn.dataset.idx)));
|
||||
});
|
||||
document.querySelectorAll('.remove-action').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
actions.splice(idx, 1);
|
||||
renderActions();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузка действий с сервера
|
||||
async function loadEventActions() {
|
||||
currentEvent = eventSelect.value;
|
||||
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`);
|
||||
if (res.ok) {
|
||||
actions = await res.json();
|
||||
if (!Array.isArray(actions)) actions = [];
|
||||
renderActions();
|
||||
editorDiv.style.display = 'block';
|
||||
} else {
|
||||
alert('Ошибка загрузки действий');
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранение действий
|
||||
async function saveEventActions() {
|
||||
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(actions)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert('Действия сохранены!');
|
||||
} else {
|
||||
alert('Ошибка сохранения');
|
||||
}
|
||||
}
|
||||
|
||||
// Обработчики UI
|
||||
loadBtn.addEventListener('click', loadEventActions);
|
||||
addBtn.addEventListener('click', () => openActionModal(-1));
|
||||
saveBtn.addEventListener('click', saveEventActions);
|
||||
|
||||
actionForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const newAction = collectActionData();
|
||||
if (editingIndex >= 0 && editingIndex < actions.length) {
|
||||
actions[editingIndex] = newAction;
|
||||
} else {
|
||||
actions.push(newAction);
|
||||
}
|
||||
renderActions();
|
||||
actionModal.style.display = 'none';
|
||||
});
|
||||
|
||||
actionTypeSelect.addEventListener('change', () => {
|
||||
const data = editingIndex >= 0 ? actions[editingIndex] : {};
|
||||
renderFieldsForType(actionTypeSelect.value, data);
|
||||
});
|
||||
|
||||
closeModalBtn.addEventListener('click', () => actionModal.style.display = 'none');
|
||||
window.addEventListener('click', (e) => { if (e.target === actionModal) actionModal.style.display = 'none'; });
|
||||
|
||||
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&'; if (m === '<') return '<'; if (m === '>') return '>'; return m; }); }
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "content"}}
|
||||
<h2>Горячие клавиши по донатам</h2>
|
||||
<p>Правила эмуляции нажатий клавиш при донатах.</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Настройка эмуляции горячих клавиш</h3>
|
||||
<label>
|
||||
<input type="checkbox" id="hotkey-enable"> Включить эмуляцию горячих клавиш
|
||||
</label>
|
||||
<p class="help">При включении бот сможет имитировать нажатия клавиш (например, для управления OBS через донаты).</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadHotkeySetting() {
|
||||
const res = await fetch('/api/settings/hotkey');
|
||||
const data = await res.json();
|
||||
document.getElementById('hotkey-enable').checked = data.enabled;
|
||||
}
|
||||
document.getElementById('hotkey-enable').addEventListener('change', async (e) => {
|
||||
await fetch('/api/settings/hotkey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: e.target.checked })
|
||||
});
|
||||
});
|
||||
loadHotkeySetting();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content"}}
|
||||
<h2>Логи бота в реальном времени</h2>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<button id="save-logs-btn">💾 Сохранить логи в файл</button>
|
||||
</div>
|
||||
<div id="log-container">
|
||||
<div>Подключение к потоку логов...</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('save-logs-btn').addEventListener('click', () => {
|
||||
window.location.href = '/api/logs/download';
|
||||
});
|
||||
const logContainer = document.getElementById('log-container');
|
||||
let eventSource = null;
|
||||
|
||||
function addLogEntry(entry) {
|
||||
const div = document.createElement('div');
|
||||
const time = new Date(entry.time).toLocaleTimeString();
|
||||
let levelClass = '';
|
||||
switch (entry.level) {
|
||||
case 'INFO': levelClass = 'color: #4ec9b0;'; break;
|
||||
case 'WARN': levelClass = 'color: #dcdcaa;'; break;
|
||||
case 'ERROR': levelClass = 'color: #f48771;'; break;
|
||||
case 'FATAL': levelClass = 'color: #ff0000; font-weight: bold;'; break;
|
||||
default: levelClass = '';
|
||||
}
|
||||
div.innerHTML = `<span style="color: #888;">[${time}]</span> <span style="${levelClass}">[${entry.level}]</span> ${escapeHtml(entry.message)}`;
|
||||
logContainer.appendChild(div);
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
function connectLogStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
eventSource = new EventSource('/api/logs/stream');
|
||||
eventSource.onmessage = function(event) {
|
||||
const entry = JSON.parse(event.data);
|
||||
addLogEntry(entry);
|
||||
};
|
||||
eventSource.onerror = function() {
|
||||
logContainer.innerHTML += '<div style="color: red;">⚠️ Потеря соединения, переподключение через 3 секунды...</div>';
|
||||
eventSource.close();
|
||||
setTimeout(connectLogStream, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
connectLogStream();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,174 @@
|
||||
{{define "content"}}
|
||||
<h2>Звуковые уведомления</h2>
|
||||
<div class="card">
|
||||
<h3>События чата и Twitch</h3>
|
||||
<table id="notif-table">
|
||||
<thead>
|
||||
<tr><th>Событие</th><th>Звуковой файл</th><th>Громкость</th><th>Вкл.</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<button id="add-defaults">➕ Добавить события по умолчанию</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Управление звуковыми файлами</h3>
|
||||
<input type="file" id="sound-upload" accept=".mp3,.wav">
|
||||
<button id="upload-btn">Загрузить</button>
|
||||
<ul id="sound-list"></ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Общие настройки звуков</h3>
|
||||
<label>
|
||||
<input type="checkbox" id="duplicate-sounds-checkbox"> Дублировать звуки из команд в локальное воспроизведение
|
||||
</label>
|
||||
<p class="help">При включении, звуки, отправляемые в веб-сервисы оповещений, также будут проигрываться на компьютере стримера.</p>
|
||||
</div>
|
||||
<script>
|
||||
let settings = [];
|
||||
let soundFiles = [];
|
||||
|
||||
async function loadSettings() {
|
||||
const res = await fetch('/api/notifications');
|
||||
settings = await res.json();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function loadSounds() {
|
||||
const res = await fetch('/api/sounds');
|
||||
soundFiles = await res.json();
|
||||
const list = document.getElementById('sound-list');
|
||||
if (soundFiles.length === 0) {
|
||||
list.innerHTML = '<li>Нет загруженных звуков. Загрузите MP3 или WAV.</li>';
|
||||
} else {
|
||||
list.innerHTML = soundFiles.map(f => `<li>${escapeHtml(f)} <button class="delete-sound" data-file="${f}">🗑️</button></li>`).join('');
|
||||
document.querySelectorAll('.delete-sound').forEach(btn => {
|
||||
btn.addEventListener('click', () => deleteSound(btn.dataset.file));
|
||||
});
|
||||
}
|
||||
// Обновляем выпадающие списки в таблице
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.querySelector('#notif-table tbody');
|
||||
if (!settings.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">Нет настроек. Нажмите "Добавить события по умолчанию".</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = settings.map(s => `
|
||||
<tr data-event="${s.event_name}">
|
||||
<td>${escapeHtml(s.event_name)}</td>
|
||||
<td>
|
||||
<select class="sound-select" data-event="${s.event_name}">
|
||||
<option value="">— без звука —</option>
|
||||
${soundFiles.map(f => `<option value="data/sounds/${f}" ${s.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="range" class="volume-slider" min="0" max="100" value="${s.volume}" data-event="${s.event_name}">
|
||||
<span class="vol-value">${s.volume}</span>%
|
||||
</td>
|
||||
<td><input type="checkbox" class="enable-checkbox" ${s.enabled ? 'checked' : ''} data-event="${s.event_name}"></td>
|
||||
<td><button class="test-btn" data-event="${s.event_name}">🔊 Тест</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Привязываем обработчики
|
||||
document.querySelectorAll('.sound-select').forEach(sel => {
|
||||
sel.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'sound_file', sel.value));
|
||||
});
|
||||
document.querySelectorAll('.volume-slider').forEach(slider => {
|
||||
slider.addEventListener('input', (e) => {
|
||||
const val = e.target.value;
|
||||
const span = e.target.parentElement.querySelector('.vol-value');
|
||||
span.innerText = val;
|
||||
updateSetting(e.target.dataset.event, 'volume', parseInt(val));
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.enable-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'enabled', cb.checked));
|
||||
});
|
||||
document.querySelectorAll('.test-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => testSound(btn.dataset.event));
|
||||
});
|
||||
}
|
||||
|
||||
async function updateSetting(eventName, field, value) {
|
||||
const setting = settings.find(s => s.event_name === eventName);
|
||||
if (!setting) return;
|
||||
setting[field] = value;
|
||||
const res = await fetch('/api/notifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(setting)
|
||||
});
|
||||
if (!res.ok) alert('Ошибка сохранения');
|
||||
}
|
||||
|
||||
async function testSound(eventName) {
|
||||
await fetch(`/api/notifications/test?event=${encodeURIComponent(eventName)}`);
|
||||
}
|
||||
|
||||
async function deleteSound(filename) {
|
||||
if (!confirm(`Удалить ${filename}?`)) return;
|
||||
const res = await fetch(`/api/sounds?file=${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
await loadSounds();
|
||||
await loadSettings();
|
||||
} else {
|
||||
alert('Ошибка удаления');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('upload-btn').onclick = async () => {
|
||||
const fileInput = document.getElementById('sound-upload');
|
||||
if (!fileInput.files.length) return;
|
||||
const formData = new FormData();
|
||||
formData.append('sound', fileInput.files[0]);
|
||||
const res = await fetch('/api/sounds/upload', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
await loadSounds();
|
||||
await loadSettings();
|
||||
fileInput.value = '';
|
||||
} else {
|
||||
alert('Ошибка загрузки');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('add-defaults').onclick = async () => {
|
||||
const res = await fetch('/api/notifications/defaults', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
await loadSettings();
|
||||
await loadSounds();
|
||||
} else {
|
||||
alert('Ошибка добавления событий по умолчанию');
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>]/g, function(m) {
|
||||
if (m === '&') return '&';
|
||||
if (m === '<') return '<';
|
||||
if (m === '>') return '>';
|
||||
return m;
|
||||
});
|
||||
}
|
||||
async function loadDuplicateSetting() {
|
||||
const res = await fetch('/api/settings/duplicate_sounds');
|
||||
const data = await res.json();
|
||||
document.getElementById('duplicate-sounds-checkbox').checked = data.enabled;
|
||||
}
|
||||
document.getElementById('duplicate-sounds-checkbox').addEventListener('change', async (e) => {
|
||||
await fetch('/api/settings/duplicate_sounds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: e.target.checked })
|
||||
});
|
||||
});
|
||||
loadDuplicateSetting();
|
||||
loadSettings();
|
||||
loadSounds();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,176 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h3>Twitch</h3>
|
||||
<div>
|
||||
<p>Статус бота: <span id="twitch-status" class="status">Загрузка...</span></p>
|
||||
<p>Имя канала: <span id="twitch-channel"></span></p>
|
||||
</div>
|
||||
<div style="margin: 10px 0;">
|
||||
<button id="auth-user-btn">🔐 Авторизовать стримера</button>
|
||||
<button id="auth-bot-btn">🤖 Авторизовать бота</button>
|
||||
</div>
|
||||
<div id="twitch-tokens-info"></div>
|
||||
<div id="token-expiry-info" style="margin-top: 10px; font-size: 0.9em;">
|
||||
<p>🕒 Срок действия токенов:</p>
|
||||
<p>👤 Стример: <span id="user-expiry">—</span></p>
|
||||
<p>🤖 Бот: <span id="bot-expiry">—</span></p>
|
||||
</div>
|
||||
<!-- Блок для отображения ссылки авторизации БОТА -->
|
||||
<div id="auth-link-container" style="margin-top: 15px; display: none;">
|
||||
<label>Скопируйте ссылку и откройте её в браузере, где залогинен аккаунт БОТА:</label>
|
||||
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||||
<input type="text" id="auth-link" readonly style="flex: 1; padding: 8px; font-family: monospace;">
|
||||
<button id="copy-link-btn">📋 Копировать</button>
|
||||
</div>
|
||||
<p id="auth-wait-message" style="color: #666; margin-top: 10px;">Ожидание подтверждения авторизации...</p>
|
||||
</div>
|
||||
<div id="notification" style="position: fixed; top: 20px; right: 20px; z-index: 1000; background: #333; color: white; padding: 10px 20px; border-radius: 5px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let pollInterval = null;
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const statusRes = await fetch('/api/platforms/twitch/status');
|
||||
const statusData = await statusRes.json();
|
||||
const statusSpan = document.getElementById('twitch-status');
|
||||
if (statusData.connected) {
|
||||
statusSpan.textContent = 'Подключен';
|
||||
statusSpan.className = 'status online';
|
||||
} else {
|
||||
statusSpan.textContent = 'Отключен';
|
||||
statusSpan.className = 'status offline';
|
||||
}
|
||||
|
||||
const authRes = await fetch('/api/platforms/twitch/auth');
|
||||
const authData = await authRes.json();
|
||||
document.getElementById('twitch-channel').textContent = authData.username || 'не указан';
|
||||
const tokensDiv = document.getElementById('twitch-tokens-info');
|
||||
if (authData.hasToken) {
|
||||
tokensDiv.innerHTML = `<p>🔑 Токен бота: ${authData.maskedToken}</p>`;
|
||||
loadTokenExpiry();
|
||||
} else {
|
||||
tokensDiv.innerHTML = '<p style="color: orange;">⚠️ Токен бота не настроен. Авторизуйте бота.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки настроек:', err);
|
||||
document.getElementById('twitch-status').textContent = 'Ошибка';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/platforms/twitch/status');
|
||||
const data = await res.json();
|
||||
const statusSpan = document.getElementById('twitch-status');
|
||||
if (data.connected) {
|
||||
statusSpan.textContent = 'Подключен';
|
||||
statusSpan.className = 'status online';
|
||||
} else {
|
||||
statusSpan.textContent = 'Отключен';
|
||||
statusSpan.className = 'status offline';
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async function authStreamer() {
|
||||
try {
|
||||
const res = await fetch('/api/platforms/twitch/auth/user');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
pollInterval = setInterval(() => checkTokenUpdated('user'), 2000);
|
||||
} else {
|
||||
alert('Не удалось получить ссылку для авторизации');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function authBot() {
|
||||
try {
|
||||
const res = await fetch('/api/platforms/twitch/auth/bot');
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
document.getElementById('auth-link').value = data.url;
|
||||
document.getElementById('auth-link-container').style.display = 'block';
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
pollInterval = setInterval(() => checkTokenUpdated('bot'), 2000);
|
||||
} else {
|
||||
alert('Не удалось получить ссылку для авторизации');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTokenExpiry() {
|
||||
try {
|
||||
const res = await fetch('/api/platforms/twitch/token_expiry');
|
||||
const data = await res.json();
|
||||
const userSpan = document.getElementById('user-expiry');
|
||||
const botSpan = document.getElementById('bot-expiry');
|
||||
if (data.user_expiry_days !== null && data.user_expiry_days !== undefined) {
|
||||
userSpan.textContent = data.user_expiry_days + ' дн.';
|
||||
if (data.user_expiry_days < 1) userSpan.style.color = 'red';
|
||||
else if (data.user_expiry_days < 3) userSpan.style.color = 'orange';
|
||||
else userSpan.style.color = 'green';
|
||||
} else {
|
||||
userSpan.textContent = 'не авторизован';
|
||||
}
|
||||
if (data.bot_expiry_days !== null && data.bot_expiry_days !== undefined) {
|
||||
botSpan.textContent = data.bot_expiry_days + ' дн.';
|
||||
if (data.bot_expiry_days < 1) botSpan.style.color = 'red';
|
||||
else if (data.bot_expiry_days < 3) botSpan.style.color = 'orange';
|
||||
else botSpan.style.color = 'green';
|
||||
} else {
|
||||
botSpan.textContent = 'не авторизован';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки срока токенов:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text, isError = false) {
|
||||
const notif = document.getElementById('notification');
|
||||
notif.textContent = text;
|
||||
notif.style.backgroundColor = isError ? '#d9534f' : '#5cb85c';
|
||||
notif.style.display = 'block';
|
||||
setTimeout(() => { notif.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
async function checkTokenUpdated(type) {
|
||||
try {
|
||||
const res = await fetch(`/api/platforms/twitch/token_check?type=${type}`);
|
||||
const data = await res.json();
|
||||
if (data.updated) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
if (type === 'bot') {
|
||||
document.getElementById('auth-link-container').style.display = 'none';
|
||||
}
|
||||
showMessage(`✅ Токен ${type === 'user' ? 'стримера' : 'бота'} сохранён!`, false);
|
||||
loadSettings();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка проверки токена:', err);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('copy-link-btn').addEventListener('click', () => {
|
||||
const linkInput = document.getElementById('auth-link');
|
||||
linkInput.select();
|
||||
document.execCommand('copy');
|
||||
alert('Ссылка скопирована! Откройте её в другом браузере.');
|
||||
});
|
||||
|
||||
document.getElementById('auth-user-btn').onclick = authStreamer;
|
||||
document.getElementById('auth-bot-btn').onclick = authBot;
|
||||
|
||||
loadSettings();
|
||||
setInterval(updateStatus, 10000);
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,296 @@
|
||||
{{define "content"}}
|
||||
<h2>Веб-сервисы для OBS</h2>
|
||||
<p>Создавайте несколько независимых оверлеев чата и оповещений. Каждый сервис работает на своём порту.</p>
|
||||
|
||||
<div class="card">
|
||||
<button id="add-chat-btn" style="margin-right:10px;">➕ Добавить чат-оверлей</button>
|
||||
<button id="add-alert-btn">🔔 Добавить оверлей оповещений</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Тестирование оповещений</h3>
|
||||
<label>Выберите alert-сервис:</label>
|
||||
<select id="test-alert-service"></select>
|
||||
<label>Тип события:</label>
|
||||
<select id="test-alert-event">
|
||||
<option value="follow">Фолловер</option>
|
||||
<option value="subscribe">Подписка</option>
|
||||
<option value="gift_sub">Подарочная подписка</option>
|
||||
<option value="raid">Рейд</option>
|
||||
<option value="reward_redemption">Награда</option>
|
||||
</select>
|
||||
<button id="test-alert-btn">🔔 Тестировать оповещение</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Список сервисов</h3>
|
||||
<table id="services-table" style="width:100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Тип</th><th>Порт</th><th>Статус</th><th>Действия</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="5">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для создания/редактирования -->
|
||||
<div id="service-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3 id="modal-title">Создание сервиса</h3>
|
||||
<form id="service-form">
|
||||
<input type="hidden" id="service-id" value="0">
|
||||
<label>Порт: <input type="number" id="service-port" required style="width:100%;"></label>
|
||||
<label><input type="checkbox" id="service-enabled" checked> Сервис активен</label>
|
||||
|
||||
<!-- Настройки чата (показываются только для типа chat) -->
|
||||
<div id="chat-config-fields" style="display:none;">
|
||||
<h4>Настройки чата</h4>
|
||||
<label>Фон (цвет): <input type="color" id="chat-bg-color" value="#000000"></label>
|
||||
<label>Цвет текста: <input type="color" id="chat-text-color" value="#ffffff"></label>
|
||||
<label>Размер шрифта (px): <input type="number" id="chat-font-size" min="10" max="50" value="14"></label>
|
||||
<label>Шрифт: <input type="text" id="chat-font-family" value="Arial, sans-serif"></label>
|
||||
<label>Прозрачность (%): <input type="range" id="chat-opacity" min="0" max="100" value="80"> <span id="opacity-val">80</span></label>
|
||||
<label>Таймаут сообщения (сек): <input type="number" id="chat-timeout" min="0" max="60" value="10"></label>
|
||||
<label>Макс. сообщений: <input type="number" id="chat-max-msgs" min="1" max="100" value="20"></label>
|
||||
<label><input type="checkbox" id="chat-show-badges" checked> Показывать значки</label>
|
||||
<label><input type="checkbox" id="chat-show-timestamps"> Показывать время</label>
|
||||
|
||||
<div class="preview-box">
|
||||
<strong>Превью:</strong>
|
||||
<div id="chat-preview" class="chat-preview" style="background:#000; color:#fff; padding:8px; border-radius:4px; margin-top:6px;">
|
||||
<span style="font-weight:bold;">TestUser:</span> Привет, мир!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Для alert-сервиса ничего лишнего не показываем -->
|
||||
<div id="alert-config-fields" style="display:none;">
|
||||
<p class="info">Оповещения настраиваются через HTTP-запросы к эндпоинту <code>/notify</code> этого сервиса. Все уведомления будут отображаться автоматически.</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:15px;">
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" id="close-modal">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentType = 'chat';
|
||||
let services = [];
|
||||
|
||||
async function loadServices() {
|
||||
try {
|
||||
const res = await fetch('/api/webservices');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
services = await res.json();
|
||||
if (!Array.isArray(services)) services = [];
|
||||
const tbody = document.querySelector('#services-table tbody');
|
||||
if (!services.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">Нет сервисов. Создайте первый.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = services.map(s => `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${s.service_type === 'chat' ? 'Чат' : 'Оповещения'}</td>
|
||||
<td>${s.port}</td>
|
||||
<td class="status-${s.id}">${s.running ? '✅ Запущен' : (s.enabled ? '⏹ Остановлен' : '❌ Отключен')}</td>
|
||||
<td>
|
||||
<button onclick="startService(${s.id})">▶ Запустить</button>
|
||||
<button onclick="stopService(${s.id})">⏹ Остановить</button>
|
||||
<button onclick="editService(${s.id})">✏️ Редактировать</button>
|
||||
<button onclick="deleteService(${s.id})">🗑 Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error('loadServices error:', err);
|
||||
document.querySelector('#services-table tbody').innerHTML = '<tr><td colspan="5">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function startService(id) {
|
||||
const res = await fetch(`/api/webservices/start?id=${id}`, { method: 'POST' });
|
||||
if (res.ok) loadServices(); else alert('Ошибка запуска');
|
||||
}
|
||||
async function stopService(id) {
|
||||
const res = await fetch(`/api/webservices/stop?id=${id}`, { method: 'POST' });
|
||||
if (res.ok) loadServices(); else alert('Ошибка остановки');
|
||||
}
|
||||
async function deleteService(id) {
|
||||
if (!confirm('Удалить сервис?')) return;
|
||||
const res = await fetch(`/api/webservices/delete?id=${id}`, { method: 'DELETE' });
|
||||
if (res.ok) loadServices(); else alert('Ошибка удаления');
|
||||
}
|
||||
|
||||
async function editService(id) {
|
||||
const service = services.find(s => s.id === id);
|
||||
if (!service) return;
|
||||
currentType = service.service_type;
|
||||
document.getElementById('service-id').value = service.id;
|
||||
document.getElementById('service-port').value = service.port;
|
||||
document.getElementById('service-enabled').checked = service.enabled;
|
||||
|
||||
if (currentType === 'chat') {
|
||||
const cfg = service.config_json ? JSON.parse(service.config_json) : {};
|
||||
document.getElementById('chat-bg-color').value = cfg.bg_color || '#000000';
|
||||
document.getElementById('chat-text-color').value = cfg.text_color || '#ffffff';
|
||||
document.getElementById('chat-font-size').value = cfg.font_size || 14;
|
||||
document.getElementById('chat-font-family').value = cfg.font_family || 'Arial, sans-serif';
|
||||
document.getElementById('chat-opacity').value = cfg.opacity || 80;
|
||||
document.getElementById('opacity-val').innerText = cfg.opacity || 80;
|
||||
document.getElementById('chat-timeout').value = cfg.message_timeout_sec || 10;
|
||||
document.getElementById('chat-max-msgs').value = cfg.max_messages || 20;
|
||||
document.getElementById('chat-show-badges').checked = cfg.show_badges !== undefined ? cfg.show_badges : true;
|
||||
document.getElementById('chat-show-timestamps').checked = cfg.show_timestamps || false;
|
||||
updateChatPreview();
|
||||
}
|
||||
showModal();
|
||||
}
|
||||
|
||||
function updateChatPreview() {
|
||||
const bg = document.getElementById('chat-bg-color').value;
|
||||
const color = document.getElementById('chat-text-color').value;
|
||||
const fontSize = document.getElementById('chat-font-size').value;
|
||||
const fontFamily = document.getElementById('chat-font-family').value;
|
||||
const opacity = document.getElementById('chat-opacity').value;
|
||||
const showBadges = document.getElementById('chat-show-badges').checked;
|
||||
const showTimestamps = document.getElementById('chat-show-timestamps').checked;
|
||||
const previewDiv = document.getElementById('chat-preview');
|
||||
previewDiv.style.backgroundColor = bg;
|
||||
previewDiv.style.color = color;
|
||||
previewDiv.style.fontSize = fontSize + 'px';
|
||||
previewDiv.style.fontFamily = fontFamily;
|
||||
previewDiv.style.opacity = opacity/100;
|
||||
let badgesHtml = '';
|
||||
if (showBadges) badgesHtml = '<span style="margin-right:4px;">🎭</span><span style="margin-right:4px;">👑</span><span>✔️</span> ';
|
||||
let timeHtml = '';
|
||||
if (showTimestamps) timeHtml = '<span style="color:#aaa; margin-right:8px;">12:34</span> ';
|
||||
previewDiv.innerHTML = timeHtml + badgesHtml + '<span style="font-weight:bold;">TestUser:</span> Привет, мир!';
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
document.getElementById('service-modal').style.display = 'flex';
|
||||
document.getElementById('chat-config-fields').style.display = currentType === 'chat' ? 'block' : 'none';
|
||||
document.getElementById('alert-config-fields').style.display = currentType === 'alert' ? 'block' : 'none';
|
||||
document.getElementById('modal-title').innerText = (document.getElementById('service-id').value == 0 ? 'Создание' : 'Редактирование') + (currentType === 'chat' ? ' чат-оверлея' : ' оверлея оповещений');
|
||||
}
|
||||
|
||||
async function openCreateDialog(type) {
|
||||
currentType = type;
|
||||
document.getElementById('service-id').value = 0;
|
||||
document.getElementById('service-enabled').checked = true;
|
||||
// Определяем свободный порт
|
||||
const res = await fetch('/api/webservices');
|
||||
let servicesList = [];
|
||||
if (res.ok) {
|
||||
servicesList = await res.json();
|
||||
if (!Array.isArray(servicesList)) servicesList = [];
|
||||
}
|
||||
const usedPorts = servicesList.map(s => s.port);
|
||||
let basePort = 9000;
|
||||
while (usedPorts.includes(basePort)) basePort++;
|
||||
document.getElementById('service-port').value = basePort;
|
||||
|
||||
if (type === 'chat') {
|
||||
// Настройки чата по умолчанию
|
||||
document.getElementById('chat-bg-color').value = '#000000';
|
||||
document.getElementById('chat-text-color').value = '#ffffff';
|
||||
document.getElementById('chat-font-size').value = 14;
|
||||
document.getElementById('chat-font-family').value = 'Arial, sans-serif';
|
||||
document.getElementById('chat-opacity').value = 80;
|
||||
document.getElementById('opacity-val').innerText = '80';
|
||||
document.getElementById('chat-timeout').value = 10;
|
||||
document.getElementById('chat-max-msgs').value = 20;
|
||||
document.getElementById('chat-show-badges').checked = true;
|
||||
document.getElementById('chat-show-timestamps').checked = false;
|
||||
updateChatPreview();
|
||||
}
|
||||
showModal();
|
||||
}
|
||||
|
||||
document.getElementById('add-chat-btn').onclick = () => openCreateDialog('chat');
|
||||
document.getElementById('add-alert-btn').onclick = () => openCreateDialog('alert');
|
||||
document.getElementById('close-modal').onclick = () => document.getElementById('service-modal').style.display = 'none';
|
||||
|
||||
// Обработчики для превью чата
|
||||
document.getElementById('chat-bg-color').addEventListener('input', updateChatPreview);
|
||||
document.getElementById('chat-text-color').addEventListener('input', updateChatPreview);
|
||||
document.getElementById('chat-font-size').addEventListener('input', updateChatPreview);
|
||||
document.getElementById('chat-font-family').addEventListener('input', updateChatPreview);
|
||||
document.getElementById('chat-opacity').addEventListener('input', (e) => { document.getElementById('opacity-val').innerText = e.target.value; updateChatPreview(); });
|
||||
document.getElementById('chat-show-badges').addEventListener('change', updateChatPreview);
|
||||
document.getElementById('chat-show-timestamps').addEventListener('change', updateChatPreview);
|
||||
|
||||
document.getElementById('service-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const id = parseInt(document.getElementById('service-id').value);
|
||||
const port = parseInt(document.getElementById('service-port').value);
|
||||
const enabled = document.getElementById('service-enabled').checked;
|
||||
let config = {};
|
||||
if (currentType === 'chat') {
|
||||
config = {
|
||||
bg_color: document.getElementById('chat-bg-color').value,
|
||||
text_color: document.getElementById('chat-text-color').value,
|
||||
font_size: parseInt(document.getElementById('chat-font-size').value),
|
||||
font_family: document.getElementById('chat-font-family').value,
|
||||
opacity: parseInt(document.getElementById('chat-opacity').value),
|
||||
message_timeout_sec: parseInt(document.getElementById('chat-timeout').value),
|
||||
max_messages: parseInt(document.getElementById('chat-max-msgs').value),
|
||||
show_badges: document.getElementById('chat-show-badges').checked,
|
||||
show_timestamps: document.getElementById('chat-show-timestamps').checked
|
||||
};
|
||||
} else {
|
||||
// Для alert-сервиса отправляем пустой конфиг
|
||||
config = {};
|
||||
}
|
||||
const url = id === 0 ? '/api/webservices/create' : `/api/webservices/update?id=${id}`;
|
||||
const method = id === 0 ? 'POST' : 'PUT';
|
||||
const body = JSON.stringify({ type: currentType, port, config, enabled });
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
|
||||
if (res.ok) {
|
||||
document.getElementById('service-modal').style.display = 'none';
|
||||
loadServices();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
alert('Ошибка: ' + err);
|
||||
}
|
||||
};
|
||||
async function loadAlertServices() {
|
||||
const res = await fetch('/api/webservices/alert/list');
|
||||
const services = await res.json();
|
||||
const select = document.getElementById('test-alert-service');
|
||||
select.innerHTML = '<option value="">-- выберите сервис --</option>';
|
||||
services.forEach(s => {
|
||||
const option = document.createElement('option');
|
||||
option.value = s.id;
|
||||
option.textContent = s.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('test-alert-btn').onclick = async () => {
|
||||
const serviceId = document.getElementById('test-alert-service').value;
|
||||
if (!serviceId) {
|
||||
alert('Выберите alert-сервис');
|
||||
return;
|
||||
}
|
||||
const eventType = document.getElementById('test-alert-event').value;
|
||||
const res = await fetch('/api/webservices/test/alert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serviceId: parseInt(serviceId), eventType: eventType })
|
||||
});
|
||||
if (res.ok) alert('Тестовое оповещение отправлено');
|
||||
else alert('Ошибка');
|
||||
};
|
||||
|
||||
// Вызываем при загрузке страницы
|
||||
loadAlertServices();
|
||||
loadServices();
|
||||
setInterval(loadServices, 5000);
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user