mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
feat: merge conflict resolution, site management, UI fixes
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
|
||||
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
|
||||
export function APIConfigsPanel() {
|
||||
const [items, setItems] = useState<APIConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
const refresh = () =>
|
||||
apiConfigsAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-400" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">外部 API 配置</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">加载中…</p>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) =>
|
||||
editing === item.provider ? (
|
||||
<EditingRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-white">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
<span className="text-primary-400">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-slate-600">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.has_key ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||
已配置
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-400/10 px-2 py-0.5 text-xs text-slate-500">
|
||||
未配置
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(item.provider)}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
title="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast(
|
||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||
)
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-slate-200"
|
||||
title="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
{item.has_key && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
|
||||
await apiConfigsAPI.remove(item.provider)
|
||||
toast.success('已清除')
|
||||
refresh()
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
title="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditingRow({
|
||||
item,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
item: APIConfig
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button type="submit" disabled={saving} className="neon-button !px-3 !py-1.5 !text-xs">
|
||||
<Save size={12} /> 保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-slate-400/30 px-2 py-1.5 text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
@@ -94,18 +94,18 @@ export function Layout() {
|
||||
|
||||
{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="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
<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="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface PermissionGuardProps {
|
||||
permission: string
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
requireSuperUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard 组件用于根据用户权限控制内容显示。
|
||||
*
|
||||
* @param permission - 需要的权限键
|
||||
* @param children - 有权限时显示的内容
|
||||
* @param fallback - 无权限时显示的内容(可选,默认不显示)
|
||||
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
requireSuperUser = false,
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
|
||||
// 超级用户(admin 或 plus)默认有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
|
||||
if (requireSuperUser && !isSuper) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 加载中时显示 fallback
|
||||
if (isLoading && Object.keys(permissions).length === 0) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (hasPermission(permission)) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 权限检查工具函数
|
||||
export function checkPermission(
|
||||
permission: string,
|
||||
isSuper: boolean,
|
||||
tier: string,
|
||||
role: string,
|
||||
permissions: Record<string, boolean>
|
||||
): boolean {
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[permission] === true
|
||||
}
|
||||
Reference in New Issue
Block a user