feat(admin): 新增备份管理页
This commit is contained in:
@@ -276,6 +276,11 @@ const AdminStaffAccountsPage = lazy(() =>
|
||||
default: module.AdminStaffAccountsPage,
|
||||
}))
|
||||
)
|
||||
const AdminBackupsPage = lazy(() =>
|
||||
import('@/features/admin/AdminBackupsPage').then(module => ({
|
||||
default: module.AdminBackupsPage,
|
||||
}))
|
||||
)
|
||||
const AdminManualEntriesPage = lazy(() =>
|
||||
import('@/features/admin/AdminManualEntriesPage').then(module => ({
|
||||
default: module.AdminManualEntriesPage,
|
||||
@@ -489,6 +494,7 @@ export function AppRouter() {
|
||||
<Route path="payment-configs" element={<AdminPaymentConfigsPage />} />
|
||||
<Route path="roles" element={<AdminRolesPage />} />
|
||||
<Route path="admin-users" element={<AdminStaffAccountsPage />} />
|
||||
<Route path="backups" element={<AdminBackupsPage />} />
|
||||
<Route path="manual-entries" element={<AdminManualEntriesPage />} />
|
||||
<Route path="payments" element={<AdminPaymentsPage />} />
|
||||
<Route path="pickup" element={<AdminPickupsPage />} />
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
InputNumber,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
fetchAdminBackupSchedule,
|
||||
fetchAdminBackupStatus,
|
||||
updateAdminBackupSchedule,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import type { AdminBackupSchedule } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatText } from '@/shared/utils/format'
|
||||
|
||||
function errorText(error: unknown) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })?.response
|
||||
return response?.data?.message || (error instanceof Error ? error.message : '请求失败')
|
||||
}
|
||||
|
||||
export function AdminBackupsPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<AdminBackupSchedule>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('backup:view'))
|
||||
const canUpdate = Boolean(
|
||||
permissions?.includes('*') || permissions?.includes('backup:schedule:update')
|
||||
)
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['admin-backup-status'],
|
||||
queryFn: fetchAdminBackupStatus,
|
||||
enabled: canView,
|
||||
retry: false,
|
||||
})
|
||||
const scheduleQuery = useQuery({
|
||||
queryKey: ['admin-backup-schedule'],
|
||||
queryFn: fetchAdminBackupSchedule,
|
||||
enabled: canView,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!scheduleQuery.data) return
|
||||
form.setFieldsValue(scheduleQuery.data)
|
||||
}, [scheduleQuery.data, form])
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 系统" title="备份管理" />
|
||||
<Alert type="warning" showIcon message="当前账号没有备份查看权限(backup:view)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusQuery.data
|
||||
const statusUnavailable =
|
||||
statusQuery.isError &&
|
||||
/尚未生成|不可用/.test(
|
||||
String((statusQuery.error as { response?: { data?: { message?: string } } })?.response?.data?.message ?? '')
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 系统"
|
||||
title="备份管理"
|
||||
description="查看数据库全量/增量备份状态,并维护定时备份计划。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
{status?.health ? (
|
||||
<Tag color={status.health === 'ok' ? 'green' : 'red'}>健康状态 {status.health}</Tag>
|
||||
) : (
|
||||
<Tag>状态未生成</Tag>
|
||||
)}
|
||||
<Tag color="geekblue">计划 {scheduleQuery.data?.enabled ? '启用' : '停用'}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={statusQuery.isFetching || scheduleQuery.isFetching}
|
||||
onClick={() => {
|
||||
void statusQuery.refetch()
|
||||
void scheduleQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{statusUnavailable ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="备份状态尚未生成"
|
||||
description="备份监控脚本尚未在服务器写入状态文件;配置好定时任务后会出现在这里。"
|
||||
/>
|
||||
) : statusQuery.isError ? (
|
||||
<Alert type="error" showIcon message="备份状态读取失败" description={errorText(statusQuery.error)} />
|
||||
) : null}
|
||||
|
||||
<Card title="最近备份" className="admin-table-card">
|
||||
<Descriptions column={2} size="middle" bordered>
|
||||
<Descriptions.Item label="状态生成时间">{formatText(status?.generated_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="健康状态">{formatText(status?.health)}</Descriptions.Item>
|
||||
<Descriptions.Item label="最近全量(本地)">
|
||||
{formatText(status?.last_full?.local_complete)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近全量(远端)">
|
||||
{formatText(status?.last_full?.remote_complete)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近 binlog 文件">
|
||||
{formatText(status?.last_binlog?.file)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近 binlog 远端目录">
|
||||
{formatText(status?.last_binlog?.remote_dir)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近任务" span={2}>
|
||||
{formatText(status?.last_job?.type)} · {formatText(status?.last_job?.status)} ·{' '}
|
||||
{formatText(status?.last_job?.at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务信息" span={2}>
|
||||
{formatText(status?.last_job?.message)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="定时备份计划" className="admin-table-card" style={{ marginTop: 16 }}>
|
||||
<Form form={form} layout="inline" style={{ gap: 12, flexWrap: 'wrap' }}>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_hour" label="全量小时">
|
||||
<InputNumber min={0} max={23} style={{ width: 90 }} disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_minute" label="全量分钟">
|
||||
<InputNumber min={0} max={59} style={{ width: 90 }} disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
<Form.Item name="binlog_interval_minutes" label="binlog 间隔(分钟)">
|
||||
<InputNumber min={1} max={1440} style={{ width: 120 }} disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
disabled={!canUpdate}
|
||||
onClick={async () => {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateAdminBackupSchedule(values)
|
||||
message.success('备份计划已更新')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-backup-schedule'] })
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存计划
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '10px 0 0' }}>
|
||||
全量备份每天在指定时间执行;binlog 按间隔增量归档到远端。修改后需等待备份脚本下次读取配置。
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -127,6 +127,8 @@ import type {
|
||||
AdminInvitationRelationPage,
|
||||
AdminInviteRebatePage,
|
||||
AdminInviteRebateSummary,
|
||||
AdminBackupStatus,
|
||||
AdminBackupSchedule,
|
||||
ManualDisbursementPayload,
|
||||
OperatingExpensePage,
|
||||
OperatingExpensePayload,
|
||||
@@ -1878,3 +1880,23 @@ export async function fetchAdminInviteRebateSummary(query: {
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminBackupStatus() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminBackupStatus>>('/admin/backups/status')
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminBackupSchedule() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminBackupSchedule>>(
|
||||
'/admin/backups/schedule'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAdminBackupSchedule(payload: AdminBackupSchedule) {
|
||||
const { data } = await adminClient.put<ApiResponse<AdminBackupSchedule>>(
|
||||
'/admin/backups/schedule',
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -2039,3 +2039,24 @@ export interface AdminTrackingOverview {
|
||||
|
||||
|
||||
|
||||
|
||||
export interface AdminBackupStatus {
|
||||
generated_at: string
|
||||
health: string
|
||||
schedule: {
|
||||
enabled: string
|
||||
full_hour: string
|
||||
full_minute: string
|
||||
binlog_interval_minutes: string
|
||||
}
|
||||
last_full: { local_complete: string; remote_complete: string }
|
||||
last_binlog: { file: string; remote_dir: string }
|
||||
last_job: { type: string; status: string; at: string; message: string }
|
||||
}
|
||||
|
||||
export interface AdminBackupSchedule {
|
||||
enabled: boolean
|
||||
full_hour: number
|
||||
full_minute: number
|
||||
binlog_interval_minutes: number
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ const allNavGroups: NavGroup[] = [
|
||||
{ key: '/roles', label: '角色权限', permission: 'role:manage' },
|
||||
{ key: '/admin-users', label: '管理员账号', permission: 'admin_user:manage' },
|
||||
{ key: '/payment-configs', label: '支付配置', permission: 'payment_config:list' },
|
||||
{ key: '/backups', label: '备份管理', permission: 'backup:view' },
|
||||
{ key: '/audit-logs', label: '审计日志', permission: 'audit_log:view' },
|
||||
],
|
||||
},
|
||||
@@ -231,6 +232,7 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
|
||||
},
|
||||
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
|
||||
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
|
||||
'/backups': { title: '备份管理', description: '查看备份健康状态并维护定时备份计划。' },
|
||||
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user