优化: 多模块功能修复与体验改进 (#34)

1. 保留策略清理后自动删除空文件夹(新增 StorageDirCleaner 接口)
2. 备份任务删除时清理远端文件但保留备份记录
3. 节点管理修复:本机 IP/版本检测、Heartbeat OS/Arch 修正、新增编辑功能
4. 审计日志规范化:统一格式、丰富详情、节点操作增加审计记录
5. 系统设置移除一键更新操作,仅保留版本检查
6. Rclone 配置项分层展示(必填 + 高级可选折叠)
7. DirectoryPicker 目录选择器样式优化
This commit is contained in:
Wu Qing
2026-04-05 11:23:46 +08:00
committed by GitHub
parent 33682b5ea7
commit b336bebdb1
21 changed files with 461 additions and 207 deletions

View File

@@ -1,5 +1,5 @@
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography } from '@arco-design/web-react'
import { IconFolder, IconFile } from '@arco-design/web-react/icon'
import { Button, Input, Message, Modal, Space, Spin, Tree, Typography, Empty } from '@arco-design/web-react'
import { IconFolder, IconFile, IconFolderAdd } from '@arco-design/web-react/icon'
import { useCallback, useState } from 'react'
import { listNodeDirectory } from '../../services/nodes'
import type { DirEntry } from '../../types/nodes'
@@ -27,7 +27,7 @@ function entriesToTreeNodes(entries: DirEntry[], mode: 'directory' | 'file'): Tr
.map((entry) => ({
key: entry.path,
title: entry.name,
icon: entry.isDir ? <IconFolder /> : <IconFile />,
icon: entry.isDir ? <IconFolder style={{ color: 'var(--color-warning-6)' }} /> : <IconFile />,
isLeaf: !entry.isDir,
}))
}
@@ -94,46 +94,83 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director
setModalVisible(false)
}
function handleInputKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
const trimmed = value?.trim()
if (trimmed) {
onChange(trimmed)
}
}
}
// 没有 nodeId 时退化为普通输入框
if (nodeId === undefined) {
return <Input value={value} placeholder={placeholder} onChange={onChange} />
return <Input value={value} placeholder={placeholder} onChange={onChange} onKeyDown={handleInputKeyDown} />
}
return (
<>
<Space style={{ width: '100%' }}>
<Input style={{ flex: 1 }} value={value} placeholder={placeholder} onChange={onChange} />
<Button type="outline" size="small" onClick={handleOpen}>
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Input
style={{ flex: 1 }}
value={value}
placeholder={placeholder}
onChange={onChange}
onKeyDown={handleInputKeyDown}
allowClear
/>
<Button type="outline" size="default" onClick={handleOpen} icon={<IconFolderAdd />}>
</Button>
</Space>
</div>
<Modal
title={mode === 'directory' ? '选择目录' : '选择文件'}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleConfirm}
okText="选择"
okText="确认选择"
cancelText="取消"
style={{ width: 560 }}
style={{ width: 640 }}
okButtonProps={{ disabled: !selectedPath }}
unmountOnExit
>
{selectedPath && (
<div style={{ padding: '8px 12px', marginBottom: 12, background: 'var(--color-fill-2)', borderRadius: 4 }}>
<Typography.Text copyable style={{ fontSize: 13 }}>
{/* 当前选中路径 */}
<div style={{
padding: '10px 14px',
marginBottom: 16,
background: selectedPath ? 'var(--color-primary-light-1)' : 'var(--color-fill-2)',
borderRadius: 6,
border: selectedPath ? '1px solid var(--color-primary-light-3)' : '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: 8,
minHeight: 40,
}}>
<IconFolder style={{ color: selectedPath ? 'var(--color-primary-6)' : 'var(--color-text-4)', fontSize: 16, flexShrink: 0 }} />
{selectedPath ? (
<Typography.Text copyable style={{ fontSize: 13, fontFamily: 'monospace', wordBreak: 'break-all' }}>
{selectedPath}
</Typography.Text>
</div>
)}
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}></Typography.Text>
)}
</div>
{/* 目录树 */}
{loading ? (
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
<Spin style={{ display: 'block', textAlign: 'center', padding: 48 }} tip="加载目录中..." />
) : treeData.length === 0 ? (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 40 }}>
</Typography.Text>
<Empty style={{ padding: 48 }} description="目录为空" />
) : (
<div style={{ maxHeight: 420, overflow: 'auto', border: '1px solid var(--color-border)', borderRadius: 4, padding: '4px 0' }}>
<div style={{
maxHeight: 400,
overflow: 'auto',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '6px 0',
}}>
<Tree
blockNode
showLine

View File

@@ -1,8 +1,8 @@
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { Alert, Button, Collapse, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, buildAllTypeOptions } from './field-config'
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
import { listRcloneBackends, type RcloneBackendInfo, type RcloneBackendOption } from '../../services/rclone'
interface StorageTargetFormDrawerProps {
visible: boolean
@@ -138,8 +138,28 @@ export function StorageTargetFormDrawer({
})
}
// 渲染动态字段rclone 后端)
// 渲染单个动态字段
function renderDynamicOption(opt: RcloneBackendOption) {
return (
<div key={opt.key}>
<Typography.Text>{opt.key}{opt.required ? ' *' : ''}</Typography.Text>
{opt.isPassword ? (
<Input.Password value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
) : (
<Input value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
)}
{opt.label && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 2, fontSize: 12 }} ellipsis={{ rows: 2, expandable: true }}>{opt.label}</Typography.Paragraph>
)}
</div>
)
}
// 渲染动态字段rclone 后端)— 必填优先,可选折叠
function renderDynamicFields() {
const requiredOptions = dynamicBackend?.options.filter((opt) => opt.required) ?? []
const optionalOptions = dynamicBackend?.options.filter((opt) => !opt.required) ?? []
return (
<>
<div>
@@ -147,19 +167,19 @@ export function StorageTargetFormDrawer({
<Input value={(draft.config.root as string) || ''} placeholder="如 /backups 或 bucket 名" onChange={(v) => updateConfig('root', v)} />
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>使</Typography.Paragraph>
</div>
{dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.options.map((opt) => (
<div key={opt.key}>
<Typography.Text>{opt.key}{opt.required ? ' *' : ''}</Typography.Text>
{opt.isPassword ? (
<Input.Password value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
) : (
<Input value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
)}
{opt.label && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 2, fontSize: 12 }} ellipsis={{ rows: 2, expandable: true }}>{opt.label}</Typography.Paragraph>
)}
</div>
))}
{requiredOptions.map(renderDynamicOption)}
{optionalOptions.length > 0 && (
<Collapse bordered={false} style={{ background: 'transparent' }}>
<Collapse.Item
header={<Typography.Text type="secondary">{optionalOptions.length} </Typography.Text>}
name="advanced"
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{optionalOptions.map(renderDynamicOption)}
</Space>
</Collapse.Item>
</Collapse>
)}
</>
)
}

