fix(alerts): сохранить черновик Telegram и подтвердить операции
This commit is contained in:
1 parent
a5c8e614bd
commit
875819c577
7 files changed
+170
-216
No files matched your search
+11
-214
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {useAlertsTargets} from "@/hooks/use-alerts-targets"
|
||||
import {AlertsTelegramForm} from "@/components/alerts-telegram-form"
|
||||
import {AlertsTargetsStatus} from "@/components/alerts-targets-status"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
|
||||
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
|
||||
TimerIcon, WifiOffIcon, TrendingDownIcon,
|
||||
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
|
||||
SendIcon, CheckIcon,
|
||||
Trash2Icon,
|
||||
PencilIcon,
|
||||
ChevronsUpDownIcon,
|
||||
@@ -815,142 +816,6 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
||||
|
||||
// ─── telegram config card ─────────────────────────────────────────────────────
|
||||
|
||||
function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: {
|
||||
cfg: TelegramConfig
|
||||
onChange: (c: TelegramConfig) => void
|
||||
/** В режиме live: токен в БД есть, в поле не подставляем */
|
||||
tokenConfigured?: boolean
|
||||
liveSaveBusy?: boolean
|
||||
onSaveTelegram?: () => void | Promise<void>
|
||||
onTestTelegram?: () => void | Promise<void>
|
||||
}) {
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
if (onTestTelegram) {
|
||||
await onTestTelegram()
|
||||
setTestResult("ok")
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
setTestResult(cfg.connected ? "ok" : "fail")
|
||||
}
|
||||
} catch {
|
||||
setTestResult("fail")
|
||||
} finally {
|
||||
setTesting(false)
|
||||
setTimeout(() => setTestResult(null), 4000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title={<span className="flex items-center gap-2"><span className="text-base">✈️</span> Telegram</span>}
|
||||
headerRight={
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
||||
cfg.connected
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
||||
{cfg.connected ? "Подключён" : "Не настроен"}
|
||||
</span>
|
||||
}
|
||||
contentClassName="px-4 pb-4 flex flex-col gap-3"
|
||||
>
|
||||
|
||||
{/* bot token */}
|
||||
<div>
|
||||
<FieldLabel>Bot Token</FieldLabel>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={cfg.token}
|
||||
onChange={e => onChange({ ...cfg, token: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
name="telegram-bot-token"
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(cfg.token)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
<CopyIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* default chat id */}
|
||||
<div>
|
||||
<FieldLabel>Chat ID по умолчанию</FieldLabel>
|
||||
<Input
|
||||
value={cfg.chatId}
|
||||
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder="-100xxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Для групп используйте отрицательный ID. Каждое правило может переопределить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>ID темы (message_thread_id)</FieldLabel>
|
||||
<Input
|
||||
value={cfg.messageThreadId}
|
||||
onChange={e => onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })}
|
||||
className="text-xs font-mono h-8"
|
||||
inputMode="numeric"
|
||||
placeholder="Напр. 12345 — только для супергрупп с темами"
|
||||
/>
|
||||
<FieldHint>
|
||||
Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы.
|
||||
</FieldHint>
|
||||
</div>
|
||||
|
||||
{onSaveTelegram && (
|
||||
<Button
|
||||
size="sm" className="w-full h-8 text-xs"
|
||||
disabled={!!liveSaveBusy}
|
||||
onClick={() => { void onSaveTelegram() }}
|
||||
>
|
||||
{liveSaveBusy ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||||
{liveSaveBusy ? "Сохранение…" : "Сохранить в БД"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* test button */}
|
||||
<Button
|
||||
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
|
||||
onClick={() => { void handleTest() }} disabled={testing}>
|
||||
{testing ? (
|
||||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||||
) : testResult === "ok" ? (
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
) : testResult === "fail" ? (
|
||||
<XIcon className="size-3.5 text-destructive" />
|
||||
) : (
|
||||
<SendIcon className="size-3.5" />
|
||||
)}
|
||||
{testing ? "Отправка…"
|
||||
: testResult === "ok" ? "Сообщение отправлено"
|
||||
: testResult === "fail" ? "Ошибка отправки"
|
||||
: "Отправить тестовое сообщение"}
|
||||
</Button>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── history card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||||
@@ -2391,7 +2256,8 @@ function AlertsContent() {
|
||||
const [loadBusy, setLoadBusy] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [rulesSaveBusy, setRulesSaveBusy] = useState(false)
|
||||
const [telegramSaveBusy, setTelegramSaveBusy] = useState(false)
|
||||
const telegramLock=useRef(false)
|
||||
const [telegramBusy,setTelegramBusy]=useState(false)
|
||||
/** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */
|
||||
const [extrasExpanded, setExtrasExpanded] = useState(false)
|
||||
const [hintsExpanded, setHintsExpanded] = useState(false)
|
||||
@@ -2423,7 +2289,7 @@ function AlertsContent() {
|
||||
}, [isLive, meta])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if(!pageActive.current)return
|
||||
if(!pageActive.current||telegramLock.current)return
|
||||
if (!isLive) {
|
||||
setRules(INIT_RULES)
|
||||
setGroups(INIT_GROUPS)
|
||||
@@ -2494,71 +2360,6 @@ function AlertsContent() {
|
||||
}
|
||||
}, [apiFetch, isLive, load])
|
||||
|
||||
const saveTelegram = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
const threadRaw = tg.messageThreadId.trim()
|
||||
let messageThreadId: number | null = null
|
||||
if (threadRaw) {
|
||||
const n = Number.parseInt(threadRaw, 10)
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым")
|
||||
return
|
||||
}
|
||||
messageThreadId = n
|
||||
}
|
||||
setTelegramSaveBusy(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await apiFetch<{
|
||||
telegram: {
|
||||
chatId: string
|
||||
tokenConfigured: boolean
|
||||
connected: boolean
|
||||
messageThreadId: number | null
|
||||
}
|
||||
}>(
|
||||
"/api/alerts/telegram",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
chatId: tg.chatId,
|
||||
messageThreadId,
|
||||
...(tg.token.trim() ? { token: tg.token.trim() } : {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
setTokenConfigured(res.telegram.tokenConfigured)
|
||||
setTg({
|
||||
token: "",
|
||||
chatId: res.telegram.chatId ?? "",
|
||||
messageThreadId:
|
||||
res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "",
|
||||
connected: res.telegram.connected,
|
||||
})
|
||||
} catch (e) {
|
||||
setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram")
|
||||
} finally {
|
||||
setTelegramSaveBusy(false)
|
||||
}
|
||||
}, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId])
|
||||
|
||||
const testTelegram = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
const threadRaw = tg.messageThreadId.trim()
|
||||
const messageThreadId =
|
||||
threadRaw ? Number.parseInt(threadRaw, 10) : undefined
|
||||
await apiFetch("/api/alerts/telegram/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
token: tg.token.trim() || undefined,
|
||||
chatId: tg.chatId.trim() || undefined,
|
||||
...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
|
||||
? { messageThreadId }
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
}, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId])
|
||||
|
||||
const testRuleTelegramFromForm = useCallback(
|
||||
async (p: RuleTelegramTestPayload) => {
|
||||
if (!isLive) return
|
||||
@@ -2758,7 +2559,7 @@ function AlertsContent() {
|
||||
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
|
||||
<Button variant="outline" size="sm" disabled={loadBusy || telegramBusy || (isLive && backendStatus === false)}
|
||||
onClick={() => { void load(); catalogs.refresh() }}>
|
||||
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
|
||||
Обновить
|
||||
@@ -2869,7 +2670,7 @@ function AlertsContent() {
|
||||
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
||||
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
|
||||
{tg.connected ? "Telegram: Параметры заполнены" : "Telegram: Не настроен"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -3091,14 +2892,10 @@ function AlertsContent() {
|
||||
|
||||
{/* ── right sidebar ── */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<TelegramCard
|
||||
cfg={tg}
|
||||
onChange={setTg}
|
||||
tokenConfigured={isLive ? tokenConfigured : false}
|
||||
liveSaveBusy={telegramSaveBusy}
|
||||
onSaveTelegram={isLive ? saveTelegram : undefined}
|
||||
onTestTelegram={isLive ? testTelegram : undefined}
|
||||
/>
|
||||
<AlertsTelegramForm initial={tg} tokenConfigured={tokenConfigured} base={backendUrl} live={isLive} available={backendStatus===true}
|
||||
onBegin={()=>{if(!pageActive.current||telegramLock.current)return false;telegramLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setTelegramBusy(true);return true}}
|
||||
onEnd={()=>{telegramLock.current=false;setTelegramBusy(false)}}
|
||||
onSaved={(value,configured)=>{setTg(value);setTokenConfigured(configured)}}/>
|
||||
<HistoryCard entries={history} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user