Добавлена форма настроек Telegram SOCKS5 в админке
Этот коммит содержится в:
+227
-10
@@ -3,9 +3,13 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bot,
|
Bot,
|
||||||
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
|
KeyRound,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
PlugZap,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Save,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Users,
|
Users,
|
||||||
@@ -16,14 +20,28 @@ import './styles.css';
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || 'https://api.stackworks.ru';
|
const API_URL = import.meta.env.VITE_API_URL || 'https://api.stackworks.ru';
|
||||||
const REFRESH_MS = 15000;
|
const REFRESH_MS = 15000;
|
||||||
|
|
||||||
|
const emptyTelegramSettings = {
|
||||||
|
botToken: '',
|
||||||
|
hasBotToken: false,
|
||||||
|
proxy: {
|
||||||
|
enabled: false,
|
||||||
|
type: 'socks5',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 9050,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
hasPassword: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
const date = new Date(value.replace(' ', 'T') + 'Z');
|
const source = value instanceof Date ? value : new Date(String(value).replace(' ', 'T') + 'Z');
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(source.getTime())) return String(value);
|
||||||
return new Intl.DateTimeFormat('ru-RU', {
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'short',
|
||||||
timeStyle: 'short'
|
timeStyle: 'short'
|
||||||
}).format(date);
|
}).format(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value }) {
|
function StatCard({ icon: Icon, label, value }) {
|
||||||
@@ -42,9 +60,14 @@ function App() {
|
|||||||
const [health, setHealth] = useState(null);
|
const [health, setHealth] = useState(null);
|
||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [telegramSettings, setTelegramSettings] = useState(emptyTelegramSettings);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [savingTelegram, setSavingTelegram] = useState(false);
|
||||||
|
const [testingProxy, setTestingProxy] = useState(false);
|
||||||
const [lastUpdate, setLastUpdate] = useState(null);
|
const [lastUpdate, setLastUpdate] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [telegramNotice, setTelegramNotice] = useState('');
|
||||||
|
const [proxyTestResult, setProxyTestResult] = useState(null);
|
||||||
|
|
||||||
const healthOk = health?.ok === true;
|
const healthOk = health?.ok === true;
|
||||||
|
|
||||||
@@ -56,26 +79,56 @@ function App() {
|
|||||||
{ icon: Bot, label: 'Сообщения', value: stats?.messages }
|
{ icon: Bot, label: 'Сообщения', value: stats?.messages }
|
||||||
]), [stats]);
|
]), [stats]);
|
||||||
|
|
||||||
|
function patchTelegramSettings(path, value) {
|
||||||
|
setTelegramSettings((current) => {
|
||||||
|
if (path.startsWith('proxy.')) {
|
||||||
|
const key = path.replace('proxy.', '');
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
proxy: {
|
||||||
|
...current.proxy,
|
||||||
|
[key]: value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[path]: value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTelegramSettings() {
|
||||||
|
const response = await fetch(API_URL + '/api/admin/telegram/settings');
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || 'Telegram settings load failed');
|
||||||
|
setTelegramSettings(data.settings || emptyTelegramSettings);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDashboard({ silent = false } = {}) {
|
async function loadDashboard({ silent = false } = {}) {
|
||||||
if (!silent) setLoading(true);
|
if (!silent) setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [healthResponse, statsResponse, messagesResponse] = await Promise.all([
|
const [healthResponse, statsResponse, messagesResponse, telegramResponse] = await Promise.all([
|
||||||
fetch(API_URL + '/health'),
|
fetch(API_URL + '/health'),
|
||||||
fetch(API_URL + '/api/admin/stats'),
|
fetch(API_URL + '/api/admin/stats'),
|
||||||
fetch(API_URL + '/api/admin/messages?limit=25')
|
fetch(API_URL + '/api/admin/messages?limit=25'),
|
||||||
|
fetch(API_URL + '/api/admin/telegram/settings')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [healthData, statsData, messagesData] = await Promise.all([
|
const [healthData, statsData, messagesData, telegramData] = await Promise.all([
|
||||||
healthResponse.json(),
|
healthResponse.json(),
|
||||||
statsResponse.json(),
|
statsResponse.json(),
|
||||||
messagesResponse.json()
|
messagesResponse.json(),
|
||||||
|
telegramResponse.json()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setHealth(healthData);
|
setHealth(healthData);
|
||||||
setStats(statsData.stats || null);
|
setStats(statsData.stats || null);
|
||||||
setMessages(Array.isArray(messagesData.messages) ? messagesData.messages : []);
|
setMessages(Array.isArray(messagesData.messages) ? messagesData.messages : []);
|
||||||
|
if (telegramData.ok) setTelegramSettings(telegramData.settings || emptyTelegramSettings);
|
||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setHealth({ ok: false, error: requestError.message });
|
setHealth({ ok: false, error: requestError.message });
|
||||||
@@ -85,6 +138,58 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveTelegramSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSavingTelegram(true);
|
||||||
|
setTelegramNotice('');
|
||||||
|
setProxyTestResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URL + '/api/admin/telegram/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
botToken: telegramSettings.botToken,
|
||||||
|
proxy: {
|
||||||
|
enabled: Boolean(telegramSettings.proxy.enabled),
|
||||||
|
type: telegramSettings.proxy.type || 'socks5',
|
||||||
|
host: telegramSettings.proxy.host || '',
|
||||||
|
port: Number(telegramSettings.proxy.port || 9050),
|
||||||
|
username: telegramSettings.proxy.username || '',
|
||||||
|
password: telegramSettings.proxy.password || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.ok) throw new Error(data.error || 'Save failed');
|
||||||
|
setTelegramSettings(data.settings || emptyTelegramSettings);
|
||||||
|
setTelegramNotice('Настройки Telegram/SOCKS5 сохранены.');
|
||||||
|
} catch (saveError) {
|
||||||
|
setTelegramNotice(`Ошибка сохранения: ${saveError.message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingTelegram(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProxy() {
|
||||||
|
setTestingProxy(true);
|
||||||
|
setTelegramNotice('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URL + '/api/admin/telegram/test-proxy', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setProxyTestResult(data);
|
||||||
|
if (data.settings) setTelegramSettings(data.settings);
|
||||||
|
} catch (testError) {
|
||||||
|
setProxyTestResult({ ok: false, status: 'error', message: testError.message });
|
||||||
|
} finally {
|
||||||
|
setTestingProxy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
const timer = setInterval(() => loadDashboard({ silent: true }), REFRESH_MS);
|
const timer = setInterval(() => loadDashboard({ silent: true }), REFRESH_MS);
|
||||||
@@ -97,13 +202,16 @@ function App() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">WSChat · operator console</p>
|
<p className="eyebrow">WSChat · operator console</p>
|
||||||
<h1>Панель управления</h1>
|
<h1>Панель управления</h1>
|
||||||
<p className="lead">Сообщения с сайта, статистика, Telegram-операторы и будущие настройки виджета в одном месте.</p>
|
<p className="lead">Сообщения с сайта, статистика, Telegram-операторы и настройки прокси в одном месте.</p>
|
||||||
<div className="status-row">
|
<div className="status-row">
|
||||||
<span className={healthOk ? 'status-pill ok' : 'status-pill bad'}>
|
<span className={healthOk ? 'status-pill ok' : 'status-pill bad'}>
|
||||||
<Server size={14} /> API: {healthOk ? 'работает' : 'ошибка'}
|
<Server size={14} /> API: {healthOk ? 'работает' : 'ошибка'}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={telegramSettings.proxy.enabled ? 'status-pill ok' : 'status-pill muted'}>
|
||||||
|
<Wifi size={14} /> SOCKS5: {telegramSettings.proxy.enabled ? 'включен' : 'выключен'}
|
||||||
|
</span>
|
||||||
<span className="status-pill muted">
|
<span className="status-pill muted">
|
||||||
<Clock size={14} /> {lastUpdate ? `обновлено ${formatDate(lastUpdate.toISOString())}` : 'ожидает данных'}
|
<Clock size={14} /> {lastUpdate ? `обновлено ${formatDate(lastUpdate)}` : 'ожидает данных'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,6 +227,115 @@ function App() {
|
|||||||
{statCards.map((card) => <StatCard key={card.label} {...card} />)}
|
{statCards.map((card) => <StatCard key={card.label} {...card} />)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-grid">
|
||||||
|
<article className="panel settings-panel">
|
||||||
|
<div className="panel-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Telegram bridge</p>
|
||||||
|
<h2>Настройки бота и SOCKS5</h2>
|
||||||
|
</div>
|
||||||
|
<span className={telegramSettings.hasBotToken ? 'status-pill ok' : 'status-pill bad'}>
|
||||||
|
<KeyRound size={14} /> token {telegramSettings.hasBotToken ? 'есть' : 'не задан'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="settings-form" onSubmit={saveTelegramSettings}>
|
||||||
|
<label className="field wide-field">
|
||||||
|
<span>Telegram Bot Token</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={telegramSettings.botToken || ''}
|
||||||
|
placeholder={telegramSettings.hasBotToken ? '********' : '123456:ABC...'}
|
||||||
|
onChange={(event) => patchTelegramSettings('botToken', event.target.value)}
|
||||||
|
/>
|
||||||
|
<small>Если токен уже сохранён, можно оставить маску ******** — старое значение не перезапишется.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="switch-field wide-field">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(telegramSettings.proxy.enabled)}
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.enabled', event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Использовать SOCKS5 для Telegram</strong>
|
||||||
|
<small>Нужно для тестов бота через прокси. Без этого Telegram подключается напрямую.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Тип</span>
|
||||||
|
<select
|
||||||
|
value={telegramSettings.proxy.type || 'socks5'}
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.type', event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="socks5">socks5</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Host</span>
|
||||||
|
<input
|
||||||
|
value={telegramSettings.proxy.host || ''}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.host', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Port</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={telegramSettings.proxy.port || ''}
|
||||||
|
placeholder="9050"
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.port', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Login</span>
|
||||||
|
<input
|
||||||
|
value={telegramSettings.proxy.username || ''}
|
||||||
|
placeholder="необязательно"
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.username', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field wide-field">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={telegramSettings.proxy.password || ''}
|
||||||
|
placeholder={telegramSettings.proxy.hasPassword ? '********' : 'необязательно'}
|
||||||
|
onChange={(event) => patchTelegramSettings('proxy.password', event.target.value)}
|
||||||
|
/>
|
||||||
|
<small>Маску ******** можно оставить, тогда сохранённый пароль не изменится.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-actions wide-field">
|
||||||
|
<button className="button" type="submit" disabled={savingTelegram}>
|
||||||
|
<Save size={18} className={savingTelegram ? 'spin' : ''} />
|
||||||
|
{savingTelegram ? 'Сохраняю...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
<button className="button secondary-button" type="button" onClick={testProxy} disabled={testingProxy}>
|
||||||
|
<PlugZap size={18} className={testingProxy ? 'spin' : ''} />
|
||||||
|
{testingProxy ? 'Проверяю...' : 'Проверить прокси'}
|
||||||
|
</button>
|
||||||
|
<button className="ghost-button" type="button" onClick={loadTelegramSettings}>Сбросить форму</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{telegramNotice ? <div className="notice"><CheckCircle size={16} /> {telegramNotice}</div> : null}
|
||||||
|
{proxyTestResult ? (
|
||||||
|
<div className={proxyTestResult.ok ? 'notice' : 'alert compact-alert'}>
|
||||||
|
<PlugZap size={16} /> {proxyTestResult.message || proxyTestResult.status}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="content-grid">
|
<section className="content-grid">
|
||||||
<article className="panel messages-panel">
|
<article className="panel messages-panel">
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
@@ -157,7 +374,7 @@ function App() {
|
|||||||
<article className="panel compact-panel">
|
<article className="panel compact-panel">
|
||||||
<Wifi />
|
<Wifi />
|
||||||
<h2>Telegram / SOCKS5</h2>
|
<h2>Telegram / SOCKS5</h2>
|
||||||
<p>Следующий крупный блок: привязка операторов Telegram, прокси и маршрутизация ответов обратно в виджет.</p>
|
<p>Прокси уже сохраняется в SQLite. Следующий шаг — подключить Telegraf bridge и отправлять сообщения сайта операторам.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="panel compact-panel">
|
<article className="panel compact-panel">
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user