View File

@@ -3,10 +3,10 @@ import {
Table, Button, Space, Tag, Typography, PageHeader, Modal, Input, Message, Badge, Popconfirm, Card, Descriptions, Empty
} from '@arco-design/web-react'
import {
IconPlus, IconDelete, IconDesktop, IconCloudDownload
IconPlus, IconDelete, IconDesktop, IconCloudDownload, IconEdit
} from '@arco-design/web-react/icon'
import type { NodeSummary } from '../../types/nodes'
import { listNodes, createNode, deleteNode } from '../../services/nodes'
import { listNodes, createNode, deleteNode, updateNode } from '../../services/nodes'
const { Title, Text } = Typography
@@ -17,6 +17,11 @@ export default function NodesPage() {
const [newNodeName, setNewNodeName] = useState('')
const [newToken, setNewToken] = useState('')
// 编辑状态
const [editVisible, setEditVisible] = useState(false)
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
const [editName, setEditName] = useState('')
const fetchNodes = useCallback(async () => {
setLoading(true)
try {
@@ -56,6 +61,21 @@ export default function NodesPage() {
}
}
const handleEdit = async () => {
if (!editNode || !editName.trim()) {
Message.warning('请输入节点名称')
return
}
try {
await updateNode(editNode.id, { name: editName.trim() })
Message.success('节点更新成功')
setEditVisible(false)
fetchNodes()
} catch {
Message.error('更新节点失败')
}
}
const columns = [
{
title: '节点名称',
@@ -110,15 +130,22 @@ export default function NodesPage() {
},
{
title: '操作',
width: 80,
render: (_: unknown, record: NodeSummary) => {
if (record.isLocal) return <Text type="secondary">-</Text>
return (
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
</Popconfirm>
)
},
width: 120,
render: (_: unknown, record: NodeSummary) => (
<Space>
<Button
type="text"
icon={<IconEdit />}
size="small"
onClick={() => { setEditNode(record); setEditName(record.name); setEditVisible(true) }}
/>
{!record.isLocal && (
<Popconfirm title="确定删除该节点?" onOk={() => handleDelete(record.id)}>
<Button type="text" status="danger" icon={<IconDelete />} size="small" />
</Popconfirm>
)}
</Space>
),
},
]
@@ -145,6 +172,7 @@ export default function NodesPage() {
/>
</Card>
{/* 添加节点弹窗 */}
<Modal
title="添加远程节点"
visible={createVisible}
@@ -175,6 +203,25 @@ export default function NodesPage() {
</div>
)}
</Modal>
{/* 编辑节点弹窗 */}
<Modal
title="编辑节点"
visible={editVisible}
onCancel={() => setEditVisible(false)}
onOk={handleEdit}
okText="保存"
cancelText="取消"
>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
</div>
<Input
placeholder="输入节点名称"
value={editName}
onChange={setEditName}
/>
</Modal>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { Badge, Button, Card, Descriptions, Grid, Link, Message, PageHeader, Space, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useState } from 'react'
import { fetchSystemInfo, checkUpdate, applyUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system'
import { fetchSystemInfo, checkUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system'
import { resolveErrorMessage } from '../../utils/error'
import { formatDuration } from '../../utils/format'
@@ -24,7 +24,6 @@ export function SettingsPage() {
const [error, setError] = useState('')
const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null)
const [checking, setChecking] = useState(false)
const [applying, setApplying] = useState(false)
useEffect(() => {
let active = true
@@ -53,24 +52,6 @@ export function SettingsPage() {
}
}
async function handleApplyUpdate() {
if (!updateResult?.latestVersion) return
setApplying(true)
try {
const result = await applyUpdate(updateResult.latestVersion)
if (result.success) {
Message.success('更新已触发,容器即将自动重启...')
setTimeout(() => Message.info('请等待 10-30 秒后刷新页面'), 3000)
} else {
Message.warning(result.message)
}
} catch (e) {
Message.error(resolveErrorMessage(e, '触发更新失败'))
} finally {
setApplying(false)
}
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<PageHeader style={{ paddingBottom: 16 }} title="系统设置" subTitle="运行信息、磁盘状态与版本更新">
@@ -124,9 +105,6 @@ export function SettingsPage() {
</Card>
)}
<Space>
<Button type="primary" status="success" loading={applying} onClick={handleApplyUpdate}>
Docker
</Button>
{updateResult.downloadUrl && (
<Link href={updateResult.downloadUrl} target="_blank">
<Button type="outline"></Button>
@@ -138,13 +116,6 @@ export function SettingsPage() {
</Link>
)}
</Space>
{updateResult.dockerImage && (
<Card size="small" title="Docker 更新命令">
<Typography.Paragraph copyable code style={{ marginBottom: 0 }}>
{`docker pull ${updateResult.dockerImage}:${updateResult.latestVersion} && docker compose up -d`}
</Typography.Paragraph>
</Card>
)}
</Space>
) : (
<Space>

View File

@@ -16,6 +16,11 @@ export async function createNode(name: string) {
return unwrapApiEnvelope(response.data)
}
export async function updateNode(id: number, data: { name: string }) {
const response = await http.put<ApiEnvelope<NodeSummary>>(`/nodes/${id}`, data)
return unwrapApiEnvelope(response.data)
}
export async function deleteNode(id: number) {
const response = await http.delete<ApiEnvelope<null>>(`/nodes/${id}`)
return unwrapApiEnvelope(response.data)

View File

@@ -33,17 +33,6 @@ export async function checkUpdate() {
return response.data.data
}
export interface UpdateApplyResult {
success: boolean
message: string
output?: string
}
export async function applyUpdate(version: string) {
const response = await http.post<{ code: string; message: string; data: UpdateApplyResult }>('/system/update-apply', { version })
return response.data.data
}
export async function fetchSettings() {
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
return response.data.data