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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user