Initial commit: TaskPool React panel

- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
This commit is contained in:
2026-07-26 08:43:52 +08:00
commit e6956aa001
397 changed files with 73621 additions and 0 deletions
+484
View File
@@ -0,0 +1,484 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Download, ExternalLink, RefreshCw, Trash2, Upload, UploadCloud } from 'lucide-react'
import { api, type WebUI } from '@/api'
import { useAbout, useSchedulerSettings, useSiteSettings } from '@/api/hooks'
import { useTheme } from '@/context/ThemeContext'
import { Button, Card, Input, PageHeader, Switch, Tabs, Textarea } from '@/components/ui'
import { toast } from '@/lib/toast'
export default function Settings() {
const { theme, setTheme } = useTheme()
const qc = useQueryClient()
const { data: site } = useSiteSettings()
const { data: scheduler } = useSchedulerSettings()
const { data: about } = useAbout()
const [tab, setTab] = useState('site')
const [siteForm, setSiteForm] = useState({
title: '',
subtitle: '',
page_size: '20',
cookie_days: '7',
openapi_enabled: false,
openapi_token: '',
system_notice_days: '7',
system_notice_max_count: '1000',
push_log_days: '7',
push_log_max_count: '1000',
login_log_days: '30',
login_log_max_count: '1000',
scheduler_log_days: '7',
scheduler_log_max_count: '1000',
})
const [schedulerForm, setSchedulerForm] = useState({ worker_count: '4', queue_size: '100', rate_interval: '0' })
const [passwordForm, setPasswordForm] = useState({ old_password: '', new_password: '', username: '' })
const [changelog, setChangelog] = useState('')
const [backupStatus, setBackupStatus] = useState<{ has_backup: boolean; backup_time: string } | null>(null)
const [message, setMessage] = useState('')
const webuiInputRef = useRef<HTMLInputElement>(null)
const { data: loginLogs } = useQuery({
queryKey: ['loginLogs'],
queryFn: () => api.settings.getLoginLogs({ page: 1, page_size: 20 }),
enabled: tab === 'security',
})
const { data: webuis, refetch: refetchWebui, isFetching: webuiFetching } = useQuery({
queryKey: ['webuis'],
queryFn: async () => {
const list = await api.webui.list()
return Array.isArray(list) ? list : []
},
enabled: tab === 'webui',
})
useEffect(() => {
if (site) {
setSiteForm({
title: site.title || '',
subtitle: site.subtitle || '',
page_size: site.page_size || '20',
cookie_days: site.cookie_days || '7',
openapi_enabled: !!site.openapi_enabled,
openapi_token: site.openapi_token || '',
system_notice_days: site.system_notice_days || '7',
system_notice_max_count: site.system_notice_max_count || '1000',
push_log_days: site.push_log_days || '7',
push_log_max_count: site.push_log_max_count || '1000',
login_log_days: site.login_log_days || '30',
login_log_max_count: site.login_log_max_count || '1000',
scheduler_log_days: site.scheduler_log_days || '7',
scheduler_log_max_count: site.scheduler_log_max_count || '1000',
})
}
}, [site])
useEffect(() => {
if (scheduler) {
setSchedulerForm({
worker_count: scheduler.worker_count || '4',
queue_size: scheduler.queue_size || '100',
rate_interval: scheduler.rate_interval || '0',
})
}
}, [scheduler])
useEffect(() => {
if (tab !== 'about' && tab !== 'backup') return
if (tab === 'about') {
api.settings
.getChangelog()
.then((t) => setChangelog(typeof t === 'string' ? t : ''))
.catch(() => setChangelog(''))
}
api.settings
.getBackupStatus()
.then(setBackupStatus)
.catch(() => setBackupStatus(null))
}, [tab])
const saveSite = useMutation({
mutationFn: () =>
api.settings.updateSite({
...(site || {}),
...siteForm,
openapi_enabled: siteForm.openapi_enabled,
} as any),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['siteSettings'] })
qc.invalidateQueries({ queryKey: ['publicSite'] })
setMessage('站点设置已保存')
toast.success('站点设置已保存')
},
onError: (e: any) => {
setMessage(e?.message || '保存失败')
toast.error(e?.message || '保存失败')
},
})
const saveScheduler = useMutation({
mutationFn: () => api.settings.updateScheduler(schedulerForm),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schedulerSettings'] })
setMessage('调度设置已保存')
toast.success('调度设置已保存')
},
onError: (e: any) => {
setMessage(e?.message || '保存失败')
toast.error(e?.message || '保存失败')
},
})
const changePassword = useMutation({
mutationFn: () =>
api.settings.changePassword({
old_password: passwordForm.old_password,
new_password: passwordForm.new_password || undefined,
username: passwordForm.username || undefined,
}),
onSuccess: () => {
setPasswordForm({ old_password: '', new_password: '', username: '' })
setMessage('账号信息已更新')
toast.success('账号信息已更新')
},
onError: (e: any) => {
setMessage(e?.message || '更新失败')
toast.error(e?.message || '更新失败')
},
})
const genToken = useMutation({
mutationFn: () => api.settings.generateOpenapiToken(),
onSuccess: (res) => {
setSiteForm((f) => ({ ...f, openapi_token: res.token || '' }))
setMessage('OpenAPI Token 已生成')
toast.success('OpenAPI Token 已生成')
qc.invalidateQueries({ queryKey: ['siteSettings'] })
},
onError: (e: any) => {
setMessage(e?.message || '生成失败')
toast.error(e?.message || '生成失败')
},
})
const createBackup = useMutation({
mutationFn: () => api.settings.createBackup(),
onSuccess: async () => {
setMessage('备份已创建')
toast.success('备份已创建')
setBackupStatus(await api.settings.getBackupStatus())
},
onError: (e: any) => {
setMessage(e?.message || '备份失败')
toast.error(e?.message || '备份失败')
},
})
const uploadWebui = useMutation({
mutationFn: (file: File) => api.webui.upload(file),
onSuccess: (res) => {
const msg = res?.message || 'WebUI 上传成功'
setMessage(msg)
toast.success(msg)
refetchWebui()
qc.invalidateQueries({ queryKey: ['siteSettings'] })
},
onError: (e: any) => {
setMessage(e?.message || '上传失败')
toast.error(e?.message || '上传失败')
},
})
const activateWebui = useMutation({
mutationFn: (name: string) => api.webui.setActive(name),
onSuccess: (res) => {
const msg = res?.message || '已切换前端主题'
setMessage(msg)
toast.success(msg)
refetchWebui()
qc.invalidateQueries({ queryKey: ['siteSettings'] })
},
onError: (e: any) => {
setMessage(e?.message || '切换失败')
toast.error(e?.message || '切换失败')
},
})
const deleteWebui = useMutation({
mutationFn: (name: string) => api.webui.delete(name),
onSuccess: (res) => {
const msg = res?.message || '已删除 WebUI'
setMessage(msg)
toast.success(msg)
refetchWebui()
},
onError: (e: any) => {
setMessage(e?.message || '删除失败')
toast.error(e?.message || '删除失败')
},
})
async function restoreBackup(file?: File | null) {
if (!file) return
try {
await api.settings.restoreBackup(file)
setMessage('恢复成功,建议刷新页面')
toast.success('恢复成功,建议刷新页面')
} catch (e: any) {
setMessage(e?.message || '恢复失败')
toast.error(e?.message || '恢复失败')
}
}
const logs = Array.isArray(loginLogs?.data) ? loginLogs.data : []
const webuiList: WebUI[] = Array.isArray(webuis) ? webuis : []
const activeWebui = site?.active_webui || ''
return (
<div className="max-w-4xl space-y-4">
<PageHeader title="系统设置" description="站点、调度、安全、前端定制与备份" />
{message ? <div className="rounded-md bg-blue-500/10 px-3 py-2 text-sm text-blue-400">{message}</div> : null}
<Tabs
value={tab}
onValueChange={setTab}
items={[
{ value: 'site', label: '站点' },
{ value: 'webui', label: '前端定制' },
{ value: 'scheduler', label: '调度' },
{ value: 'security', label: '安全' },
{ value: 'backup', label: '备份' },
{ value: 'about', label: '关于' },
]}
/>
{tab === 'site' ? (
<div className="space-y-4">
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<div className="flex gap-2">
<Button variant={theme === 'light' ? 'default' : 'secondary'} onClick={() => setTheme('light')}>
</Button>
<Button variant={theme === 'dark' ? 'default' : 'secondary'} onClick={() => setTheme('dark')}>
</Button>
</div>
</Card>
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
<div className="grid gap-3 md:grid-cols-2">
<Input placeholder="分页大小" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
</div>
<label className="flex items-center justify-between text-sm">
<span> OpenAPI</span>
<Switch checked={siteForm.openapi_enabled} onCheckedChange={(v) => setSiteForm((f) => ({ ...f, openapi_enabled: v }))} />
</label>
<div className="flex flex-wrap gap-2">
<Input className="min-w-[220px] flex-1 font-mono text-xs" readOnly value={siteForm.openapi_token} placeholder="OpenAPI Token" />
<Button variant="secondary" onClick={() => genToken.mutate()} disabled={genToken.isPending}>
Token
</Button>
</div>
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]"></h3>
<div className="grid gap-3 md:grid-cols-2">
<Input placeholder="系统通知保留天" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
</div>
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
</Button>
</Card>
</div>
) : null}
{tab === 'webui' ? (
<Card className="space-y-4 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-medium"> (WebUI)</h2>
<p className="mt-1 text-sm text-[var(--text-muted)]"></p>
<a
href="https://engigu.github.io/taskpool/guide/webui"
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-500 hover:underline"
>
<ExternalLink size={12} />
</a>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => refetchWebui()} disabled={webuiFetching}>
<RefreshCw size={14} className={webuiFetching ? 'animate-spin' : ''} />
</Button>
<Button onClick={() => webuiInputRef.current?.click()} disabled={uploadWebui.isPending}>
<UploadCloud size={14} />
{uploadWebui.isPending ? '上传中...' : '上传资源包'}
</Button>
<input
ref={webuiInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) uploadWebui.mutate(file)
e.target.value = ''
}}
/>
</div>
</div>
{webuiList.length === 0 ? (
<div className="rounded-md border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--text-muted)]">
zip
</div>
) : (
<div className="space-y-2">
{webuiList.map((item) => {
const active = activeWebui === item.name
return (
<div key={item.name} className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--border)] p-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{item.name}</span>
{active ? (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-500"></span>
) : null}
</div>
<div className="mt-1 text-xs text-[var(--text-muted)]">
v{item.version || '-'} · {item.author || '未知作者'}
{item.min_panel_version ? ` · 最低面板 ${item.min_panel_version}` : ''}
</div>
{item.description ? <div className="mt-1 text-sm text-[var(--text-secondary)]">{item.description}</div> : null}
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={active || activateWebui.isPending} onClick={() => activateWebui.mutate(item.name)}>
{active ? '使用中' : '启用'}
</Button>
<Button
size="sm"
variant="danger"
disabled={deleteWebui.isPending}
onClick={() => {
if (confirm(`确认删除前端包 ${item.name}`)) deleteWebui.mutate(item.name)
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
)
})}
</div>
)}
</Card>
) : null}
{tab === 'scheduler' ? (
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<div className="grid gap-3 md:grid-cols-3">
<Input placeholder="Worker 数" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
</div>
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>
</Button>
</Card>
) : null}
{tab === 'security' ? (
<div className="space-y-4">
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<Input placeholder="新用户名(可选)" value={passwordForm.username} onChange={(e) => setPasswordForm((f) => ({ ...f, username: e.target.value }))} />
<Input type="password" placeholder="旧密码" value={passwordForm.old_password} onChange={(e) => setPasswordForm((f) => ({ ...f, old_password: e.target.value }))} />
<Input type="password" placeholder="新密码(可选)" value={passwordForm.new_password} onChange={(e) => setPasswordForm((f) => ({ ...f, new_password: e.target.value }))} />
<Button onClick={() => changePassword.mutate()} disabled={!passwordForm.old_password || changePassword.isPending}>
</Button>
</Card>
<Card className="overflow-hidden">
<div className="border-b border-[var(--border)] px-4 py-3 text-sm font-medium"></div>
{logs.length === 0 ? (
<div className="p-4 text-sm text-[var(--text-muted)]"></div>
) : (
<div className="divide-y divide-[var(--border)]">
{logs.map((log) => (
<div key={log.id} className="px-4 py-3 text-sm">
<div className="flex flex-wrap gap-2">
<span className="font-medium">{log.username}</span>
<span className="text-[var(--text-muted)]">{log.ip}</span>
<span className="text-xs text-[var(--text-muted)]">{log.status}</span>
</div>
<div className="mt-1 text-xs text-[var(--text-muted)]">
{log.message} · {log.created_at}
</div>
</div>
))}
</div>
)}
</Card>
</div>
) : null}
{tab === 'backup' ? (
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<div className="text-sm text-[var(--text-muted)]">
{backupStatus?.has_backup ? `最近备份: ${backupStatus.backup_time}` : '暂无备份'}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={() => createBackup.mutate()} disabled={createBackup.isPending}>
<RefreshCw size={14} />
</Button>
<a href={api.settings.downloadBackup()} className="inline-flex">
<Button variant="secondary">
<Download size={14} />
</Button>
</a>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2 text-sm hover:bg-[var(--bg-hover)]">
<Upload size={14} />
<input type="file" className="hidden" onChange={(e) => restoreBackup(e.target.files?.[0])} />
</label>
</div>
</Card>
) : null}
{tab === 'about' ? (
<div className="space-y-4">
<Card className="space-y-2 p-4 text-sm">
<h2 className="font-medium"></h2>
<div className="text-[var(--text-muted)]">: {about?.version || '-'}</div>
<div className="text-[var(--text-muted)]">: {about?.remote_version || '-'}</div>
<div className="text-[var(--text-muted)]">: {about?.build_time || '-'}</div>
<div className="text-[var(--text-muted)]">: {about?.uptime || '-'}</div>
<div className="text-[var(--text-muted)]">: {about?.mem_usage || '-'}</div>
<div className="text-[var(--text-muted)]">
//: {about?.task_count ?? '-'} / {about?.log_count ?? '-'} / {about?.env_count ?? '-'}
</div>
</Card>
<Card className="p-4">
<h2 className="mb-2 font-medium"></h2>
<Textarea className="min-h-[240px] font-mono text-xs" readOnly value={changelog || '暂无'} />
</Card>
</div>
) : null}
</div>
)
}