Compare commits

..

6 Commits

Author SHA1 Message Date
Awuqing
eeec7678a1 优化: 重新设计 Cron 编辑器交互体验
核心问题:预设选中后下方 Tab 编辑器仍展开显示混乱的技术细节。

重新设计为三层交互:
1. 预设按钮(一键选择常见场景,选中高亮,无多余 UI)
2. 自定义选择器(每天/每周/每月/间隔四种模式,直观的时间选择器
   和星期按钮,无需理解 cron 语法)
3. 手动输入(高级用户直接编辑 cron 表达式)

同时优化中文描述为自然语言("每天 02:00 执行" 替代 "02 时 00 分 执行")
2026-04-01 07:44:19 +08:00
Wu Qing
cefbdf3a53 优化: Cron 表达式编辑器增加预设和中文描述 (#26)
优化: Cron 表达式编辑器增加预设和中文描述
2026-04-01 00:17:38 +08:00
Wu Qing
4a56ad05fc 修复: 审计日志补充操作详情 + 版本号注入修复 (#25)
修复: 审计日志补充操作详情 + 版本号注入修复
2026-04-01 00:17:34 +08:00
Wu Qing
9ea02566cb 修复: 存储目标创建/连接测试/类型选择三个关键问题 (#24)
修复: 存储目标创建/连接测试/类型选择三个关键问题
2026-04-01 00:17:29 +08:00
Awuqing
bfc8728785 修复: 审计日志补充操作详情 + 版本号注入修复
1. 审计日志:所有 handler 的 recordAudit 调用补充有意义的 detail,
   包括创建/更新时记录类型、删除时记录 ID、设置变更时记录修改的 key
2. 版本号:Makefile 的 run/build 都通过 ldflags 注入 git 版本号,
   开发模式不再显示 "dev"
2026-04-01 00:10:51 +08:00
Awuqing
3023a089fb 修复: 存储目标创建/连接测试/类型选择三个关键问题
1. 修复 oneof 白名单仅含 4 种类型,阿里云/腾讯/七牛/FTP/Rclone
   类型的存储目标无法创建(binding 验证直接拒绝)
2. 修复本地磁盘 TestConnection 报 "directory not found",
   在 List 前先 Mkdir 确保目录存在
3. 前端存储类型选项明确标注 Rclone 支持 SFTP/Azure/Dropbox 等
2026-04-01 00:06:08 +08:00
9 changed files with 298 additions and 234 deletions

View File

@@ -1,14 +1,15 @@
APP_NAME=backupx APP_NAME=backupx
BUILD_DIR=./bin BUILD_DIR=./bin
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
.PHONY: build run test .PHONY: build run test
build: build:
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
run: run:
go run ./cmd/backupx go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
test: test:
go test ./... go test ./...

View File

@@ -130,7 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "") recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("恢复备份记录 #%d", id))
response.Success(c, gin.H{"restored": true}) response.Success(c, gin.H{"restored": true})
} }
@@ -143,7 +143,7 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "") recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份记录 #%d", id))
response.Success(c, gin.H{"deleted": true}) response.Success(c, gin.H{"deleted": true})
} }

View File

@@ -51,7 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "") recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item) response.Success(c, item)
} }
@@ -70,7 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "") recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s, Cron: %s", input.Type, input.CronExpr))
response.Success(c, item) response.Success(c, item)
} }
@@ -83,7 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "") recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份任务 #%d", id))
response.Success(c, gin.H{"deleted": true}) response.Success(c, gin.H{"deleted": true})
} }
@@ -115,6 +115,6 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
if !enabled { if !enabled {
action = "disable" action = "disable"
} }
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "") recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, fmt.Sprintf("%s 备份任务", action))
response.Success(c, item) response.Success(c, item)
} }

View File

@@ -1,6 +1,9 @@
package http package http
import ( import (
"fmt"
"strings"
"backupx/server/internal/apperror" "backupx/server/internal/apperror"
"backupx/server/internal/service" "backupx/server/internal/service"
"backupx/server/pkg/response" "backupx/server/pkg/response"
@@ -36,6 +39,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "") keys := make([]string, 0, len(input))
for k := range input {
keys = append(keys, k)
}
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", fmt.Sprintf("修改设置: %s", strings.Join(keys, ", ")))
response.Success(c, settings) response.Success(c, settings)
} }

View File

