Files
TaskPool/web/src/pages/Settings.tsx
T
admin 6709c4ed77
Build and Deploy / build-and-push (push) Successful in 53s
feat(ui): improve settings form labels and descriptions for clarity
2026-07-27 02:22:35 +08:00

549 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">分页大小</label>
<Input placeholder="20" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">登录 Cookie 有效天数</label>
<Input placeholder="7" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
</div>
</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>
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] p-3 text-xs leading-5 text-[var(--text-secondary)]">
<div className="mb-1 font-medium text-[var(--text-primary)]">MCP ServerHermes / OpenClaw</div>
<p className="mb-2">
OpenAPI REST 接口;AI Agent 请使用 <code className="rounded bg-[var(--bg-tertiary)] px-1">taskpool mcp</code> 作为 MCP
Server 完整接管任务/脚本/日志。
</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"],
"env": {
"TASKPOOL_URL": "${window.location.origin}",
"TASKPOOL_TOKEN": "${siteForm.openapi_token || '在设置页生成 OpenAPI Token'}"
}
}
}
}`}</pre>
<p className="mt-2 text-[var(--text-muted)]">详细说明见仓库 docs/guide/mcp.md skills/taskpool/SKILL.md</p>
</div>
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]">日志保留</h3>
<p className="text-xs text-[var(--text-muted)]">设置各类日志的保留天数和最大条数限制,超出后自动清理最旧记录</p>
<div className="space-y-3">
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-3">
<div className="mb-2 text-sm font-medium text-[var(--text-primary)]">系统通知</div>
<p className="mb-2 text-xs text-[var(--text-muted)]">系统级事件通知,如任务失败报警、服务状态变更等</p>
<div className="grid gap-2 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 }))} />
</div>
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-3">
<div className="mb-2 text-sm font-medium text-[var(--text-primary)]">推送日志</div>
<p className="mb-2 text-xs text-[var(--text-muted)]">消息推送记录,如钉钉、企业微信、Telegram 等渠道的发送历史</p>
<div className="grid gap-2 md:grid-cols-2">
<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 }))} />
</div>
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-3">
<div className="mb-2 text-sm font-medium text-[var(--text-primary)]">登录日志</div>
<p className="mb-2 text-xs text-[var(--text-muted)]">用户登录记录,包含 IP、时间、状态等安全审计信息</p>
<div className="grid gap-2 md:grid-cols-2">
<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 }))} />
</div>
</div>
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-3">
<div className="mb-2 text-sm font-medium text-[var(--text-primary)]">调度日志</div>
<p className="mb-2 text-xs text-[var(--text-muted)]">任务执行日志,包含命令输出、执行状态、耗时等信息</p>
<div className="grid gap-2 md:grid-cols-2">
<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>
</div>
</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>
<p className="text-xs text-[var(--text-muted)]">控制任务调度器的并发能力和队列行为</p>
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">Worker 数量</label>
<Input placeholder="4" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]">并发执行任务的最大数量</p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">队列大小</label>
<Input placeholder="100" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]">等待执行的任务队列容量</p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">限速间隔(毫秒)</label>
<Input placeholder="0" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]">任务启动间隔,0 表示不限速</p>
</div>
</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>
)
}