refactor: split modules and harden scraping workflows

This commit is contained in:
ShukeBta
2026-06-24 11:59:18 +08:00
parent efca3cbe69
commit 192f35d9fa
470 changed files with 47957 additions and 33839 deletions
+8 -552
View File
@@ -1,13 +1,12 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
import toast from 'react-hot-toast'
import {
notifyChannelsAPI,
type NotifyChannelInput,
} from '../api/notify_channels'
import { confirmAction } from '../components/ConfirmDialog'
import { notifyChannelsAPI } from '../api/notify_channels'
import { confirmAction } from '../components/confirmAction'
import type { NotifyChannel } from '../types'
import { NotifyChannelCard } from './NotifyChannelCard'
import { NotifyChannelFormModal } from './NotifyChannelFormModal'
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
@@ -152,7 +151,7 @@ export function NotifyChannelsPage() {
{!loading && channels.length > 0 && (
<div className="space-y-3">
{channels.map((ch) => (
<ChannelCard
<NotifyChannelCard
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
@@ -168,7 +167,7 @@ export function NotifyChannelsPage() {
)}
{showForm && (
<ChannelFormModal
<NotifyChannelFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
@@ -180,546 +179,3 @@ export function NotifyChannelsPage() {
</div>
)
}
const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
telegram: 'Telegram',
wechat: '企业微信',
bark: 'Bark',
webhook: 'Webhook',
email: 'Email',
}
const EVENT_OPTIONS = [
{ value: 'subscription_hit', label: '订阅命中新资源' },
{ value: 'download_complete', label: '下载任务完成' },
{ value: 'library_ingest', label: '入库完成' },
{ value: 'scrape_failed', label: '刮削失败告警' },
{ value: 'system_alert', label: '系统异常通知' },
]
const EVENT_ALL = '__all__'
const EVENT_NONE = '__none__'
type EventMode = 'all' | 'custom' | 'none'
function initialEventMode(events: string[] | undefined): EventMode {
if (events?.includes(EVENT_NONE)) return 'none'
if (events?.includes(EVENT_ALL) || !events || events.length === 0) return 'all'
return 'custom'
}
function ChannelCard({
channel,
onTest,
testing,
onEdit,
onDelete,
}: {
channel: NotifyChannel
onTest: () => void
testing?: boolean
onEdit: () => void
onDelete: () => void
}) {
const summary = channelSummary(channel)
return (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{channel.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{TYPE_LABELS[channel.type] ?? channel.type}
</span>
{!channel.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
</button>
<button
onClick={onEdit}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={onDelete}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
function eventSummary(events: string[] | undefined): string {
if (events?.includes(EVENT_NONE)) return '事件:不推送'
if (events?.includes(EVENT_ALL)) return '事件:全部'
if (!events || events.length === 0) return '事件:全部'
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
return `事件:${labels.join('、')}`
}
function channelSummary(ch: NotifyChannel): string {
const cfg = ch.config ?? {}
switch (ch.type) {
case 'telegram':
return `Bot ${cfg.bot_token ? '已配置' : '未配置'} → 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}`
case 'wechat':
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
case 'bark':
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
case 'webhook':
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
case 'email':
return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}`
default:
return ''
}
}
// ─── Form Modal ─────────────────────────────────────────────────────────────
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
telegram: {
bot_token: '',
admin_user_ids: '',
group_chat_id: '',
channel_chat_id: '',
api_base_url: '',
proxy_url: '',
},
wechat: { sendkey: '' },
bark: { device_key: '', server: '' },
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' },
}
function normalizeInitialConfig(type: NotifyChannel['type'], raw: Record<string, unknown>): Record<string, string> {
const base = { ...EMPTY_CONFIG[type] }
for (const [key, value] of Object.entries(raw ?? {})) {
base[key] = String(value ?? '')
}
if (type === 'telegram' && !base.group_chat_id && !base.channel_chat_id && base.chat_id?.startsWith('-')) {
base.group_chat_id = base.chat_id
}
if (type === 'telegram' && !base.admin_user_ids && base.chat_id && !base.chat_id.startsWith('-')) {
base.admin_user_ids = base.chat_id
}
delete base.chat_id
return base
}
function ChannelFormModal({
editing,
onClose,
onSaved,
}: {
editing: NotifyChannel | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [name, setName] = useState(editing?.name ?? '')
const [type, setType] = useState<NotifyChannel['type']>(
editing?.type ?? 'telegram',
)
const [config, setConfig] = useState<Record<string, string>>(
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
)
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
const [eventMode, setEventMode] = useState<EventMode>(initialEventMode(editing?.events))
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
const [saving, setSaving] = useState(false)
const onTypeChange = (t: NotifyChannel['type']) => {
setType(t)
setConfig({ ...EMPTY_CONFIG[t] })
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
if (type === 'telegram') {
if (!String(config.bot_token ?? '').trim()) {
toast.error('请填写 Telegram Bot Token')
return
}
if (!String(config.admin_user_ids ?? '').trim()) {
toast.error('请填写管理员 Telegram ID')
return
}
}
const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
if (eventMode === 'custom' && selectedEvents.length === 0) {
toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
return
}
setSaving(true)
try {
const cleanedConfig = Object.fromEntries(
Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
)
delete cleanedConfig.chat_id
const input: NotifyChannelInput = {
name: name.trim(),
type: type,
config: cleanedConfig,
events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
enabled,
}
if (editing) {
await notifyChannelsAPI.update(editing.id, input)
} else {
await notifyChannelsAPI.create(input)
}
toast.success('已保存')
await onSaved()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const updateConfig = (k: string, v: string) =>
setConfig((c) => ({ ...c, [k]: v }))
const toggleEvent = (event: string) => {
setEvents((current) =>
current.includes(event)
? current.filter((item) => item !== event)
: [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑通知渠道' : '添加通知渠道'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
placeholder="如: Telegram 通知"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="渠道类型">
<select
className="input-base"
value={type}
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
>
<option value="telegram">Telegram</option>
<option value="wechat">企业微信 / Server酱</option>
<option value="bark">Bark (iOS)</option>
<option value="webhook">Webhook</option>
<option value="email">Email (SMTP)</option>
</select>
</Field>
{type === 'telegram' && (
<>
<Field label="Bot Token">
<input
required
className="input-base"
placeholder="123456:ABC-DEF…"
value={config.bot_token ?? ''}
onChange={(e) => updateConfig('bot_token', e.target.value)}
/>
</Field>
<Field label="管理员 Telegram ID">
<input
required
className="input-base"
placeholder="多个用逗号分隔,如 123456789,987654321"
value={config.admin_user_ids ?? ''}
onChange={(e) => updateConfig('admin_user_ids', e.target.value)}
/>
</Field>
<Field label="绑定群组 ID">
<input
className="input-base"
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
value={config.group_chat_id ?? ''}
onChange={(e) => updateConfig('group_chat_id', e.target.value)}
/>
</Field>
<Field label="绑定频道 ID">
<input
className="input-base"
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
value={config.channel_chat_id ?? ''}
onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
/>
</Field>
<Field label="Telegram API 地址 (可选)">
<input
className="input-base"
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
value={config.api_base_url ?? ''}
onChange={(e) => updateConfig('api_base_url', e.target.value)}
/>
</Field>
<Field label="Telegram 代理 (可选)">
<input
className="input-base"
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
value={config.proxy_url ?? ''}
onChange={(e) => updateConfig('proxy_url', e.target.value)}
/>
</Field>
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
</div>
</>
)}
{type === 'wechat' && (
<Field label="SendKey">
<input
required
className="input-base"
placeholder="SCT…"
value={config.sendkey ?? ''}
onChange={(e) => updateConfig('sendkey', e.target.value)}
/>
</Field>
)}
{type === 'bark' && (
<>
<Field label="设备 Key">
<input
required
className="input-base"
value={config.device_key ?? ''}
onChange={(e) => updateConfig('device_key', e.target.value)}
/>
</Field>
<Field label="服务器地址 (可选)">
<input
className="input-base"
placeholder="https://api.day.app"
value={config.server ?? ''}
onChange={(e) => updateConfig('server', e.target.value)}
/>
</Field>
</>
)}
{type === 'webhook' && (
<>
<Field label="URL">
<input
required
className="input-base"
placeholder="https://example.com/notify"
value={config.url ?? ''}
onChange={(e) => updateConfig('url', e.target.value)}
/>
</Field>
<Field label="Method">
<select
className="input-base"
value={config.method ?? 'POST'}
onChange={(e) => updateConfig('method', e.target.value)}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</Field>
<Field label="Headers (JSON)">
<textarea
rows={2}
className="input-base font-mono text-xs"
placeholder='{"Content-Type":"application/json"}'
value={config.headers ?? ''}
onChange={(e) => updateConfig('headers', e.target.value)}
/>
</Field>
<Field label="Body 模板 (支持 {{title}} {{message}})">
<textarea
rows={3}
className="input-base font-mono text-xs"
placeholder='{"title":"{{title}}","message":"{{message}}"}'
value={config.body_template ?? ''}
onChange={(e) => updateConfig('body_template', e.target.value)}
/>
</Field>
</>
)}
{type === 'email' && (
<>
<Field label="SMTP 地址">
<input
required
className="input-base"
placeholder="smtp.gmail.com"
value={config.smtp_host ?? ''}
onChange={(e) => updateConfig('smtp_host', e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="SMTP 端口">
<input
required
className="input-base"
placeholder="465"
value={config.smtp_port ?? ''}
onChange={(e) => updateConfig('smtp_port', e.target.value)}
/>
</Field>
<Field label="TLS">
<select
className="input-base"
value={config.tls ?? 'true'}
onChange={(e) => updateConfig('tls', e.target.value)}
>
<option value="true">启用</option>
<option value="false">关闭</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="用户名">
<input
required
className="input-base"
value={config.username ?? ''}
onChange={(e) => updateConfig('username', e.target.value)}
/>
</Field>
<Field label="密码">
<input
type="password"
required
className="input-base"
value={config.password ?? ''}
onChange={(e) => updateConfig('password', e.target.value)}
/>
</Field>
</div>
<Field label="发件人">
<input
required
className="input-base"
placeholder="noreply@example.com"
value={config.from ?? ''}
onChange={(e) => updateConfig('from', e.target.value)}
/>
</Field>
<Field label="收件人 (多个用逗号分隔)">
<input
required
className="input-base"
placeholder="user@example.com"
value={config.to ?? ''}
onChange={(e) => updateConfig('to', e.target.value)}
/>
</Field>
</>
)}
<Field label="推送事件">
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'all'}
onChange={() => setEventMode('all')}
/>
全部事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'none'}
onChange={() => setEventMode('none')}
/>
关闭全部推送事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'custom'}
onChange={() => setEventMode('custom')}
/>
仅推送勾选事件
</label>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((event) => (
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
disabled={eventMode !== 'custom'}
checked={events.includes(event.value)}
onChange={() => toggleEvent(event.value)}
/>
{event.label}
</label>
))}
</div>
</div>
</Field>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
启用
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}