fix(alerts): сохранить черновик Telegram и подтвердить операции

This commit is contained in:
Denozordec committed 2026-10-03 18:00:01 +07:00
1 parent a5c8e614bd
commit 875819c577
7 files changed
+170 -216

No files matched your search

+11 -214
View File
@@ -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>