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
509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
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>filters(JSON)</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>metadata(JSON,可选)</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>
|
||
)
|
||
}
|