Files
BackupX/web/src/layouts/AppLayout.tsx
Awuqing 09698cc767 feat: add community enhancements — password reset, audit logs, multi-source backup
Three community-requested features:

1. CLI password reset: `backupx reset-password --username admin --password xxx`
   Docker users can run via `docker exec`. No full app init needed.

2. Audit logging: async fire-and-forget audit trail for all key operations
   (login, CRUD on tasks/targets/records, settings changes).
   New UI page at /audit with category filter and pagination.

3. Multi-source path backup: file backup tasks now support multiple source
   directories packed into a single tar archive. Backward compatible with
   existing single sourcePath field.
2026-03-30 23:04:37 +08:00

176 lines
6.4 KiB
TypeScript

import { Avatar, Button, Dropdown, Layout, Menu, Message, Modal, Form, Input, Space, Typography } from '@arco-design/web-react'
import {
IconDashboard,
IconStorage,
IconFile,
IconHistory,
IconNotification,
IconSettings,
IconMenuFold,
IconMenuUnfold,
IconLock,
IconPoweroff,
IconDown,
IconCloud,
IconDesktop,
IconList,
} from '@arco-design/web-react/icon'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { changePassword, type ChangePasswordPayload } from '../services/auth'
import { useAuthStore } from '../stores/auth'
import { resolveErrorMessage } from '../utils/error'
const Header = Layout.Header
const Sider = Layout.Sider
const Content = Layout.Content
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/backup/tasks')) {
return '/backup/tasks'
}
if (pathname.startsWith('/backup/records')) {
return '/backup/records'
}
if (pathname.startsWith('/storage-targets')) {
return '/storage-targets'
}
if (pathname.startsWith('/settings/notifications')) {
return '/settings/notifications'
}
if (pathname.startsWith('/audit')) {
return '/audit'
}
if (pathname.startsWith('/nodes')) {
return '/nodes'
}
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
return '/settings'
}
return pathname
}
const menuItems = [
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
{ key: '/audit', label: '审计日志', icon: <IconList /> },
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
]
export function AppLayout() {
const [collapsed, setCollapsed] = useState(false)
const [pwdVisible, setPwdVisible] = useState(false)
const [pwdLoading, setPwdLoading] = useState(false)
const [pwdForm] = Form.useForm<ChangePasswordPayload & { confirmPassword: string }>()
const location = useLocation()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
async function handleChangePassword() {
try {
const values = await pwdForm.validate()
if (values.newPassword !== values.confirmPassword) {
Message.error('两次输入的新密码不一致')
return
}
setPwdLoading(true)
await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword })
Message.success('密码修改成功')
setPwdVisible(false)
pwdForm.resetFields()
} catch (err) {
if (err) {
Message.error(resolveErrorMessage(err, '密码修改失败'))
}
} finally {
setPwdLoading(false)
}
}
const userDroplist = (
<Menu onClickMenuItem={(key) => {
if (key === 'password') {
setPwdVisible(true)
} else if (key === 'logout') {
logout()
}
}}>
<Menu.Item key="password"><IconLock style={{ marginRight: 8 }} /></Menu.Item>
<Menu.Item key="logout"><IconPoweroff style={{ marginRight: 8 }} />退</Menu.Item>
</Menu>
)
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible collapsed={collapsed} trigger={null} breakpoint="lg" width={220}>
<div style={{ padding: '20px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
<IconCloud style={{ fontSize: 28, color: 'var(--color-primary-6)' }} />
{!collapsed && <Typography.Title heading={5} style={{ margin: 0, fontWeight: 700 }}>BackupX</Typography.Title>}
</div>
<Menu selectedKeys={[resolveSelectedKey(location.pathname)]} onClickMenuItem={(key) => navigate(key)}>
{menuItems.map((item) => (
<Menu.Item key={item.key}>
{item.icon}
{item.label}
</Menu.Item>
))}
</Menu>
{!collapsed && (
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, textAlign: 'center' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>v1.0.0</Typography.Text>
</div>
)}
</Sider>
<Layout>
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 20px', background: 'var(--color-bg-2)', borderBottom: '1px solid var(--color-border)' }}>
<Button
type="text"
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
onClick={() => setCollapsed((value) => !value)}
/>
<Space>
<Dropdown droplist={userDroplist} position="br">
<Button type="text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Avatar size={28} style={{ backgroundColor: 'var(--color-primary-6)' }}>
{(user?.displayName ?? user?.username ?? '管')[0]}
</Avatar>
<span>{user?.displayName ?? user?.username ?? '管理员'}</span>
<IconDown />
</Button>
</Dropdown>
</Space>
</Header>
<Content style={{ padding: '24px', background: 'var(--color-fill-2)', overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
<Modal
title="修改密码"
visible={pwdVisible}
onCancel={() => { setPwdVisible(false); pwdForm.resetFields() }}
onOk={handleChangePassword}
confirmLoading={pwdLoading}
unmountOnExit
>
<Form form={pwdForm} layout="vertical">
<Form.Item field="oldPassword" label="当前密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item field="newPassword" label="新密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请输入新密码(至少 8 位)" />
</Form.Item>
<Form.Item field="confirmPassword" label="确认新密码" rules={[{ required: true, minLength: 8 }]}>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
</Form>
</Modal>
</Layout>
)
}