Files
termi-blog/admin/src/pages/subscriptions-page.tsx
limitcool 497a9d713d
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
feat: ship public ops features and cache docker builds
2026-04-01 13:22:19 +08:00

509 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>).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<string, unknown>
} catch {
throw new Error(`${label} 不是合法 JSON`)
}
}
function normalizePreview(value: unknown) {
const text = prettyJson(value)
return text || '—'
}
export function SubscriptionsPage() {
const [subscriptions, setSubscriptions] = useState<SubscriptionRecord[]>([])
const [deliveries, setDeliveries] = useState<NotificationDeliveryRecord[]>([])
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<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(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 (
<div className="space-y-6">
<Skeleton className="h-40 rounded-3xl" />
<Skeleton className="h-[640px] rounded-3xl" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<Badge variant="secondary"></Badge>
<div>
<h2 className="text-3xl font-semibold tracking-tight"> / / </h2>
<p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground">
Webhook / Discord / Telegram / ntfy / Web Push retry pending
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void loadData(true)} disabled={refreshing}>
<RefreshCcw className="h-4 w-4" />
{refreshing ? '刷新中...' : '刷新'}
</Button>
<Button
variant="secondary"
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('weekly')
const result = await adminApi.sendSubscriptionDigest('weekly')
toast.success(`周报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送周报失败。')
} finally {
setDigesting(null)
}
}}
>
<Send className="h-4 w-4" />
{digesting === 'weekly' ? '入队中...' : '发送周报'}
</Button>
<Button
disabled={digesting !== null}
onClick={async () => {
try {
setDigesting('monthly')
const result = await adminApi.sendSubscriptionDigest('monthly')
toast.success(`月报已入队queued ${result.queued}skipped ${result.skipped}`)
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '发送月报失败。')
} finally {
setDigesting(null)
}
}}
>
<BellRing className="h-4 w-4" />
{digesting === 'monthly' ? '入队中...' : '发送月报'}
</Button>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.98fr_1.02fr]">
<Card>
<CardHeader>
<CardTitle>{editingId ? `编辑订阅 #${editingId}` : '新增订阅目标'}</CardTitle>
<CardDescription>
{subscriptions.length} {activeCount} / {queuedOrRetryCount}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select
value={form.channelType}
onChange={(event) => setForm((current) => ({ ...current, channelType: event.target.value }))}
>
{CHANNEL_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.target}
onChange={(event) => 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://...'
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.displayName}
onChange={(event) =>
setForm((current) => ({ ...current, displayName: event.target.value }))
}
placeholder="例如 站长邮箱 / Discord 运维群"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select
value={form.status}
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
>
<option value="active">active</option>
<option value="paused">paused</option>
<option value="pending">pending</option>
<option value="unsubscribed">unsubscribed</option>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.notes}
onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))}
placeholder="用途、机器人说明、负责人等"
/>
</div>
</div>
<div className="space-y-2">
<Label>filtersJSON</Label>
<Textarea
value={form.filtersText}
onChange={(event) => setForm((current) => ({ ...current, filtersText: event.target.value }))}
placeholder='{"event_types":["post.published","digest.weekly"]}'
className="min-h-32 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label>metadataJSON</Label>
<Textarea
value={form.metadataText}
onChange={(event) => setForm((current) => ({ ...current, metadataText: event.target.value }))}
placeholder='{"owner":"ops","source":"manual"}'
className="min-h-28 font-mono text-xs"
/>
</div>
<div className="flex flex-wrap gap-3">
<Button className="flex-1" disabled={submitting} onClick={() => void submitForm()}>
{editingId ? <Save className="h-4 w-4" /> : <MailPlus className="h-4 w-4" />}
{submitting ? '保存中...' : editingId ? '保存修改' : '保存订阅目标'}
</Button>
{editingId ? (
<Button variant="outline" disabled={submitting} onClick={resetForm}>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> filters / metadata</CardDescription>
</div>
<Badge variant="outline">{subscriptions.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="space-y-1">
<div className="font-medium">{item.display_name ?? item.channel_type}</div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{item.channel_type}
</div>
</div>
</TableCell>
<TableCell className="max-w-[280px] break-words text-sm text-muted-foreground">
<div>{item.target}</div>
<div className="mt-1 text-xs text-muted-foreground/80">
manage_token: {item.manage_token ? '已生成' : '—'} · verified: {item.verified_at ? 'yes' : 'no'}
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant={item.status === 'active' ? 'success' : 'secondary'}>
{item.status}
</Badge>
<div className="text-xs text-muted-foreground">
{item.failure_count ?? 0} · {item.last_delivery_status ?? '—'}
</div>
</div>
</TableCell>
<TableCell className="max-w-[260px] whitespace-pre-wrap break-words text-xs text-muted-foreground">
{normalizePreview(item.filters)}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingId(item.id)
setForm({
channelType: item.channel_type,
target: item.target,
displayName: item.display_name ?? '',
status: item.status,
notes: item.notes ?? '',
filtersText: prettyJson(item.filters),
metadataText: prettyJson(item.metadata),
})
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.testSubscription(item.id)
toast.success('测试通知已入队。')
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '测试发送失败。')
} finally {
setActioningId(null)
}
}}
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={actioningId === item.id}
onClick={async () => {
try {
setActioningId(item.id)
await adminApi.deleteSubscription(item.id)
toast.success('订阅目标已删除。')
if (editingId === item.id) {
resetForm()
}
await loadData(false)
} catch (error) {
toast.error(error instanceof ApiError ? error.message : '删除失败。')
} finally {
setActioningId(null)
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> attempts / next retry / response</CardDescription>
</div>
<Badge variant="outline">{deliveries.length} </Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-muted-foreground">{item.delivered_at ?? item.created_at}</TableCell>
<TableCell>
<div className="font-medium">{item.event_type}</div>
<div className="text-xs text-muted-foreground">#{item.subscription_id ?? '—'}</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{item.channel_type}</div>
<div className="line-clamp-1 text-xs text-muted-foreground">{item.target}</div>
</div>
</TableCell>
<TableCell>
<Badge variant={item.status === 'sent' ? 'success' : item.status === 'retry_pending' ? 'warning' : 'secondary'}>
{item.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>attempts: {item.attempts_count}</div>
<div>next: {item.next_retry_at ?? '—'}</div>
</TableCell>
<TableCell className="max-w-[360px] whitespace-pre-wrap break-words text-sm text-muted-foreground">
{item.response_text ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}