feat: refactor backup scheduling to use interval hours and update UI components

This commit is contained in:
shuaiplus
2026-03-20 05:44:00 +08:00
parent fba2aa9746
commit c2b920532d
6 changed files with 202 additions and 230 deletions
+3 -10
View File
@@ -1,11 +1,10 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_SCHEDULE_TIME = '03:00';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto'; export const BACKUP_DEFAULT_E3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export type BackupDestinationType = 'e3' | 'webdav'; export type BackupDestinationType = 'e3' | 'webdav';
export type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly';
export interface E3BackupDestination { export interface E3BackupDestination {
endpoint: string; endpoint: string;
@@ -40,11 +39,8 @@ export interface BackupRuntimeState {
export interface BackupScheduleConfig { export interface BackupScheduleConfig {
enabled: boolean; enabled: boolean;
frequency: BackupScheduleFrequency; intervalHours: number;
scheduleTime: string;
timezone: string; timezone: string;
dayOfWeek: number;
dayOfMonth: number;
retentionCount: number | null; retentionCount: number | null;
} }
@@ -85,11 +81,8 @@ export function createDefaultBackupRuntimeState(): BackupRuntimeState {
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig { export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
return { return {
enabled: false, enabled: false,
frequency: 'daily', intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
scheduleTime: BACKUP_DEFAULT_SCHEDULE_TIME,
timezone, timezone,
dayOfWeek: 1,
dayOfMonth: 1,
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT, retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
}; };
} }
+29 -99
View File
@@ -7,14 +7,13 @@ import {
parseBackupSettingsEnvelope, parseBackupSettingsEnvelope,
} from './backup-settings-crypto'; } from './backup-settings-crypto';
import { import {
BACKUP_DEFAULT_SCHEDULE_TIME, BACKUP_DEFAULT_INTERVAL_HOURS,
BACKUP_DEFAULT_TIMEZONE, BACKUP_DEFAULT_TIMEZONE,
type BackupDestinationConfig, type BackupDestinationConfig,
type BackupDestinationRecord, type BackupDestinationRecord,
type BackupDestinationType, type BackupDestinationType,
type BackupRuntimeState, type BackupRuntimeState,
type BackupScheduleConfig, type BackupScheduleConfig,
type BackupScheduleFrequency,
type BackupSettings, type BackupSettings,
type E3BackupDestination, type E3BackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
@@ -73,19 +72,6 @@ function assertValidTimeZone(timezone: string): string {
} }
} }
function assertValidScheduleTime(value: string): string {
if (!/^\d{2}:\d{2}$/.test(value)) {
throw new Error('Backup time must use HH:MM format');
}
const [hoursRaw, minutesRaw] = value.split(':');
const hours = Number(hoursRaw);
const minutes = Number(minutesRaw);
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
throw new Error('Backup time is invalid');
}
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null { function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
if (value === undefined) return fallback; if (value === undefined) return fallback;
if (value === null || String(value).trim() === '') return null; if (value === null || String(value).trim() === '') return null;
@@ -96,33 +82,12 @@ function normalizeRetentionCount(value: unknown, fallback: number | null = 30):
return count; return count;
} }
function normalizeScheduleFrequency( function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
value: unknown, const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
fallback: BackupScheduleFrequency = 'daily' if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
): BackupScheduleFrequency { throw new Error('Backup interval hours must be between 1 and 99');
const frequency = asTrimmedString(value) || fallback;
if (frequency !== 'daily' && frequency !== 'weekly' && frequency !== 'monthly') {
throw new Error('Backup frequency is invalid');
} }
return frequency; return raw;
}
function normalizeDayOfWeek(value: unknown, fallback: number = 1): number {
if (value === undefined || value === null || value === '') return fallback;
const day = Number(value);
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new Error('Backup day of week is invalid');
}
return day;
}
function normalizeDayOfMonth(value: unknown, fallback: number = 1): number {
if (value === undefined || value === null || value === '') return fallback;
const day = Number(value);
if (!Number.isInteger(day) || day < 1 || day > 31) {
throw new Error('Backup day of month must be between 1 and 31');
}
return day;
} }
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination { function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
@@ -250,11 +215,11 @@ function normalizeDestinationRecord(
: previousSchedule.retentionCount; : previousSchedule.retentionCount;
const schedule: BackupScheduleConfig = { const schedule: BackupScheduleConfig = {
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled), enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
frequency: normalizeScheduleFrequency(scheduleSource.frequency ?? previousSchedule.frequency, previousSchedule.frequency), intervalHours: normalizeIntervalHours(
scheduleTime: assertValidScheduleTime(asTrimmedString(scheduleSource.scheduleTime ?? previousSchedule.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME), scheduleSource.intervalHours ?? previousSchedule.intervalHours,
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
),
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
dayOfWeek: normalizeDayOfWeek(scheduleSource.dayOfWeek ?? previousSchedule.dayOfWeek, previousSchedule.dayOfWeek),
dayOfMonth: normalizeDayOfMonth(scheduleSource.dayOfMonth ?? previousSchedule.dayOfMonth, previousSchedule.dayOfMonth),
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
}; };
@@ -274,6 +239,12 @@ function normalizeDestinationRecord(
} }
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings { function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
const intervalHours = legacyFrequency === 'weekly'
? 24 * 7
: legacyFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const destinationTypeRaw = asTrimmedString(rawValue.destinationType); const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType = const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
@@ -287,11 +258,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
destination: normalizeDestination(destinationType, rawValue.destination), destination: normalizeDestination(destinationType, rawValue.destination),
schedule: { schedule: {
enabled: !!rawValue.enabled, enabled: !!rawValue.enabled,
frequency: 'daily', intervalHours,
scheduleTime: assertValidScheduleTime(asTrimmedString(rawValue.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
dayOfWeek: 1,
dayOfMonth: 1,
retentionCount: 30, retentionCount: 30,
}, },
runtime: normalizeRuntime(rawValue.runtime), runtime: normalizeRuntime(rawValue.runtime),
@@ -338,10 +306,15 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
try { try {
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as Record<string, unknown>;
if (Array.isArray(parsed.destinations)) { if (Array.isArray(parsed.destinations)) {
const globalScheduleTime = assertValidScheduleTime(asTrimmedString(parsed.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME);
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE); const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
const globalEnabled = !!parsed.enabled; const globalEnabled = !!parsed.enabled;
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId); const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
const globalIntervalHours = globalFrequency === 'weekly'
? 24 * 7
: globalFrequency === 'monthly'
? 24 * 30
: BACKUP_DEFAULT_INTERVAL_HOURS;
const previousById = new Map<string, BackupDestinationRecord>(); const previousById = new Map<string, BackupDestinationRecord>();
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => { const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
if (!isPlainObject(entry)) return entry; if (!isPlainObject(entry)) return entry;
@@ -352,11 +325,8 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
...entry, ...entry,
schedule: { schedule: {
enabled: scheduleEnabled, enabled: scheduleEnabled,
frequency: 'daily', intervalHours: globalIntervalHours,
scheduleTime: globalScheduleTime,
timezone: globalTimezone, timezone: globalTimezone,
dayOfWeek: 1,
dayOfMonth: 1,
retentionCount: 30, retentionCount: 30,
}, },
}; };
@@ -515,29 +485,6 @@ function getDateTimeParts(date: Date, timezone: string): { year: string; month:
}; };
} }
function getLocalWeekday(date: Date, timezone: string): number {
const value = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
weekday: 'short',
}).format(date);
const map: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
return map[value] ?? 0;
}
function getMonthLastDay(date: Date, timezone: string): number {
const { year, month } = getDateTimeParts(date, timezone);
const utcDate = new Date(Date.UTC(Number(year), Number(month), 0));
return utcDate.getUTCDate();
}
export function getBackupLocalDateKey(date: Date, timezone: string): string { export function getBackupLocalDateKey(date: Date, timezone: string): string {
const parts = getDateTimeParts(date, timezone); const parts = getDateTimeParts(date, timezone);
return `${parts.year}-${parts.month}-${parts.day}`; return `${parts.year}-${parts.month}-${parts.day}`;
@@ -548,32 +495,15 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
return `${parts.hour}:${parts.minute}`; return `${parts.hour}:${parts.minute}`;
} }
function toMinutes(value: string): number {
const [hoursRaw, minutesRaw] = value.split(':');
return Number(hoursRaw) * 60 + Number(minutesRaw);
}
export function isBackupDueNow( export function isBackupDueNow(
destination: BackupDestinationRecord, destination: BackupDestinationRecord,
now: Date, now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone)); const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime); const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
const delta = currentMinutes - scheduledMinutes; if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
if (delta < 0 || delta >= windowMinutes) return false; return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
if (destination.schedule.frequency === 'weekly') {
return getLocalWeekday(now, destination.schedule.timezone) === destination.schedule.dayOfWeek;
}
if (destination.schedule.frequency === 'monthly') {
const currentDay = Number(getDateTimeParts(now, destination.schedule.timezone).day);
const scheduledDay = Math.min(destination.schedule.dayOfMonth, getMonthLastDay(now, destination.schedule.timezone));
return currentDay === scheduledDay;
}
return true;
} }
@@ -5,12 +5,14 @@ import type {
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api/backup'; } 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 type { RecommendedProvider } from '@/lib/backup-recommendations';
import { RemoteBackupBrowser } from './RemoteBackupBrowser'; import { RemoteBackupBrowser } from './RemoteBackupBrowser';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
const INTERVAL_HOUR_PRESETS = [1, 6, 12, 24];
interface BackupDestinationDetailProps { interface BackupDestinationDetailProps {
selectedRecommendedProvider: RecommendedProvider | null; selectedRecommendedProvider: RecommendedProvider | null;
selectedDestination: BackupDestinationRecord | null; selectedDestination: BackupDestinationRecord | null;
@@ -206,41 +208,53 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
<div className="field-grid backup-detail-schedule-grid"> <div className="field-grid backup-detail-schedule-grid">
<label className="field"> <label className="field">
<span>{t('txt_backup_frequency')}</span> <span>{t('txt_backup_interval_hours')}</span>
<select <div className="backup-interval-row">
className="input" <div className="backup-inline-suffix-wrap">
value={props.selectedDestination.schedule.frequency} <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} disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({ 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, ...destination,
schedule: { schedule: {
...destination.schedule, ...destination.schedule,
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly', intervalHours: value,
dayOfWeek: destination.schedule.dayOfWeek ?? 1, },
dayOfMonth: destination.schedule.dayOfMonth ?? 1, }));
}}
/>
<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,
}, },
}))} }))}
> >
<option value="daily">{t('txt_backup_frequency_daily')}</option> {preset}
<option value="weekly">{t('txt_backup_frequency_weekly')}</option> </button>
<option value="monthly">{t('txt_backup_frequency_monthly')}</option> );
</select> })}
</label> </div>
<label className="field"> </div>
<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,
},
}))}
/>
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_timezone')}</span> <span>{t('txt_backup_timezone')}</span>
@@ -263,17 +277,17 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_retention_count')}</span> <span>{t('txt_backup_retention_count')}</span>
<div className="backup-retention-input"> <div className="backup-inline-suffix-wrap">
<input <input
className="input" className="input backup-inline-suffix-input"
type="number" type="text"
min="1" inputMode="numeric"
step="1" pattern="[0-9]*"
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)} value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="30" placeholder="30"
onInput={(event) => { onInput={(event) => {
const nextValue = (event.currentTarget as HTMLInputElement).value.trim(); const nextValue = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '').trim();
props.onUpdateDestination((destination) => ({ props.onUpdateDestination((destination) => ({
...destination, ...destination,
schedule: { schedule: {
@@ -283,11 +297,12 @@ 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> </div>
</label> </label>
</div> </div>
<div className="backup-schedule-attachments-row">
<BackupIncludeAttachmentsField <BackupIncludeAttachmentsField
checked={props.selectedDestination.includeAttachments} checked={props.selectedDestination.includeAttachments}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
@@ -296,54 +311,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
includeAttachments: checked, 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> </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}
{props.selectedDestination.type === 'webdav' ? ( {props.selectedDestination.type === 'webdav' ? (
<div className="field-grid"> <div className="field-grid">
@@ -5,6 +5,7 @@ interface BackupIncludeAttachmentsFieldProps {
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
showHelp?: boolean; showHelp?: boolean;
showLabel?: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
} }
@@ -34,7 +35,7 @@ export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFie
disabled={props.disabled} disabled={props.disabled}
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)} 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> </label>
{props.showHelp !== false ? ( {props.showHelp !== false ? (
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}> <div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
+6
View File
@@ -141,6 +141,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_destination_reserved: "Reserved Slot", txt_backup_destination_reserved: "Reserved Slot",
txt_backup_time: "Backup Time", txt_backup_time: "Backup Time",
txt_backup_timezone: "Timezone", 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: "Frequency",
txt_backup_frequency_daily: "Daily", txt_backup_frequency_daily: "Daily",
txt_backup_frequency_weekly: "Weekly", txt_backup_frequency_weekly: "Weekly",
@@ -759,6 +762,9 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_destination_reserved: '预留位置', txt_backup_destination_reserved: '预留位置',
txt_backup_time: '备份时间', txt_backup_time: '备份时间',
txt_backup_timezone: '时区', txt_backup_timezone: '时区',
txt_backup_interval_hours: '每隔',
txt_backup_interval_hours_suffix: '小时',
txt_backup_interval_hours_presets: '快捷时间预设',
txt_backup_frequency: '备份频率', txt_backup_frequency: '备份频率',
txt_backup_frequency_daily: '每天', txt_backup_frequency_daily: '每天',
txt_backup_frequency_weekly: '每周', txt_backup_frequency_weekly: '每周',
+95 -21
View File
@@ -1443,7 +1443,6 @@ input[type='file'].input::file-selector-button:hover {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
} }
.backup-option-label { .backup-option-label {
@@ -1749,7 +1748,49 @@ input[type='file'].input::file-selector-button:hover {
} }
.backup-detail-schedule-grid { .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 { .backup-retention-input {
@@ -1765,6 +1806,31 @@ input[type='file'].input::file-selector-button:hover {
width: 100%; 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 { .backup-retention-suffix {
color: #475467; color: #475467;
white-space: nowrap; white-space: nowrap;
@@ -2772,47 +2838,51 @@ input[type='file'].input::file-selector-button:hover {
} }
.list-head { .list-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px; gap: 8px;
align-items: center;
} }
.list-count { .list-count {
order: 10; grid-column: auto;
width: 100%; width: auto;
font-size: 12px; font-size: 12px;
white-space: nowrap;
} }
.list-head .search-input { .list-head .search-input {
width: 100%;
min-width: 0;
height: 42px; height: 42px;
border-radius: 14px; border-radius: 14px;
} }
.list-icon-btn { .list-icon-btn {
width: 38px; width: auto;
min-width: 38px; min-width: 0;
padding: 0; padding: 0 12px;
font-size: 0; font-size: 13px;
gap: 0; gap: 6px;
} white-space: nowrap;
.list-icon-btn .btn-icon {
margin: 0;
} }
.toolbar.actions { .toolbar.actions {
justify-content: flex-start; justify-content: flex-end;
flex-wrap: nowrap; flex-wrap: wrap;
overflow-x: auto; overflow: visible;
padding-bottom: 2px; padding-bottom: 2px;
scrollbar-width: none;
}
.toolbar.actions::-webkit-scrollbar {
display: none;
} }
.toolbar.actions .btn.small { .toolbar.actions .btn.small {
width: auto;
min-width: 0;
height: 34px; height: 34px;
padding: 0 12px;
font-size: 13px; font-size: 13px;
gap: 6px;
border-radius: 999px;
white-space: nowrap;
} }
.mobile-fab-wrap { .mobile-fab-wrap {
@@ -3136,6 +3206,10 @@ input[type='file'].input::file-selector-button:hover {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.backup-interval-row {
grid-template-columns: 1fr;
}
.backup-status-grid, .backup-status-grid,
.backup-browser-row, .backup-browser-row,
.field-grid { .field-grid {