import { BellRing, MailPlus, Pencil, RefreshCcw, Save, Send, Trash2, X } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' import { adminApi, ApiError } from '@/lib/api' import type { NotificationDeliveryRecord, SubscriptionRecord } from '@/lib/types' const CHANNEL_OPTIONS = [ { value: 'email', label: 'Email' }, { value: 'webhook', label: 'Webhook' }, { value: 'discord', label: 'Discord Webhook' }, { value: 'telegram', label: 'Telegram Bot API' }, { value: 'ntfy', label: 'ntfy' }, { value: 'web_push', label: 'Web Push / Browser Push' }, ] as const const DEFAULT_FILTERS = { event_types: ['post.published', 'digest.weekly', 'digest.monthly'], } function prettyJson(value: unknown) { if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0)) { return '' } return JSON.stringify(value, null, 2) } function emptyForm() { return { channelType: 'email', target: '', displayName: '', status: 'active', notes: '', filtersText: prettyJson(DEFAULT_FILTERS), metadataText: '', } } function parseOptionalJson(label: string, raw: string) { const trimmed = raw.trim() if (!trimmed) { return null } try { return JSON.parse(trimmed) as Record } catch { throw new Error(`${label} 不是合法 JSON`) } } function normalizePreview(value: unknown) { const text = prettyJson(value) return text || '—' } export function SubscriptionsPage() { const [subscriptions, setSubscriptions] = useState([]) const [deliveries, setDeliveries] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [submitting, setSubmitting] = useState(false) const [digesting, setDigesting] = useState<'weekly' | 'monthly' | null>(null) const [actioningId, setActioningId] = useState(null) const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(emptyForm()) const loadData = useCallback(async (showToast = false) => { try { if (showToast) { setRefreshing(true) } const [nextSubscriptions, nextDeliveries] = await Promise.all([ adminApi.listSubscriptions(), adminApi.listSubscriptionDeliveries(), ]) startTransition(() => { setSubscriptions(nextSubscriptions) setDeliveries(nextDeliveries) }) if (showToast) { toast.success('订阅中心已刷新。') } } catch (error) { if (error instanceof ApiError && error.status === 401) { return } toast.error(error instanceof ApiError ? error.message : '无法加载订阅中心。') } finally { setLoading(false) setRefreshing(false) } }, []) useEffect(() => { void loadData(false) }, [loadData]) const activeCount = useMemo( () => subscriptions.filter((item) => item.status === 'active').length, [subscriptions], ) const queuedOrRetryCount = useMemo( () => deliveries.filter((item) => item.status === 'queued' || item.status === 'retry_pending').length, [deliveries], ) const resetForm = useCallback(() => { setEditingId(null) setForm(emptyForm()) }, []) const submitForm = useCallback(async () => { try { setSubmitting(true) const payload = { channelType: form.channelType, target: form.target, displayName: form.displayName || null, status: form.status, notes: form.notes || null, filters: parseOptionalJson('filters', form.filtersText), metadata: parseOptionalJson('metadata', form.metadataText), } if (editingId) { await adminApi.updateSubscription(editingId, payload) toast.success('订阅目标已更新。') } else { await adminApi.createSubscription(payload) toast.success('订阅目标已创建。') } resetForm() await loadData(false) } catch (error) { toast.error(error instanceof Error ? error.message : '保存订阅失败。') } finally { setSubmitting(false) } }, [editingId, form, loadData, resetForm]) if (loading) { return (
) } return (
订阅与推送

订阅中心 / 异步投递 / 汇总简报

这里统一管理邮件订阅、Webhook / Discord / Telegram / ntfy / Web Push 推送目标;当前投递走异步队列,并支持 retry pending 状态追踪。

{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'} 当前共有 {subscriptions.length} 个订阅目标,其中 {activeCount} 个处于启用状态,当前待处理/重试中的投递 {queuedOrRetryCount} 条。
setForm((current) => ({ ...current, target: event.target.value }))} placeholder={ form.channelType === 'email' ? 'name@example.com' : form.channelType === 'ntfy' ? 'topic-name 或 https://ntfy.example.com/topic' : form.channelType === 'web_push' ? 'https://push-service/...' : 'https://...' } />
setForm((current) => ({ ...current, displayName: event.target.value })) } placeholder="例如 站长邮箱 / Discord 运维群" />
setForm((current) => ({ ...current, notes: event.target.value }))} placeholder="用途、机器人说明、负责人等" />