mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: refactor backup scheduling to use interval hours and update UI components
This commit is contained in:
@@ -5,12 +5,14 @@ import type {
|
||||
RemoteBackupBrowserResponse,
|
||||
WebDavBackupDestination,
|
||||
} from '@/lib/api/backup';
|
||||
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||
|
||||
const INTERVAL_HOUR_PRESETS = [1, 6, 12, 24];
|
||||
|
||||
interface BackupDestinationDetailProps {
|
||||
selectedRecommendedProvider: RecommendedProvider | null;
|
||||
selectedDestination: BackupDestinationRecord | null;
|
||||
@@ -206,41 +208,53 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
|
||||
<div className="field-grid backup-detail-schedule-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_frequency')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.selectedDestination.schedule.frequency}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly',
|
||||
dayOfWeek: destination.schedule.dayOfWeek ?? 1,
|
||||
dayOfMonth: destination.schedule.dayOfMonth ?? 1,
|
||||
},
|
||||
}))}
|
||||
>
|
||||
<option value="daily">{t('txt_backup_frequency_daily')}</option>
|
||||
<option value="weekly">{t('txt_backup_frequency_weekly')}</option>
|
||||
<option value="monthly">{t('txt_backup_frequency_monthly')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_time')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="time"
|
||||
value={props.selectedDestination.schedule.scheduleTime}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
scheduleTime: (event.currentTarget as HTMLInputElement).value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<span>{t('txt_backup_interval_hours')}</span>
|
||||
<div className="backup-interval-row">
|
||||
<div className="backup-inline-suffix-wrap">
|
||||
<input
|
||||
className="input backup-inline-suffix-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => {
|
||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||
const value = Math.min(99, Math.max(1, Number(raw || 1)));
|
||||
props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
intervalHours: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="backup-inline-suffix">{t('txt_backup_interval_hours_suffix')}</span>
|
||||
</div>
|
||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className={`backup-interval-preset${active ? ' active' : ''}`}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onClick={() => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
intervalHours: preset,
|
||||
},
|
||||
}))}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_timezone')}</span>
|
||||
@@ -263,17 +277,17 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_retention_count')}</span>
|
||||
<div className="backup-retention-input">
|
||||
<div className="backup-inline-suffix-wrap">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
className="input backup-inline-suffix-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="30"
|
||||
onInput={(event) => {
|
||||
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
|
||||
const nextValue = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '').trim();
|
||||
props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
@@ -283,67 +297,21 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="backup-retention-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||
<span className="backup-inline-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<BackupIncludeAttachmentsField
|
||||
checked={props.selectedDestination.includeAttachments}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(checked) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
includeAttachments: checked,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{props.selectedDestination.schedule.frequency === 'weekly' ? (
|
||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_day_of_week')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.selectedDestination.schedule.dayOfWeek)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
dayOfWeek: Number((event.currentTarget as HTMLSelectElement).value),
|
||||
},
|
||||
}))}
|
||||
>
|
||||
{WEEKDAY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={String(option.value)}>{t(option.label)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.selectedDestination.schedule.frequency === 'monthly' ? (
|
||||
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_day_of_month')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
step="1"
|
||||
value={String(props.selectedDestination.schedule.dayOfMonth || 1)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
schedule: {
|
||||
...destination.schedule,
|
||||
dayOfMonth: Math.min(31, Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1)),
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="backup-schedule-attachments-row">
|
||||
<BackupIncludeAttachmentsField
|
||||
checked={props.selectedDestination.includeAttachments}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(checked) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
includeAttachments: checked,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.selectedDestination.type === 'webdav' ? (
|
||||
<div className="field-grid">
|
||||
|
||||
@@ -5,6 +5,7 @@ interface BackupIncludeAttachmentsFieldProps {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
showHelp?: boolean;
|
||||
showLabel?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFie
|
||||
disabled={props.disabled}
|
||||
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t('txt_backup_include_attachments')}</span>
|
||||
{props.showLabel !== false ? <span>{t('txt_backup_include_attachments')}</span> : null}
|
||||
</label>
|
||||
{props.showHelp !== false ? (
|
||||
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
|
||||
|
||||
@@ -141,6 +141,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_destination_reserved: "Reserved Slot",
|
||||
txt_backup_time: "Backup Time",
|
||||
txt_backup_timezone: "Timezone",
|
||||
txt_backup_interval_hours: "Every",
|
||||
txt_backup_interval_hours_suffix: "hours",
|
||||
txt_backup_interval_hours_presets: "Quick interval presets",
|
||||
txt_backup_frequency: "Frequency",
|
||||
txt_backup_frequency_daily: "Daily",
|
||||
txt_backup_frequency_weekly: "Weekly",
|
||||
@@ -759,6 +762,9 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_destination_reserved: '预留位置',
|
||||
txt_backup_time: '备份时间',
|
||||
txt_backup_timezone: '时区',
|
||||
txt_backup_interval_hours: '每隔',
|
||||
txt_backup_interval_hours_suffix: '小时',
|
||||
txt_backup_interval_hours_presets: '快捷时间预设',
|
||||
txt_backup_frequency: '备份频率',
|
||||
txt_backup_frequency_daily: '每天',
|
||||
txt_backup_frequency_weekly: '每周',
|
||||
|
||||
+95
-21
@@ -1443,7 +1443,6 @@ input[type='file'].input::file-selector-button:hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.backup-option-label {
|
||||
@@ -1749,7 +1748,49 @@ input[type='file'].input::file-selector-button:hover {
|
||||
}
|
||||
|
||||
.backup-detail-schedule-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.backup-interval-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 86px;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.backup-interval-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.backup-interval-preset {
|
||||
height: 22px;
|
||||
border: 1px solid #cdd7e6;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.backup-interval-preset:hover:not(:disabled) {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.backup-interval-preset.active {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.backup-interval-preset:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.backup-retention-input {
|
||||
@@ -1765,6 +1806,31 @@ input[type='file'].input::file-selector-button:hover {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-inline-suffix-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-inline-suffix-input {
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.backup-inline-suffix {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backup-schedule-attachments-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.backup-retention-suffix {
|
||||
color: #475467;
|
||||
white-space: nowrap;
|
||||
@@ -2772,47 +2838,51 @@ input[type='file'].input::file-selector-button:hover {
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
order: 10;
|
||||
width: 100%;
|
||||
grid-column: auto;
|
||||
width: auto;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.list-icon-btn .btn-icon {
|
||||
margin: 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar.actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar.actions::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar.actions .btn.small {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap {
|
||||
@@ -3136,6 +3206,10 @@ input[type='file'].input::file-selector-button:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.backup-interval-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-status-grid,
|
||||
.backup-browser-row,
|
||||
.field-grid {
|
||||
|
||||
Reference in New Issue
Block a user