Compare commits

..

1 Commits

Author SHA1 Message Date
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
4 changed files with 15 additions and 90 deletions

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,5 +1,5 @@
import { Button, Input, Space, Switch, Tabs, Typography, Radio, Select } from '@arco-design/web-react' import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
export interface CronInputProps { export interface CronInputProps {
value?: string value?: string
@@ -18,18 +18,6 @@ interface CronState {
week: string week: string
} }
// 常用预设
const PRESETS = [
{ label: '每天 02:00', value: '0 2 * * *' },
{ label: '每天 00:00', value: '0 0 * * *' },
{ label: '每 6 小时', value: '0 */6 * * *' },
{ label: '每 12 小时', value: '0 */12 * * *' },
{ label: '每周日 03:00', value: '0 3 * * 0' },
{ label: '每月 1 日 02:00', value: '0 2 1 * *' },
{ label: '每 30 分钟', value: '*/30 * * * *' },
{ label: '每小时', value: '0 * * * *' },
]
function parseCron(expr: string): CronState { function parseCron(expr: string): CronState {
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/) const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
return { return {
@@ -45,43 +33,6 @@ function stringifyCron(state: CronState): string {
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}` return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
} }
// 将 cron 表达式转为中文可读描述
function describeCron(expr: string): string {
const parts = expr.trim().split(/\s+/)
if (parts.length !== 5) return ''
const [minute, hour, day, month, week] = parts
const segments: string[] = []
// 月
if (month !== '*') segments.push(`${month}`)
// 日
if (day !== '*') segments.push(`${day}`)
// 周
if (week !== '*') {
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
const weekDesc = week.split(',').map((w) => weekNames[w] || w).join('、')
segments.push(`星期${weekDesc}`)
}
// 小时
if (hour.includes('/')) {
segments.push(`${hour.split('/')[1]} 小时`)
} else if (hour !== '*') {
segments.push(`${hour.padStart(2, '0')}`)
}
// 分钟
if (minute.includes('/')) {
segments.push(`${minute.split('/')[1]} 分钟`)
} else if (minute !== '*') {
segments.push(`${minute.padStart(2, '0')}`)
} else if (hour !== '*' && !hour.includes('/')) {
segments.push('00 分')
}
if (segments.length === 0) return '每分钟执行'
return segments.join(' ') + ' 执行'
}
function generateOptions(min: number, max: number) { function generateOptions(min: number, max: number) {
return Array.from({ length: max - min + 1 }, (_, i) => ({ return Array.from({ length: max - min + 1 }, (_, i) => ({
label: String(i + min), label: String(i + min),
@@ -118,8 +69,6 @@ export function CronInput({ value, onChange }: CronInputProps) {
} }
}, [value, isAdvanced, internalValue]) }, [value, isAdvanced, internalValue])
const description = useMemo(() => describeCron(internalValue), [internalValue])
const notifyChange = (nextValue: string) => { const notifyChange = (nextValue: string) => {
setInternalValue(nextValue) setInternalValue(nextValue)
if (onChange) { if (onChange) {
@@ -133,12 +82,6 @@ export function CronInput({ value, onChange }: CronInputProps) {
notifyChange(stringifyCron(nextState)) notifyChange(stringifyCron(nextState))
} }
const handlePreset = (cronExpr: string) => {
setInternalValue(cronExpr)
setState(parseCron(cronExpr))
if (onChange) onChange(cronExpr)
}
const renderPartTab = ( const renderPartTab = (
part: CronPart, part: CronPart,
title: string, title: string,
@@ -148,7 +91,8 @@ export function CronInput({ value, onChange }: CronInputProps) {
const currentVal = state[part] const currentVal = state[part]
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?' const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-') const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
const type = isAny ? 'any' : 'specific' const type = isAny ? 'any' : 'specific'
const specificValues = isSpecific ? currentVal.split(',') : [] const specificValues = isSpecific ? currentVal.split(',') : []
@@ -161,7 +105,7 @@ export function CronInput({ value, onChange }: CronInputProps) {
if (val === 'any') { if (val === 'any') {
handleStateChange(part, allowAnyVal) handleStateChange(part, allowAnyVal)
} else { } else {
handleStateChange(part, options[0].value) handleStateChange(part, options[0].value) // Default to first valid item
} }
}} }}
> >
@@ -184,6 +128,7 @@ export function CronInput({ value, onChange }: CronInputProps) {
if (vals.length === 0) { if (vals.length === 0) {
handleStateChange(part, allowAnyVal) handleStateChange(part, allowAnyVal)
} else { } else {
// Sort numerically to keep things neat
const sorted = [...vals].sort((a, b) => Number(a) - Number(b)) const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
handleStateChange(part, sorted.join(',')) handleStateChange(part, sorted.join(','))
} }
@@ -199,24 +144,6 @@ export function CronInput({ value, onChange }: CronInputProps) {
return ( return (
<div className="cron-input-container"> <div className="cron-input-container">
{/* 常用预设 */}
<div style={{ marginBottom: 12 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap size="small">
{PRESETS.map((preset) => (
<Button
key={preset.value}
size="small"
type={internalValue === preset.value ? 'primary' : 'secondary'}
onClick={() => handlePreset(preset.value)}
>
{preset.label}
</Button>
))}
</Space>
</div>
{/* 表达式 + 可读描述 */}
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Input <Input
value={internalValue} value={internalValue}
@@ -231,12 +158,13 @@ export function CronInput({ value, onChange }: CronInputProps) {
placeholder="* * * * *" placeholder="* * * * *"
/> />
<Space> <Space>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"> ()</Typography.Text>
<Switch <Switch
checked={isAdvanced} checked={isAdvanced}
onChange={(checked) => { onChange={(checked) => {
setIsAdvanced(checked) setIsAdvanced(checked)
if (!checked) { if (!checked) {
// When switching back to visual, parse the current raw value
setState(parseCron(internalValue)) setState(parseCron(internalValue))
notifyChange(stringifyCron(parseCron(internalValue))) notifyChange(stringifyCron(parseCron(internalValue)))
} }
@@ -245,13 +173,6 @@ export function CronInput({ value, onChange }: CronInputProps) {
</Space> </Space>
</div> </div>
{/* 中文可读描述 */}
{description && (
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, marginTop: 0 }}>
{description}
</Typography.Paragraph>
)}
{!isAdvanced && ( {!isAdvanced && (
<Tabs type="card-gutter" size="small"> <Tabs type="card-gutter" size="small">
<Tabs.TabPane key="minute" title="分钟"> <Tabs.TabPane key="minute" title="分钟">

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