@@ -65,7 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "") recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item) response.Success(c, item)
} }
@@ -84,7 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "") recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
response.Success(c, item) response.Success(c, item)
} }
@@ -97,7 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
response.Error(c, err) response.Error(c, err)
return return
} }
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "") recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除存储目标 #%d", id))
response.Success(c, gin.H{"deleted": true}) response.Success(c, gin.H{"deleted": true})
} }

View File

@@ -21,7 +21,7 @@ import (
type StorageTargetUpsertInput struct { type StorageTargetUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=128"` Name string `json:"name" binding:"required,min=1,max=128"`
Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav"` Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav aliyun_oss tencent_cos qiniu_kodo ftp rclone"`
Description string `json:"description" binding:"max=255"` Description string `json:"description" binding:"max=255"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Config map[string]any `json:"config" binding:"required"` Config map[string]any `json:"config" binding:"required"`

View File

@@ -26,8 +26,12 @@ func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider {
func (p *Provider) Type() storage.ProviderType { return p.providerType } func (p *Provider) Type() storage.ProviderType { return p.providerType }
// TestConnection 通过列出根目录验证连通性。 // TestConnection 验证连通性。对本地磁盘会先确保目录存在
func (p *Provider) TestConnection(ctx context.Context) error { func (p *Provider) TestConnection(ctx context.Context) error {
// 确保根目录存在(本地磁盘等后端需要预创建)
if err := p.rfs.Mkdir(ctx, ""); err != nil {
return fmt.Errorf("rclone test connection (mkdir): %w", err)
}
_, err := p.rfs.List(ctx, "") _, err := p.rfs.List(ctx, "")
if err != nil { if err != nil {
return fmt.Errorf("rclone test connection: %w", err) return fmt.Errorf("rclone test connection: %w", err)

View File

@@ -1,4 +1,4 @@
import { Button, Input, Space, Switch, Tabs, Typography, Radio, Select } from '@arco-design/web-react' import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
export interface CronInputProps { export interface CronInputProps {
@@ -6,17 +6,7 @@ export interface CronInputProps {
onChange?: (value: string) => void onChange?: (value: string) => void
} }
const DEFAULT_CRON = '* * * * *' const DEFAULT_CRON = '0 2 * * *'
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
interface CronState {
minute: string
hour: string
day: string
month: string
week: string
}
// 常用预设 // 常用预设
const PRESETS = [ const PRESETS = [
@@ -27,249 +17,311 @@ const PRESETS = [
{ label: '每周日 03:00', value: '0 3 * * 0' }, { label: '每周日 03:00', value: '0 3 * * 0' },
{ label: '每月 1 日 02:00', value: '0 2 1 * *' }, { label: '每月 1 日 02:00', value: '0 2 1 * *' },
{ label: '每 30 分钟', value: '*/30 * * * *' }, { label: '每 30 分钟', value: '*/30 * * * *' },
{ label: '每小时', value: '0 * * * *' }, { label: '每小时整点', value: '0 * * * *' },
] ]
function parseCron(expr: string): CronState { const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/) label: `${String(i).padStart(2, '0')}`,
return { value: String(i),
minute: parts[0] || '*', }))
hour: parts[1] || '*',
day: parts[2] || '*',
month: parts[3] || '*',
week: parts[4] || '*',
}
}
function stringifyCron(state: CronState): string { const MINUTE_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}` label: `${String(i * 5).padStart(2, '0')}`,
} value: String(i * 5),
}))
// 将 cron 表达式转为中文可读描述 const WEEKDAY_OPTIONS = [
{ label: '周一', value: '1' },
{ label: '周二', value: '2' },
{ label: '周三', value: '3' },
{ label: '周四', value: '4' },
{ label: '周五', value: '5' },
{ label: '周六', value: '6' },
{ label: '周日', value: '0' },
]
const DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({
label: `${i + 1}`,
value: String(i + 1),
}))
type ScheduleMode = 'daily' | 'weekly' | 'monthly' | 'interval'
// 将 cron 表达式转为自然语言中文描述
function describeCron(expr: string): string { function describeCron(expr: string): string {
const parts = expr.trim().split(/\s+/) const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return '' if (parts.length !== 5) return ''
const [minute, hour, day, month, week] = parts const [minute, hour, day, _month, week] = parts
const segments: string[] = [] // 每 N 分钟
if (minute.includes('/') && hour === '*' && day === '*' && week === '*') {
return `${minute.split('/')[1]} 分钟执行一次`
}
// 每 N 小时
if (minute !== '*' && hour.includes('/') && day === '*' && week === '*') {
return `${hour.split('/')[1]} 小时执行一次(在第 ${minute} 分)`
}
// 每小时
if (minute !== '*' && hour === '*' && day === '*' && week === '*') {
return `每小时的第 ${minute} 分执行`
}
// 月 const hh = hour.padStart(2, '0')
if (month !== '*') segments.push(`${month}`) const mm = minute.padStart(2, '0')
// 日 const time = `${hh}:${mm}`
if (day !== '*') segments.push(`${day}`)
// // 每周某天
if (week !== '*') { if (day === '*' && week !== '*') {
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' } const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
const weekDesc = week.split(',').map((w) => weekNames[w] || w).join('、') const days = week.split(',').map((w) => `${weekNames[w] || w}`).join('、')
segments.push(`星期${weekDesc}`) return `${days} ${time} 执行`
} }
// 小时 // 每月某日
if (hour.includes('/')) { if (day !== '*' && week === '*') {
segments.push(`${hour.split('/')[1]} 小时`) return ` ${day}${time} 执行`
} else if (hour !== '*') {
segments.push(`${hour.padStart(2, '0')}`)
} }
// 分钟 // 每天
if (minute.includes('/')) { if (day === '*' && week === '*' && hour !== '*' && !hour.includes('/')) {
segments.push(`${minute.split('/')[1]} 分钟`) return ` ${time} 执行`
} else if (minute !== '*') {
segments.push(`${minute.padStart(2, '0')}`)
} else if (hour !== '*' && !hour.includes('/')) {
segments.push('00 分')
} }
if (segments.length === 0) return '每分钟执行' return ''
return segments.join(' ') + ' 执行'
} }
function generateOptions(min: number, max: number) {
return Array.from({ length: max - min + 1 }, (_, i) => ({
label: String(i + min),
value: String(i + min),
}))
}
const MINUTES_OPTIONS = generateOptions(0, 59)
const HOURS_OPTIONS = generateOptions(0, 23)
const DAYS_OPTIONS = generateOptions(1, 31)
const MONTHS_OPTIONS = generateOptions(1, 12)
const WEEKS_OPTIONS = [
{ label: '星期日', value: '0' },
{ label: '星期一', value: '1' },
{ label: '星期二', value: '2' },
{ label: '星期三', value: '3' },
{ label: '星期四', value: '4' },
{ label: '星期五', value: '5' },
{ label: '星期六', value: '6' },
]
export function CronInput({ value, onChange }: CronInputProps) { export function CronInput({ value, onChange }: CronInputProps) {
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON) const [cronExpr, setCronExpr] = useState(value || DEFAULT_CRON)
const [isAdvanced, setIsAdvanced] = useState(false) const [isAdvanced, setIsAdvanced] = useState(false)
const [state, setState] = useState<CronState>(parseCron(internalValue)) const [showCustom, setShowCustom] = useState(false)
// Sync prop to internal state // 自定义模式的状态
const [mode, setMode] = useState<ScheduleMode>('daily')
const [customHour, setCustomHour] = useState('2')
const [customMinute, setCustomMinute] = useState('0')
const [customWeekdays, setCustomWeekdays] = useState<string[]>(['0'])
const [customDay, setCustomDay] = useState('1')
const [customInterval, setCustomInterval] = useState('6')
// 从 prop 同步
useEffect(() => { useEffect(() => {
if (value !== undefined && value !== internalValue) { if (value !== undefined && value !== cronExpr) {
setInternalValue(value || DEFAULT_CRON) setCronExpr(value || DEFAULT_CRON)
if (!isAdvanced) {
setState(parseCron(value || DEFAULT_CRON))
}
} }
}, [value, isAdvanced, internalValue]) }, [value])
const description = useMemo(() => describeCron(internalValue), [internalValue]) const description = useMemo(() => describeCron(cronExpr), [cronExpr])
const isPreset = PRESETS.some((p) => p.value === cronExpr)
const notifyChange = (nextValue: string) => { const emit = (expr: string) => {
setInternalValue(nextValue) setCronExpr(expr)
if (onChange) { onChange?.(expr)
onChange(nextValue)
}
} }
const handleStateChange = (part: CronPart, val: string) => { // 从自定义选择器构建 cron
const nextState = { ...state, [part]: val } const buildCustomCron = (
setState(nextState) m: ScheduleMode,
notifyChange(stringifyCron(nextState)) h: string,
} min: string,
weekdays: string[],
const handlePreset = (cronExpr: string) => { day: string,
setInternalValue(cronExpr) interval: string,
setState(parseCron(cronExpr))
if (onChange) onChange(cronExpr)
}
const renderPartTab = (
part: CronPart,
title: string,
options: { label: string; value: string }[],
allowAnyVal = '*',
) => { ) => {
const currentVal = state[part] switch (m) {
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?' case 'daily':
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-') return `${min} ${h} * * *`
case 'weekly':
return `${min} ${h} * * ${weekdays.sort().join(',') || '0'}`
case 'monthly':
return `${min} ${h} ${day} * *`
case 'interval':
return `0 */${interval} * * *`
default:
return DEFAULT_CRON
}
}
const type = isAny ? 'any' : 'specific' const handleCustomChange = (updates: {
const specificValues = isSpecific ? currentVal.split(',') : [] mode?: ScheduleMode
hour?: string
minute?: string
weekdays?: string[]
day?: string
interval?: string
}) => {
const m = updates.mode ?? mode
const h = updates.hour ?? customHour
const min = updates.minute ?? customMinute
const w = updates.weekdays ?? customWeekdays
const d = updates.day ?? customDay
const iv = updates.interval ?? customInterval
return ( if (updates.mode !== undefined) setMode(m)
<div style={{ padding: '16px 0' }}> if (updates.hour !== undefined) setCustomHour(h)
<Radio.Group if (updates.minute !== undefined) setCustomMinute(min)
direction="vertical" if (updates.weekdays !== undefined) setCustomWeekdays(w)
value={type} if (updates.day !== undefined) setCustomDay(d)
onChange={(val) => { if (updates.interval !== undefined) setCustomInterval(iv)
if (val === 'any') {
handleStateChange(part, allowAnyVal)
} else {
handleStateChange(part, options[0].value)
}
}}
>
<Radio value="any">
<Typography.Text> ({allowAnyVal}) - {title}</Typography.Text>
</Radio>
<Radio value="specific">
<Typography.Text>{title}</Typography.Text>
</Radio>
</Radio.Group>
{type === 'specific' && ( emit(buildCustomCron(m, h, min, w, d, iv))
<div style={{ paddingLeft: 24, marginTop: 12 }}>
<Select
mode="multiple"
placeholder={`请选择${title}`}
value={specificValues}
options={options}
onChange={(vals: string[]) => {
if (vals.length === 0) {
handleStateChange(part, allowAnyVal)
} else {
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
handleStateChange(part, sorted.join(','))
}
}}
style={{ width: '100%', maxWidth: 400 }}
allowClear
/>
</div>
)}
</div>
)
} }
return ( return (
<div className="cron-input-container"> <div>
{/* 常用预设 */} {/* 预设按钮 */}
<div style={{ marginBottom: 12 }}> <Space wrap size="small" style={{ marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}></Typography.Text> {PRESETS.map((preset) => (
<Space wrap size="small"> <Button
{PRESETS.map((preset) => ( key={preset.value}
<Button size="small"
key={preset.value} type={cronExpr === preset.value ? 'primary' : 'secondary'}
size="small" onClick={() => {
type={internalValue === preset.value ? 'primary' : 'secondary'} emit(preset.value)
onClick={() => handlePreset(preset.value)} setShowCustom(false)
> setIsAdvanced(false)
{preset.label}
</Button>
))}
</Space>
</div>
{/* 表达式 + 可读描述 */}
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input
value={internalValue}
onChange={(val) => {
setInternalValue(val)
if (isAdvanced && onChange) {
onChange(val)
}
}}
readOnly={!isAdvanced}
style={{ width: 240, fontFamily: 'monospace' }}
placeholder="* * * * *"
/>
<Space>
<Typography.Text type="secondary"></Typography.Text>
<Switch
checked={isAdvanced}
onChange={(checked) => {
setIsAdvanced(checked)
if (!checked) {
setState(parseCron(internalValue))
notifyChange(stringifyCron(parseCron(internalValue)))
}
}} }}
/> >
</Space> {preset.label}
</Button>
))}
<Button
size="small"
type={!isPreset && !isAdvanced ? 'primary' : 'secondary'}
onClick={() => {
setShowCustom(true)
setIsAdvanced(false)
}}
>
...
</Button>
</Space>
{/* 中文描述 + cron 表达式 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Input
value={cronExpr}
readOnly={!isAdvanced}
style={{ width: 180, fontFamily: 'monospace', fontSize: 13 }}
placeholder="0 2 * * *"
onChange={(val) => {
if (isAdvanced) emit(val)
}}
/>
{description && (
<Typography.Text type="secondary">{description}</Typography.Text>
)}
<div style={{ marginLeft: 'auto' }}>
<Space size="mini">
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Switch
size="small"
checked={isAdvanced}
onChange={(checked) => {
setIsAdvanced(checked)
setShowCustom(false)
if (!checked) {
setCronExpr(cronExpr)
}
}}
/>
</Space>
</div>
</div> </div>
{/* 中文可读描述 */} {/* 自定义选择器 */}
{description && ( {showCustom && !isAdvanced && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, marginTop: 0 }}> <div style={{ padding: '12px 16px', background: 'var(--color-fill-1)', borderRadius: 6 }}>
{description} <Space size="large" style={{ marginBottom: 12 }}>
</Typography.Paragraph> <Button size="small" type={mode === 'daily' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'daily' })}>
)}
</Button>
<Button size="small" type={mode === 'weekly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'weekly' })}>
</Button>
<Button size="small" type={mode === 'monthly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'monthly' })}>
</Button>
<Button size="small" type={mode === 'interval' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'interval' })}>
</Button>
</Space>
{!isAdvanced && ( {mode === 'interval' ? (
<Tabs type="card-gutter" size="small"> <Space align="center">
<Tabs.TabPane key="minute" title="分钟"> <Typography.Text></Typography.Text>
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')} <Select
</Tabs.TabPane> size="small"
<Tabs.TabPane key="hour" title="小时"> value={customInterval}
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')} style={{ width: 80 }}
</Tabs.TabPane> options={[
<Tabs.TabPane key="day" title="日"> { label: '1', value: '1' },
{renderPartTab('day', '', DAYS_OPTIONS, '*')} { label: '2', value: '2' },
</Tabs.TabPane> { label: '3', value: '3' },
<Tabs.TabPane key="month" title="月"> { label: '4', value: '4' },
{renderPartTab('month', '', MONTHS_OPTIONS, '*')} { label: '6', value: '6' },
</Tabs.TabPane> { label: '8', value: '8' },
<Tabs.TabPane key="week" title="周"> { label: '12', value: '12' },
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')} ]}
</Tabs.TabPane> onChange={(val) => handleCustomChange({ interval: val })}
</Tabs> />
<Typography.Text></Typography.Text>
</Space>
) : (
<>
{mode === 'weekly' && (
<div style={{ marginBottom: 8 }}>
<Space wrap size="mini">
{WEEKDAY_OPTIONS.map((opt) => (
<Button
key={opt.value}
size="mini"
type={customWeekdays.includes(opt.value) ? 'primary' : 'secondary'}
onClick={() => {
const next = customWeekdays.includes(opt.value)
? customWeekdays.filter((v) => v !== opt.value)
: [...customWeekdays, opt.value]
handleCustomChange({ weekdays: next.length > 0 ? next : [opt.value] })
}}
>
{opt.label}
</Button>
))}
</Space>
</div>
)}
{mode === 'monthly' && (
<div style={{ marginBottom: 8 }}>
<Space align="center">
<Typography.Text></Typography.Text>
<Select
size="small"
value={customDay}
style={{ width: 90 }}
options={DAY_OPTIONS}
onChange={(val) => handleCustomChange({ day: val })}
/>
</Space>
</div>
)}
<Space align="center">
<Typography.Text></Typography.Text>
<Select
size="small"
value={customHour}
style={{ width: 90 }}
options={HOUR_OPTIONS}
onChange={(val) => handleCustomChange({ hour: val })}
/>
<Typography.Text>:</Typography.Text>
<Select
size="small"
value={customMinute}
style={{ width: 90 }}
options={MINUTE_OPTIONS}
onChange={(val) => handleCustomChange({ minute: val })}
/>
</Space>
</>
)}
</div>
)} )}
</div> </div>
) )

View File

@@ -301,5 +301,5 @@ export const storageTargetTypeOptions = [
{ label: 'Google Drive', value: 'google_drive' }, { label: 'Google Drive', value: 'google_drive' },
{ label: 'WebDAV', value: 'webdav' }, { label: 'WebDAV', value: 'webdav' },
{ label: 'FTP', value: 'ftp' }, { label: 'FTP', value: 'ftp' },
{ label: 'Rclone (70+ 后端)', value: 'rclone' }, { label: 'Rclone — SFTP / Azure / Dropbox / OneDrive 等 70+ 后端', value: 'rclone' },
] as const ] as const