549 lines
25 KiB
TypeScript
549 lines
25 KiB
TypeScript
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 Server(Hermes / 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>
|
||
)
|
||
}
|