feat: Warm Industrial UI redesign + social footer + code deduplication

Design System (Phase A):
- New palette: warm charcoal #111110 / amber gold #c9954a / sage green #7a9a8a
- Fonts: Cabinet Grotesk (heading) + Geist (body) + JetBrains Mono (data)
- SVG grain texture overlay, collapsed sidebar with hover expand
- Bento grid dashboard with WideContinueCard + PosterCard + QuickLink
- Backward-compatible CSS aliases for glass-panel/neon-button/input-base

Social Footer (Phase B):
- AppFooter component with GitHub repo / author homepage / TG group links
- Embedded in LoginPage, Layout (global), and SettingsPage
- TG group badge added to README.md

Code Cleanup:
- Removed deprecated DownloadClientCard / NotifyChannelCard components
- Merged downloadClient/notify API clients into unified modules
- Route deduplication: removed authed-group duplicates for admin endpoints
- Removed redundant AdminPage tabs (sites/downloads/notify/scheduler)
- UI fixes: blank pages (discover/download-clients/notify-channels) nil slice defense
- Email channel support in NotifyChannelsPage
@
This commit is contained in:
ShukeBta
2026-05-17 23:13:26 +08:00
parent 9be81222cc
commit 828ca36d7f
18 changed files with 732 additions and 910 deletions
+41
View File
@@ -0,0 +1,41 @@
import { Github, Globe, Send } from 'lucide-react'
const LINKS = [
{
href: 'https://github.com/ShukeBta/MediaStationGo',
icon: Github,
label: '开源仓库',
},
{
href: 'https://github.com/ShukeBta',
icon: Globe,
label: '作者主页',
},
{
href: 'https://t.me/MediaStationGo',
icon: Send,
label: 'TG 群组',
},
]
export function AppFooter({ className = '' }: { className?: string }) {
return (
<footer className={`flex items-center justify-center gap-1 ${className}`}>
{LINKS.map((link, i) => (
<span key={link.href} className="flex items-center">
{i > 0 && <span className="mx-2 h-3 w-px bg-cream-900/20" />}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-cream-500 transition-colors hover:bg-cream-900/10 hover:text-cream-300"
title={link.label}
>
<link.icon size={14} />
<span>{link.label}</span>
</a>
</span>
))}
</footer>
)
}
-250
View File
@@ -1,250 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react'
import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient'
import type { DownloadClient } from '../types'
const CLIENT_TYPES = [
{ value: 'qbittorrent', label: 'qBittorrent' },
{ value: 'transmission', label: 'Transmission' },
{ value: 'aria2', label: 'Aria2' },
] as const
const TYPE_LABELS: Record<string, string> = {
qbittorrent: 'qBittorrent',
transmission: 'Transmission',
aria2: 'Aria2',
}
export function DownloadClientCard() {
const [clients, setClients] = useState<DownloadClient[]>([])
const [showForm, setShowForm] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [form, setForm] = useState<DownloadClientCreateParams>({
name: '',
type: 'qbittorrent',
host: '',
username: '',
password: '',
is_default: false,
})
const refresh = () => downloadClientAPI.list().then(setClients)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
try {
await downloadClientAPI.create(form)
toast.success('下载客户端已创建')
setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false })
setShowForm(false)
await refresh()
} catch {
toast.error('创建失败')
}
}
const handleTest = async (id: string) => {
setTesting(id)
try {
await downloadClientAPI.test(id)
toast.success('连接测试成功')
} catch {
toast.error('连接测试失败')
} finally {
setTesting(null)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除「${name}」?`)) return
await downloadClientAPI.delete(id)
toast.success('已删除')
await refresh()
}
const handleToggleDefault = async (client: DownloadClient) => {
await downloadClientAPI.update(client.id, { is_default: true })
toast.success(`已设「${client.name}」为默认客户端`)
await refresh()
}
const handleToggleEnabled = async (client: DownloadClient) => {
await downloadClientAPI.update(client.id, { enabled: !client.enabled })
toast.success(client.enabled ? '已禁用' : '已启用')
await refresh()
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">下载客户端</h2>
<button
className="neon-button flex items-center gap-1 text-sm"
onClick={() => setShowForm(!showForm)}
>
<Plug size={14} />
添加客户端
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<input
required
className="input-base"
placeholder="名称"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<select
className="input-base"
value={form.type}
onChange={(e) =>
setForm({ ...form, type: e.target.value as DownloadClientCreateParams['type'] })
}
>
{CLIENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<input
required
className="input-base"
placeholder="地址 (如 http://127.0.0.1:8080)"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
/>
<div className="grid gap-3 md:grid-cols-2">
<input
className="input-base"
placeholder="用户名"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
<input
className="input-base"
type="password"
placeholder="密码"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
className="rounded border-slate-600"
/>
设为默认客户端
</label>
<div className="flex gap-2">
<button type="submit" className="neon-button">
创建
</button>
<button
type="button"
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
onClick={() => setShowForm(false)}
>
取消
</button>
</div>
</form>
)}
<div className="space-y-2">
{clients.map((client) => (
<div
key={client.id}
className={`glass-panel flex items-center justify-between ${
!client.enabled ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-3">
<div
className={`rounded-lg p-2 ${
client.type === 'qbittorrent'
? 'bg-blue-500/20 text-blue-400'
: client.type === 'transmission'
? 'bg-purple-500/20 text-purple-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
<Settings size={18} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{client.name}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
{TYPE_LABELS[client.type]}
</span>
{client.is_default && (
<span className="rounded bg-primary-400/20 px-1.5 py-0.5 text-xs text-primary-400">
默认
</span>
)}
{!client.enabled && (
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
已禁用
</span>
)}
</div>
<span className="text-xs text-slate-500">{client.host}</span>
</div>
</div>
<div className="flex items-center gap-1">
{!client.is_default && (
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={() => handleToggleDefault(client)}
title="设为默认"
>
<Zap size={12} />
</button>
)}
<button
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
onClick={() => handleToggleEnabled(client)}
title={client.enabled ? '禁用' : '启用'}
>
{client.enabled ? '暂停' : '启用'}
</button>
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={() => handleTest(client.id)}
disabled={testing === client.id}
>
{testing === client.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
'测试'
)}
</button>
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={() => handleDelete(client.id, client.name)}
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
{clients.length === 0 && (
<div className="py-8 text-center text-sm text-slate-500">
暂无下载客户端,点击上方按钮添加
</div>
)}
</div>
</div>
)
}
+132 -88
View File
@@ -36,11 +36,11 @@ import {
} from 'lucide-react'
import clsx from 'clsx'
import { AppFooter } from './AppFooter'
import { libraryAPI } from '../api/library'
import { useAuthStore } from '../stores/auth'
import type { Library } from '../types'
// Top-level chrome: a fixed left rail + scrollable content area.
export function Layout() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
@@ -58,96 +58,132 @@ export function Layout() {
return (
<div className="flex min-h-full">
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/5 bg-surface-900/80 px-4 py-6 backdrop-blur md:flex">
<Link to="/" className="mb-8 flex items-center gap-2 px-2">
<Film className="h-6 w-6 text-primary-400" />
<span className="font-display text-xl font-bold tracking-wide text-white">
MediaStationGo
</span>
</Link>
<nav className="flex-1 space-y-1 overflow-y-auto pr-1">
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
媒体库
</div>
{libraries.length === 0 && (
<div className="px-2 py-1 text-sm text-slate-500">暂无媒体库</div>
{/* ── Sidebar group — collapsed 64px, expands to 240px on hover ── */}
<div className="group relative z-30 shrink-0">
<aside
className={clsx(
'flex h-full flex-col border-r border-cream-900/15 bg-surface-600',
'w-16 transition-all duration-300 ease-out',
'group-hover:w-60',
)}
{libraries.map((lib) => (
<SidebarLink
key={lib.id}
to={`/library/${lib.id}`}
icon={<LibraryIcon size={18} />}
label={lib.name}
/>
))}
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
自动化
</div>
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
账号
</div>
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
{user?.role === 'admin' && (
<>
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
管理
</div>
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
</>
)}
</nav>
<button
onClick={handleLogout}
className="mt-4 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-400 transition hover:bg-white/5 hover:text-white"
>
<LogOut size={16} />
退出登录 ({user?.username})
</button>
</aside>
{/* Logo */}
<Link to="/" className="flex items-center gap-3 px-4 py-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500/15">
<Film className="h-4 w-4 text-brand-400" />
</div>
<span
className={clsx(
'font-display text-lg font-bold tracking-tight text-cream-100',
'overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200',
'group-hover:opacity-100',
)}
>
MediaStation
</span>
</Link>
<main className="flex-1 overflow-y-auto px-6 py-8">
<Outlet />
{/* Navigation */}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
<SectionHeader label="浏览" />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
<SectionHeader label="媒体库" />
{libraries.length === 0 && (
<div className="hidden px-3 py-1.5 text-xs text-cream-500/50 group-hover:block">
暂无媒体库
</div>
)}
{libraries.map((lib) => (
<SidebarLink
key={lib.id}
to={`/library/${lib.id}`}
icon={<LibraryIcon size={18} />}
label={lib.name}
/>
))}
<SectionHeader label="自动化" />
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
<SectionHeader label="账号" />
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
{user?.role === 'admin' && (
<>
<SectionHeader label="管理" />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
</>
)}
</nav>
{/* Bottom: logout */}
<div className="border-t border-cream-900/15 px-3 py-3">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-cream-400 transition hover:bg-cream-900/10 hover:text-cream-200"
title={`退出登录 (${user?.username})`}
>
<LogOut size={16} />
<span className="overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100">
退出
</span>
</button>
</div>
</aside>
</div>
{/* ── Main content ── */}
<main className="flex flex-1 flex-col overflow-y-auto">
<div className="flex-1 px-6 py-8">
<Outlet />
</div>
<AppFooter className="py-4" />
</main>
</div>
)
}
/** Section divider — visible only when sidebar is expanded */
function SectionHeader({ label }: { label: string }) {
return (
<div className="mt-4 mb-1 overflow-hidden whitespace-nowrap px-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="text-[10px] font-medium uppercase tracking-[0.15em] text-cream-500/50">
{label}
</span>
</div>
)
}
/** Single nav link — icon always visible, label fades in on expand */
function SidebarLink({
to,
icon,
@@ -165,15 +201,23 @@ function SidebarLink({
end={end}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-primary-400/10 text-primary-400'
: 'text-slate-300 hover:bg-white/5 hover:text-white',
? 'bg-brand-500/10 text-brand-400'
: 'text-cream-400 hover:bg-cream-900/10 hover:text-cream-200',
)
}
>
{icon}
<span className="truncate">{label}</span>
<span className="flex shrink-0 items-center justify-center w-5 h-5">{icon}</span>
<span
className={clsx(
'overflow-hidden whitespace-nowrap truncate',
'opacity-0 transition-opacity duration-200',
'group-hover:opacity-100',
)}
>
{label}
</span>
</NavLink>
)
}
+28 -15
View File
@@ -1,14 +1,10 @@
import { Link } from 'react-router-dom'
import { Film } from 'lucide-react'
import { Film, Play } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Media } from '../types'
// Compact poster tile used by every listing surface (home / library / search).
//
// Props:
// - media: the Media row to render
// - progress: 0..1 fractional resume position (optional)
/** Compact poster tile — used by library, search, favourites, poster wall, etc. */
export function MediaCard({
media,
progress,
@@ -19,34 +15,51 @@ export function MediaCard({
return (
<Link
to={`/media/${media.id}`}
className="group block overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 transition hover:border-primary-400/50 hover:shadow-lg hover:shadow-primary-400/20"
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
>
<div className="relative aspect-[2/3] w-full bg-surface-900">
{/* Poster */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-105"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
<Film size={48} />
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
<span className="flex items-center gap-1.5 text-xs text-white/90">
<Play size={12} />
播放
</span>
</div>
{/* Progress bar */}
{progress !== undefined && progress > 0 && progress < 1 && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
<div
className="h-full bg-primary-400"
className="h-full bg-brand-500/80 transition-colors group-hover:bg-brand-400"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
)}
</div>
<div className="px-3 py-2">
<p className="truncate text-sm font-medium text-white">{media.title}</p>
{media.year > 0 && <p className="text-xs text-slate-400">{media.year}</p>}
{/* Info */}
<div className="px-3 py-2.5">
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
{media.title}
</p>
{media.year > 0 && (
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
)}
</div>
</Link>
)
-282
View File
@@ -1,282 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Bell, Trash2, Loader2 } from 'lucide-react'
import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify'
import type { NotifyChannel } from '../types'
const CHANNEL_TYPES = [
{ value: 'telegram', label: 'Telegram' },
{ value: 'wechat', label: 'Server酱' },
{ value: 'bark', label: 'Bark' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'email', label: 'Email' },
] as const
const TYPE_LABELS: Record<string, string> = {
telegram: 'Telegram',
wechat: 'Server酱',
bark: 'Bark',
webhook: 'Webhook',
email: 'Email',
}
const EVENT_OPTIONS = [
{ value: 'subscription_hit', label: '订阅命中' },
{ value: 'download_complete', label: '下载完成' },
{ value: 'scrape_failed', label: '刮削失败' },
{ value: 'system_alert', label: '系统告警' },
]
const CONFIG_FIELDS: Record<string, { key: string; label: string; type?: string; placeholder: string }[]> = {
telegram: [
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
{ key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' },
{ key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' },
],
wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }],
bark: [
{ key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' },
{ key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' },
],
webhook: [
{ key: 'url', label: 'Webhook URL', placeholder: 'https://...' },
{ key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' },
{ key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' },
{ key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' },
],
email: [
{ key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' },
{ key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' },
{ key: 'username', label: '用户名', placeholder: '' },
{ key: 'password', label: '密码', placeholder: '', type: 'password' },
{ key: 'from', label: '发件人', placeholder: 'noreply@example.com' },
{ key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' },
{ key: 'tls', label: 'TLS', placeholder: 'true/false' },
],
}
export function NotifyChannelCard() {
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [showForm, setShowForm] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [form, setForm] = useState<NotifyChannelCreateParams>({
name: '',
type: 'telegram',
enabled: true,
config: {},
events: [],
})
const refresh = () => notifyAPI.list().then(setChannels)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleTypeChange = (type: string) => {
setForm({
...form,
type: type as NotifyChannelCreateParams['type'],
config: {},
})
}
const handleConfigChange = (key: string, value: string) => {
setForm({ ...form, config: { ...form.config, [key]: value } })
}
const handleEventToggle = (event: string) => {
const events = form.events || []
const idx = events.indexOf(event)
if (idx >= 0) {
events.splice(idx, 1)
} else {
events.push(event)
}
setForm({ ...form, events: [...events] })
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
try {
await notifyAPI.create(form)
toast.success('通知渠道已创建')
setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] })
setShowForm(false)
await refresh()
} catch {
toast.error('创建失败')
}
}
const handleTest = async (id: string) => {
setTesting(id)
try {
await notifyAPI.test(id)
toast.success('测试通知已发送')
} catch {
toast.error('测试通知发送失败')
} finally {
setTesting(null)
}
}
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除「${name}」?`)) return
await notifyAPI.delete(id)
toast.success('已删除')
await refresh()
}
const handleToggleEnabled = async (channel: NotifyChannel) => {
await notifyAPI.update(channel.id, { enabled: !channel.enabled })
toast.success(channel.enabled ? '已禁用' : '已启用')
await refresh()
}
const configFields = CONFIG_FIELDS[form.type] || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">通知渠道</h2>
<button
className="neon-button flex items-center gap-1 text-sm"
onClick={() => setShowForm(!showForm)}
>
<Bell size={14} />
添加渠道
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<input
required
className="input-base"
placeholder="名称"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<select className="input-base" value={form.type} onChange={(e) => handleTypeChange(e.target.value)}>
{CHANNEL_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{configFields.map((field) => (
<input
key={field.key}
required={field.key !== 'parse_mode' && field.key !== 'method' && field.key !== 'headers_json' && field.key !== 'body_template' && field.key !== 'server_url' && field.key !== 'tls'}
className="input-base"
type={field.type || 'text'}
placeholder={field.label + (field.placeholder ? ` (${field.placeholder})` : '')}
value={form.config[field.key] || ''}
onChange={(e) => handleConfigChange(field.key, e.target.value)}
/>
))}
<div>
<label className="mb-1 block text-xs text-slate-500">订阅事件</label>
<div className="flex flex-wrap gap-2">
{EVENT_OPTIONS.map((event) => (
<label
key={event.value}
className="flex cursor-pointer items-center gap-1 rounded border border-white/10 px-2 py-1 text-xs text-slate-300"
>
<input
type="checkbox"
checked={(form.events || []).includes(event.value)}
onChange={() => handleEventToggle(event.value)}
className="rounded border-slate-600"
/>
{event.label}
</label>
))}
</div>
</div>
<div className="flex gap-2">
<button type="submit" className="neon-button">
创建
</button>
<button
type="button"
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
onClick={() => setShowForm(false)}
>
取消
</button>
</div>
</form>
)}
<div className="space-y-2">
{channels.map((channel) => (
<div
key={channel.id}
className={`glass-panel flex items-center justify-between ${
!channel.enabled ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-3">
<div className="rounded-lg bg-green-500/20 p-2 text-green-400">
<Bell size={18} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{channel.name}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
{TYPE_LABELS[channel.type]}
</span>
{!channel.enabled && (
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
已禁用
</span>
)}
</div>
<span className="text-xs text-slate-500">
事件: {channel.events || '无'}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
onClick={() => handleToggleEnabled(channel)}
>
{channel.enabled ? '禁用' : '启用'}
</button>
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={() => handleTest(channel.id)}
disabled={testing === channel.id}
>
{testing === channel.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
'测试'
)}
</button>
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={() => handleDelete(channel.id, channel.name)}
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
{channels.length === 0 && (
<div className="py-8 text-center text-sm text-slate-500">
暂无通知渠道,点击上方按钮添加
</div>
)}
</div>
</div>
)
}